Compare commits
54 Commits
93d9027e2c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a50fc9fd7 | ||
|
|
1718c3c68e | ||
|
|
356411d388 | ||
|
|
87062c288e | ||
|
|
a2f7c5f466 | ||
|
|
83b078b9ae | ||
|
|
d78fe02411 | ||
|
|
7f8bf5e3a9 | ||
|
|
e3103d27e7 | ||
|
|
8a9a3d7988 | ||
|
|
884e8802bd | ||
|
|
1606efa09f | ||
|
|
633b19f80d | ||
|
|
2fae816162 | ||
|
|
2201ac8699 | ||
|
|
a6bbe33e4b | ||
|
|
4c197eef91 | ||
|
|
e9f65c2845 | ||
|
|
2fccaaac40 | ||
|
|
3178366e0e | ||
|
|
fa47538a8f | ||
|
|
7a4ab2279d | ||
|
|
7cbfcb2d0d | ||
|
|
46a6ebc9dd | ||
|
|
f6bf7ce0ed | ||
|
|
96733b55cd | ||
|
|
3ed285c9d1 | ||
|
|
6a21a4d711 | ||
|
|
bbfb9864bd | ||
|
|
c29aa695d4 | ||
|
|
4db351cb45 | ||
|
|
af1c59b75c | ||
|
|
738ab12631 | ||
|
|
46f999c676 | ||
|
|
39f0902a0f | ||
|
|
fd3c2dc69a | ||
|
|
1411c750e4 | ||
|
|
1ef5903da1 | ||
|
|
d662860c61 | ||
|
|
a1d3ce707c | ||
|
|
275d74b18c | ||
|
|
38556f9bf5 | ||
|
|
972ae92291 | ||
|
|
0b5a81c3e6 | ||
|
|
64389b3cf9 | ||
|
|
a2a1aa2c7a | ||
|
|
9b66a415ff | ||
|
|
bbd2ec46dd | ||
|
|
3b10984233 | ||
|
|
9a874ad032 | ||
|
|
da22154f66 | ||
|
|
69943f91be | ||
|
|
68605d837d | ||
|
|
d7d2421fc2 |
4
.dockerignore
Normal file
4
.dockerignore
Normal file
@@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
.git
|
||||
.env
|
||||
*.log
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,5 +1,6 @@
|
||||
node_modules/
|
||||
data/
|
||||
data/uploads/
|
||||
data/reports/
|
||||
.env
|
||||
*.log
|
||||
.DS_Store
|
||||
|
||||
70
CLAUDE.md
70
CLAUDE.md
@@ -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
14
Dockerfile
Normal 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
433
README.md
@@ -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> •
|
||||
<a href="#funcionalidades">Funcionalidades</a> •
|
||||
<a href="#quick-start">Quick Start</a> •
|
||||
<a href="#arquitetura">Arquitetura</a> •
|
||||
<a href="#api">API</a> •
|
||||
<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
|
||||
|
||||
- **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.
|
||||
- **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.
|
||||
### Agentes
|
||||
|
||||
## 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+
|
||||
- **Claude Code CLI** instalado e autenticado (`claude` disponível no PATH)
|
||||
### Execucao
|
||||
|
||||
## 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
|
||||
git clone <repo-url>
|
||||
git clone https://github.com/fredac100/agents-orchestrator.git
|
||||
cd agents-orchestrator
|
||||
npm install
|
||||
npm start
|
||||
```
|
||||
|
||||
## Uso
|
||||
Acesse `http://localhost:3000`.
|
||||
|
||||
### Com Docker
|
||||
|
||||
```bash
|
||||
# Produção
|
||||
npm start
|
||||
|
||||
# Desenvolvimento (hot reload)
|
||||
npm run dev
|
||||
docker build -t agents-orchestrator .
|
||||
docker run -p 3000:3000 \
|
||||
-v $(pwd)/data:/app/data \
|
||||
-v ~/.claude:/home/node/.claude \
|
||||
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
|
||||
|
||||
```
|
||||
server.js Express + WebSocket na mesma porta
|
||||
src/
|
||||
routes/api.js API REST (/api/*)
|
||||
agents/
|
||||
manager.js CRUD + orquestração de agentes
|
||||
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)
|
||||
public/
|
||||
index.html SPA single-page
|
||||
css/styles.css Estilos (Inter, JetBrains Mono, Lucide)
|
||||
js/
|
||||
app.js Controlador principal + WebSocket client
|
||||
api.js Client HTTP para a API
|
||||
components/ UI por seção (dashboard, agents, tasks, etc.)
|
||||
data/
|
||||
agents.json Agentes cadastrados
|
||||
tasks.json Templates de tarefas
|
||||
pipelines.json Pipelines configurados
|
||||
HTTPS (443)
|
||||
|
|
||||
[Caddy] ─── SSL automatico via DuckDNS
|
||||
|
|
||||
*.nitro-cloud.duckdns.org
|
||||
|
|
||||
┌──────────────┼──────────────┐
|
||||
| | |
|
||||
[agents.*] [git.*] [projeto.*]
|
||||
| | |
|
||||
┌──────┴──────┐ [Gitea] [Caddy file_server]
|
||||
| |
|
||||
[Express] [WebSocket]
|
||||
| |
|
||||
├── API REST (40+ endpoints)
|
||||
├── Manager (CRUD + orquestracao)
|
||||
├── Executor (spawn claude CLI)
|
||||
├── Pipeline (sequencial + aprovacao)
|
||||
├── Scheduler (cron jobs)
|
||||
├── 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 |
|
||||
| `POST` | `/api/agents` | Criar agente |
|
||||
| `GET` | `/api/agents/:id` | Obter agente |
|
||||
| `PUT` | `/api/agents/:id` | Atualizar agente |
|
||||
| `DELETE` | `/api/agents/:id` | Excluir agente |
|
||||
| `POST` | `/api/agents/:id/execute` | Executar tarefa no agente |
|
||||
| `POST` | `/api/agents/:id/cancel/:executionId` | Cancelar execução |
|
||||
| `GET` | `/api/agents/:id/export` | Exportar agente (JSON) |
|
||||
| `GET` | `/api/tasks` | Listar tarefas |
|
||||
| `POST` | `/api/tasks` | Criar tarefa |
|
||||
| `PUT` | `/api/tasks/:id` | Atualizar tarefa |
|
||||
| `DELETE` | `/api/tasks/:id` | Excluir tarefa |
|
||||
| `GET` | `/api/schedules` | Listar agendamentos |
|
||||
| `POST` | `/api/schedules` | Criar agendamento |
|
||||
| `DELETE` | `/api/schedules/:taskId` | Remover agendamento |
|
||||
| `POST` | `/api/agents/:id/execute` | Executar tarefa (aceita `repoName` e `repoBranch`) |
|
||||
| `POST` | `/api/agents/:id/continue` | Continuar conversa |
|
||||
| `POST` | `/api/agents/:id/cancel/:execId` | Cancelar execucao |
|
||||
| `GET` | `/api/agents/:id/export` | Exportar agente |
|
||||
| `POST` | `/api/agents/:id/duplicate` | Duplicar agente |
|
||||
|
||||
### Pipelines
|
||||
|
||||
| Metodo | Endpoint | Descricao |
|
||||
|--------|----------|-----------|
|
||||
| `GET` | `/api/pipelines` | Listar pipelines |
|
||||
| `POST` | `/api/pipelines` | Criar pipeline |
|
||||
| `GET` | `/api/pipelines/:id` | Obter pipeline |
|
||||
| `PUT` | `/api/pipelines/:id` | Atualizar pipeline |
|
||||
| `DELETE` | `/api/pipelines/:id` | Excluir pipeline |
|
||||
| `POST` | `/api/pipelines/:id/execute` | Executar pipeline |
|
||||
| `POST` | `/api/pipelines/:id/cancel` | Cancelar pipeline |
|
||||
| `GET` | `/api/system/status` | Status geral do sistema |
|
||||
| `GET` | `/api/executions/active` | Execuções em andamento |
|
||||
| `POST` | `/api/pipelines/:id/execute` | Executar (aceita `repoName` e `repoBranch`) |
|
||||
| `POST` | `/api/pipelines/:id/approve` | Aprovar passo pendente |
|
||||
| `POST` | `/api/pipelines/:id/reject` | Rejeitar passo |
|
||||
| `POST` | `/api/pipelines/resume/:execId` | Retomar pipeline falho |
|
||||
|
||||
### Repositorios
|
||||
|
||||
| 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
|
||||
|
||||
O servidor envia eventos tipados via WebSocket que o frontend renderiza no terminal:
|
||||
|
||||
| Evento | Descrição |
|
||||
| Evento | Descricao |
|
||||
|--------|-----------|
|
||||
| `execution_output` | Chunk de texto da saída do agente |
|
||||
| `execution_complete` | Execução finalizada com resultado |
|
||||
| `execution_error` | Erro durante execução |
|
||||
| `pipeline_step_start` | Início de um passo do pipeline |
|
||||
| `pipeline_step_complete` | Passo do pipeline concluído |
|
||||
| `execution_output` | Chunk de saida do agente |
|
||||
| `execution_complete` | Execucao finalizada |
|
||||
| `execution_error` | Erro durante execucao |
|
||||
| `execution_retry` | Tentativa de retry |
|
||||
| `pipeline_step_start` | Inicio de passo |
|
||||
| `pipeline_step_complete` | Passo concluido |
|
||||
| `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
|
||||
|
||||
- **Backend**: Node.js, Express, WebSocket (ws), node-cron, uuid
|
||||
- **Frontend**: HTML, CSS, JavaScript vanilla (sem framework, sem bundler)
|
||||
- **Ícones**: Lucide
|
||||
- **Fontes**: Inter (UI), JetBrains Mono (código/terminal)
|
||||
- **Persistência**: Arquivos JSON em disco
|
||||
| Camada | Tecnologias |
|
||||
|--------|-------------|
|
||||
| **Backend** | Node.js 22, Express, WebSocket (ws), node-cron, uuid |
|
||||
| **Frontend** | HTML, CSS, JavaScript vanilla — sem framework, sem bundler |
|
||||
| **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
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
<sub>Desenvolvido por <a href="https://nitro-cloud.duckdns.org">Nitro Cloud</a></sub>
|
||||
</p>
|
||||
|
||||
5
data/agent-settings.json
Normal file
5
data/agent-settings.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"env": {
|
||||
"CLAUDE_CODE_MAX_OUTPUT_TOKENS": "128000"
|
||||
}
|
||||
}
|
||||
1656
data/agent_versions.json
Normal file
1656
data/agent_versions.json
Normal file
File diff suppressed because one or more lines are too long
1266
data/agents.json
Normal file
1266
data/agents.json
Normal file
File diff suppressed because one or more lines are too long
1266
data/executions.json
Normal file
1266
data/executions.json
Normal file
File diff suppressed because one or more lines are too long
296
data/notifications.json
Normal file
296
data/notifications.json
Normal file
@@ -0,0 +1,296 @@
|
||||
[
|
||||
{
|
||||
"id": "9f80ba16-4325-4129-9d35-9716ac68164a",
|
||||
"type": "success",
|
||||
"title": "Execução concluída",
|
||||
"message": "Agente \"Arquiteto de Software\" finalizou a tarefa",
|
||||
"metadata": {
|
||||
"agentId": "f356e42a-73db-4b04-bca2-1fb022f373b7",
|
||||
"executionId": "4863452b-259e-48ec-abf1-f41f0e4bf0c4"
|
||||
},
|
||||
"read": false,
|
||||
"createdAt": "2026-02-26T23:56:33.927Z",
|
||||
"created_at": "2026-02-26T23:56:33.927Z",
|
||||
"updated_at": "2026-02-26T23:56:33.927Z"
|
||||
},
|
||||
{
|
||||
"id": "ded8580c-f49c-4a9a-8855-43163afd68b9",
|
||||
"type": "success",
|
||||
"title": "Execução concluída",
|
||||
"message": "Agente \"Arquiteto de Software\" finalizou a tarefa",
|
||||
"metadata": {
|
||||
"agentId": "f356e42a-73db-4b04-bca2-1fb022f373b7",
|
||||
"executionId": "a4e88ba3-7d72-4069-848c-c98dbed801b1"
|
||||
},
|
||||
"read": false,
|
||||
"createdAt": "2026-02-27T00:00:56.197Z",
|
||||
"created_at": "2026-02-27T00:00:56.197Z",
|
||||
"updated_at": "2026-02-27T00:00:56.197Z"
|
||||
},
|
||||
{
|
||||
"id": "008a6514-5ae4-4c62-8c61-2024520395af",
|
||||
"type": "success",
|
||||
"title": "Execução concluída",
|
||||
"message": "Agente \"Analista de Segurança do Sistema\" finalizou a tarefa",
|
||||
"metadata": {
|
||||
"agentId": "d026be9f-0b16-492e-9e3c-833f839f6d72",
|
||||
"executionId": "4bffa6ef-1d64-4788-aec1-d5a596ea287f"
|
||||
},
|
||||
"read": false,
|
||||
"createdAt": "2026-02-27T01:34:32.561Z",
|
||||
"created_at": "2026-02-27T01:34:32.561Z",
|
||||
"updated_at": "2026-02-27T01:34:32.561Z"
|
||||
},
|
||||
{
|
||||
"id": "a9b4e347-9bb2-416b-aa86-11b59b6e2346",
|
||||
"type": "success",
|
||||
"title": "Execução concluída",
|
||||
"message": "Agente \"Analista de Segurança do Sistema\" finalizou a tarefa",
|
||||
"metadata": {
|
||||
"agentId": "d026be9f-0b16-492e-9e3c-833f839f6d72",
|
||||
"executionId": "ddd17e8a-9a37-4179-9499-f28df598d4b1"
|
||||
},
|
||||
"read": false,
|
||||
"createdAt": "2026-02-27T01:34:37.970Z",
|
||||
"created_at": "2026-02-27T01:34:37.970Z",
|
||||
"updated_at": "2026-02-27T01:34:37.970Z"
|
||||
},
|
||||
{
|
||||
"id": "b4fe795a-09ce-4978-901f-e0d2609c0adf",
|
||||
"type": "success",
|
||||
"title": "Execução concluída",
|
||||
"message": "Agente \"Analista de Segurança do Sistema\" finalizou a tarefa",
|
||||
"metadata": {
|
||||
"agentId": "d026be9f-0b16-492e-9e3c-833f839f6d72",
|
||||
"executionId": "124e658b-a947-4c41-bac9-790c093c9754"
|
||||
},
|
||||
"read": false,
|
||||
"createdAt": "2026-02-27T01:35:06.418Z",
|
||||
"created_at": "2026-02-27T01:35:06.418Z",
|
||||
"updated_at": "2026-02-27T01:35:06.418Z"
|
||||
},
|
||||
{
|
||||
"id": "940de39e-e62b-4580-821c-9ae2a613c570",
|
||||
"type": "success",
|
||||
"title": "Execução concluída",
|
||||
"message": "Agente \"Analista de Segurança do Sistema\" finalizou a tarefa",
|
||||
"metadata": {
|
||||
"agentId": "d026be9f-0b16-492e-9e3c-833f839f6d72",
|
||||
"executionId": "526e060f-5ea2-4302-a7d0-d2107959e395"
|
||||
},
|
||||
"read": false,
|
||||
"createdAt": "2026-02-27T01:36:23.692Z",
|
||||
"created_at": "2026-02-27T01:36:23.692Z",
|
||||
"updated_at": "2026-02-27T01:36:23.692Z"
|
||||
},
|
||||
{
|
||||
"id": "8db380ef-6329-46c8-a740-aad07c001b8d",
|
||||
"type": "success",
|
||||
"title": "Execução concluída",
|
||||
"message": "Agente \"Analista de Segurança do Sistema\" finalizou a tarefa",
|
||||
"metadata": {
|
||||
"agentId": "d026be9f-0b16-492e-9e3c-833f839f6d72",
|
||||
"executionId": "53b75799-e74e-41e8-a8aa-f0af83a82693"
|
||||
},
|
||||
"read": false,
|
||||
"createdAt": "2026-02-27T01:42:15.698Z",
|
||||
"created_at": "2026-02-27T01:42:15.698Z",
|
||||
"updated_at": "2026-02-27T01:42:15.698Z"
|
||||
},
|
||||
{
|
||||
"id": "931fa597-5af3-47c3-8f71-2d56a2c9bf0f",
|
||||
"type": "success",
|
||||
"title": "Execução concluída",
|
||||
"message": "Agente \"Analista de Segurança do Sistema\" finalizou a tarefa",
|
||||
"metadata": {
|
||||
"agentId": "d026be9f-0b16-492e-9e3c-833f839f6d72",
|
||||
"executionId": "91454df6-acc7-44d3-b3dc-4418fa7a093a"
|
||||
},
|
||||
"read": false,
|
||||
"createdAt": "2026-02-27T01:52:49.615Z",
|
||||
"created_at": "2026-02-27T01:52:49.615Z",
|
||||
"updated_at": "2026-02-27T01:52:49.615Z"
|
||||
},
|
||||
{
|
||||
"id": "69426884-a8f8-4efe-a37d-80d8a524133d",
|
||||
"type": "success",
|
||||
"title": "Execução concluída",
|
||||
"message": "Agente \"Arquiteto de Software\" finalizou a tarefa",
|
||||
"metadata": {
|
||||
"agentId": "f356e42a-73db-4b04-bca2-1fb022f373b7",
|
||||
"executionId": "97e3f81a-f04d-48dd-9f9b-23b19f91fd00"
|
||||
},
|
||||
"read": false,
|
||||
"createdAt": "2026-02-27T05:00:32.024Z",
|
||||
"created_at": "2026-02-27T05:00:32.024Z",
|
||||
"updated_at": "2026-02-27T05:00:32.024Z"
|
||||
},
|
||||
{
|
||||
"id": "c4419676-b592-4a29-8590-2b70b60dc04c",
|
||||
"type": "success",
|
||||
"title": "Execução concluída",
|
||||
"message": "Agente \"Analista de Segurança do Sistema\" finalizou a tarefa",
|
||||
"metadata": {
|
||||
"agentId": "d026be9f-0b16-492e-9e3c-833f839f6d72",
|
||||
"executionId": "ec7c2602-56d2-40fc-8b0f-cb434aa6910e"
|
||||
},
|
||||
"read": false,
|
||||
"createdAt": "2026-02-27T06:24:58.009Z",
|
||||
"created_at": "2026-02-27T06:24:58.009Z",
|
||||
"updated_at": "2026-02-27T06:24:58.009Z"
|
||||
},
|
||||
{
|
||||
"id": "f5f04515-85e8-4e3b-9bd5-2197eb7aa719",
|
||||
"type": "success",
|
||||
"title": "Execução concluída",
|
||||
"message": "Agente \"Analisador de Armazenamento\" finalizou a tarefa",
|
||||
"metadata": {
|
||||
"agentId": "07cfd2ae-e6a4-4254-891b-0989ff472381",
|
||||
"executionId": "9e80e8d2-43e1-4430-9f89-91a370dd22e1"
|
||||
},
|
||||
"read": false,
|
||||
"createdAt": "2026-02-27T06:37:17.326Z",
|
||||
"created_at": "2026-02-27T06:37:17.326Z",
|
||||
"updated_at": "2026-02-27T06:37:17.326Z"
|
||||
},
|
||||
{
|
||||
"id": "84ae7db5-9617-43f7-a441-2d5783ebc826",
|
||||
"type": "success",
|
||||
"title": "Execução concluída",
|
||||
"message": "Agente \"Analisador de Armazenamento\" finalizou a tarefa",
|
||||
"metadata": {
|
||||
"agentId": "07cfd2ae-e6a4-4254-891b-0989ff472381",
|
||||
"executionId": "ec5e8f78-fc81-4537-b26b-f923e0ab142c"
|
||||
},
|
||||
"read": false,
|
||||
"createdAt": "2026-02-27T06:43:46.776Z",
|
||||
"created_at": "2026-02-27T06:43:46.776Z",
|
||||
"updated_at": "2026-02-27T06:43:46.776Z"
|
||||
},
|
||||
{
|
||||
"id": "1a7ab6a0-252c-453e-a1aa-2bc39bd284ef",
|
||||
"type": "success",
|
||||
"title": "Execução concluída",
|
||||
"message": "Agente \"Analista de Segurança do Sistema\" finalizou a tarefa",
|
||||
"metadata": {
|
||||
"agentId": "d026be9f-0b16-492e-9e3c-833f839f6d72",
|
||||
"executionId": "1d15c272-e953-4087-aea3-a46c8e12aa17"
|
||||
},
|
||||
"read": false,
|
||||
"createdAt": "2026-02-27T16:05:54.704Z",
|
||||
"created_at": "2026-02-27T16:05:54.704Z",
|
||||
"updated_at": "2026-02-27T16:05:54.704Z"
|
||||
},
|
||||
{
|
||||
"id": "16198936-ad6f-4af4-bc06-0f2e40e4e4ca",
|
||||
"type": "success",
|
||||
"title": "Execução concluída",
|
||||
"message": "Agente \"Analisador de Armazenamento\" finalizou a tarefa",
|
||||
"metadata": {
|
||||
"agentId": "07cfd2ae-e6a4-4254-891b-0989ff472381",
|
||||
"executionId": "46f2afb4-5827-4db8-a1c4-db67ac9dd538"
|
||||
},
|
||||
"read": false,
|
||||
"createdAt": "2026-02-27T16:23:06.763Z",
|
||||
"created_at": "2026-02-27T16:23:06.763Z",
|
||||
"updated_at": "2026-02-27T16:23:06.763Z"
|
||||
},
|
||||
{
|
||||
"id": "b62a6ae6-7f6b-4475-bdae-48557269573b",
|
||||
"type": "success",
|
||||
"title": "Execução concluída",
|
||||
"message": "Agente \"Analista de Segurança do Sistema\" finalizou a tarefa",
|
||||
"metadata": {
|
||||
"agentId": "d026be9f-0b16-492e-9e3c-833f839f6d72",
|
||||
"executionId": "23edd584-0ddc-41a7-b135-b8880685fecb"
|
||||
},
|
||||
"read": false,
|
||||
"createdAt": "2026-02-27T16:30:33.854Z",
|
||||
"created_at": "2026-02-27T16:30:33.854Z",
|
||||
"updated_at": "2026-02-27T16:30:33.854Z"
|
||||
},
|
||||
{
|
||||
"id": "f4fadf97-a587-4844-ba46-c7ea5362e100",
|
||||
"type": "success",
|
||||
"title": "Execução concluída",
|
||||
"message": "Agente \"Monitor SAE\" finalizou a tarefa",
|
||||
"metadata": {
|
||||
"agentId": "a53e89a1-2f82-4188-91b8-142705f94f47",
|
||||
"executionId": "2c502232-7486-42ed-975c-8a7ef1628914"
|
||||
},
|
||||
"read": false,
|
||||
"createdAt": "2026-02-27T17:04:57.155Z",
|
||||
"created_at": "2026-02-27T17:04:57.155Z",
|
||||
"updated_at": "2026-02-27T17:04:57.155Z"
|
||||
},
|
||||
{
|
||||
"id": "54a03f66-494a-4572-98b7-77919aa133ea",
|
||||
"type": "success",
|
||||
"title": "Execução concluída",
|
||||
"message": "Agente \"Monitor CONSPRE\" finalizou a tarefa",
|
||||
"metadata": {
|
||||
"agentId": "c954e0cd-48ab-4c6c-bc38-2d360bf417ae",
|
||||
"executionId": "2fb1397d-ad04-42d3-bc9d-324500a8c11f"
|
||||
},
|
||||
"read": false,
|
||||
"createdAt": "2026-02-27T17:20:03.623Z",
|
||||
"created_at": "2026-02-27T17:20:03.623Z",
|
||||
"updated_at": "2026-02-27T17:20:03.623Z"
|
||||
},
|
||||
{
|
||||
"id": "a8d697a2-344c-4015-aa00-65532f5dce3b",
|
||||
"type": "error",
|
||||
"title": "Execução falhou",
|
||||
"message": "Agente \"Monitor AtuaCAPES\" encontrou um erro",
|
||||
"metadata": {
|
||||
"agentId": "1568189e-92c5-4139-8f18-035eca8e3753",
|
||||
"executionId": "261418b2-04ae-4889-a12b-e37b2e11adcc"
|
||||
},
|
||||
"read": false,
|
||||
"createdAt": "2026-02-27T17:40:05.390Z",
|
||||
"created_at": "2026-02-27T17:40:05.390Z",
|
||||
"updated_at": "2026-02-27T17:40:05.390Z"
|
||||
},
|
||||
{
|
||||
"id": "989ebce7-5805-48e0-baf8-092bffe8d8ea",
|
||||
"type": "success",
|
||||
"title": "Execução concluída",
|
||||
"message": "Agente \"Monitor Consolidado CAPES\" finalizou a tarefa",
|
||||
"metadata": {
|
||||
"agentId": "d5ca1c47-872c-47cc-8d80-5ae5491f8207",
|
||||
"executionId": "c2b3c2c2-061a-4e86-a214-0ec1211864df"
|
||||
},
|
||||
"read": false,
|
||||
"createdAt": "2026-02-27T18:03:21.985Z",
|
||||
"created_at": "2026-02-27T18:03:21.985Z",
|
||||
"updated_at": "2026-02-27T18:03:21.985Z"
|
||||
},
|
||||
{
|
||||
"id": "a71c0471-c452-449b-80ac-4fb2882b57ec",
|
||||
"type": "success",
|
||||
"title": "Execução concluída",
|
||||
"message": "Agente \"Tech Lead\" finalizou a tarefa",
|
||||
"metadata": {
|
||||
"agentId": "ea485e4f-b4b7-47ab-bb9b-e9faebcb3921",
|
||||
"executionId": "1b95b14c-092a-406e-906b-acadbc4e391a"
|
||||
},
|
||||
"read": false,
|
||||
"createdAt": "2026-02-27T18:07:52.369Z",
|
||||
"created_at": "2026-02-27T18:07:52.369Z",
|
||||
"updated_at": "2026-02-27T18:07:52.369Z"
|
||||
},
|
||||
{
|
||||
"id": "082b2e45-24c3-4de3-8020-0a34d6a35288",
|
||||
"type": "success",
|
||||
"title": "Execução concluída",
|
||||
"message": "Agente \"Code Reviewer\" finalizou a tarefa",
|
||||
"metadata": {
|
||||
"agentId": "3f1a5442-ffe9-461c-a8e1-2f7239a8f025",
|
||||
"executionId": "51908dd9-1759-4cd1-856d-7650321e4f7f"
|
||||
},
|
||||
"read": false,
|
||||
"createdAt": "2026-02-28T07:42:13.938Z",
|
||||
"created_at": "2026-02-28T07:42:13.938Z",
|
||||
"updated_at": "2026-02-28T07:42:13.938Z"
|
||||
}
|
||||
]
|
||||
355
data/pipelines.json
Normal file
355
data/pipelines.json
Normal file
@@ -0,0 +1,355 @@
|
||||
[
|
||||
{
|
||||
"id": "652065ba-a996-44e9-8c14-75b6ad76d280",
|
||||
"name": "Pipeline de Desenvolvimento de Feature",
|
||||
"description": "Pipeline completo para desenvolvimento de uma nova feature: desde a análise arquitetural, passando pelo desenvolvimento backend e frontend, testes, revisão de segurança e preparação para deploy.",
|
||||
"steps": [
|
||||
{
|
||||
"id": "7efde1d7-b844-4a48-8d5f-3ca58fe32159",
|
||||
"agentId": "f356e42a-73db-4b04-bca2-1fb022f373b7",
|
||||
"order": 0,
|
||||
"inputTemplate": "Analise os requisitos da feature a seguir e crie um design arquitetural detalhado. Defina as entidades, endpoints de API, estrutura de banco de dados e componentes de frontend necessários. Considere escalabilidade, segurança e manutenibilidade. Feature: {{input}}",
|
||||
"description": "",
|
||||
"requiresApproval": false
|
||||
},
|
||||
{
|
||||
"id": "b4d5030c-a67f-4fac-a464-d3b3bba17b52",
|
||||
"agentId": "4212b6b0-f519-41dc-891d-0c8b4a8f6843",
|
||||
"order": 1,
|
||||
"inputTemplate": "Com base no design arquitetural abaixo, implemente os endpoints de API, models, services e repositories necessários. Use TypeScript com NestJS, Prisma ORM e PostgreSQL. Inclua validações, tratamento de erros e testes unitários. Design: {{input}}",
|
||||
"description": "",
|
||||
"requiresApproval": false
|
||||
},
|
||||
{
|
||||
"id": "e4662771-ed2f-48e5-9121-aff85e2433a7",
|
||||
"agentId": "a763246a-f411-4895-aa0d-8324af490d2e",
|
||||
"order": 2,
|
||||
"inputTemplate": "Com base na implementação backend abaixo, desenvolva os componentes React/TypeScript necessários para a interface. Crie componentes reutilizáveis, hooks customizados, integração com a API usando React Query, formulários com validação e testes com Testing Library. Implementação Backend: {{input}}",
|
||||
"description": "",
|
||||
"requiresApproval": false
|
||||
},
|
||||
{
|
||||
"id": "940b975e-7109-4d16-ab9b-df4e9f4f585f",
|
||||
"agentId": "aacbde57-6952-407d-8388-a62230b06c0b",
|
||||
"order": 3,
|
||||
"inputTemplate": "Revise todo o código implementado (backend e frontend) abaixo. Crie testes E2E com Playwright cobrindo os fluxos críticos, verifique a cobertura de testes unitários, identifique bugs potenciais e sugira melhorias de qualidade. Código para revisão: {{input}}",
|
||||
"description": "",
|
||||
"requiresApproval": false
|
||||
},
|
||||
{
|
||||
"id": "a4adea8c-e1a2-4996-902a-2538cfe92db3",
|
||||
"agentId": "d026be9f-0b16-492e-9e3c-833f839f6d72",
|
||||
"order": 4,
|
||||
"inputTemplate": "Realize uma auditoria de segurança completa no código e testes abaixo. Verifique vulnerabilidades OWASP Top 10, validação de entrada, autenticação/autorização, exposição de dados sensíveis e compliance. Forneça um relatório de segurança com severidade e correções. Código e testes: {{input}}",
|
||||
"description": "",
|
||||
"requiresApproval": false
|
||||
}
|
||||
],
|
||||
"status": "active",
|
||||
"created_at": "2026-02-26T03:31:18.048Z",
|
||||
"updated_at": "2026-02-26T22:33:45.869Z"
|
||||
},
|
||||
{
|
||||
"id": "21d4ad2c-6337-4389-b766-e0806ecf696e",
|
||||
"name": "Pipeline de Code Review Completo",
|
||||
"description": "Pipeline para revisão completa de código: análise arquitetural, revisão de qualidade, verificação de segurança e validação de performance. Garante que todo código atenda aos padrões da equipe.",
|
||||
"steps": [
|
||||
{
|
||||
"id": "dcda608f-da88-48d6-8ab5-ff9410de6c68",
|
||||
"agentId": "f356e42a-73db-4b04-bca2-1fb022f373b7",
|
||||
"order": 0,
|
||||
"inputTemplate": "Revise a arquitetura e padrões de design do código abaixo. Verifique aderência a Clean Architecture, SOLID, e identifique acoplamentos indesejados ou violações de camadas. Código: {{input}}",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"id": "1d8d819c-474b-4bd0-823b-ff14a6cb4b25",
|
||||
"agentId": "4212b6b0-f519-41dc-891d-0c8b4a8f6843",
|
||||
"order": 1,
|
||||
"inputTemplate": "Com base na revisão arquitetural, analise a qualidade do código backend: legibilidade, tratamento de erros, tipagem, testes, performance de queries e boas práticas. Sugira refatorações específicas. Revisão anterior e código: {{input}}",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"id": "7b0fe629-8c92-4d09-bc18-bd65a1289acc",
|
||||
"agentId": "d026be9f-0b16-492e-9e3c-833f839f6d72",
|
||||
"order": 2,
|
||||
"inputTemplate": "Realize análise de segurança no código revisado. Verifique: SQL injection, XSS, CSRF, exposição de dados, autenticação/autorização, validação de input e dependências vulneráveis. Código e revisões: {{input}}",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"id": "3c8da665-0f70-4374-8507-bd9e74081d7a",
|
||||
"agentId": "aacbde57-6952-407d-8388-a62230b06c0b",
|
||||
"order": 3,
|
||||
"inputTemplate": "Com base em todas as revisões anteriores, compile um relatório final de qualidade. Liste: bugs encontrados, melhorias sugeridas, cobertura de testes necessária e um checklist de aprovação para merge. Revisões: {{input}}",
|
||||
"description": ""
|
||||
}
|
||||
],
|
||||
"status": "active",
|
||||
"created_at": "2026-02-26T03:32:06.846Z",
|
||||
"updated_at": "2026-02-26T03:32:06.846Z"
|
||||
},
|
||||
{
|
||||
"id": "675a2293-a77b-4953-9cf7-168d8671afb2",
|
||||
"name": "Pipeline de Correção de Bug",
|
||||
"description": "Pipeline para análise, correção e validação de bugs reportados. Inclui diagnóstico, implementação da correção, testes de regressão e verificação de segurança.",
|
||||
"steps": [
|
||||
{
|
||||
"id": "49f3f0ba-230b-4349-9e6d-b0845f8737d2",
|
||||
"agentId": "4212b6b0-f519-41dc-891d-0c8b4a8f6843",
|
||||
"order": 0,
|
||||
"inputTemplate": "Analise o bug reportado abaixo. Identifique a causa raiz, o impacto no sistema e proponha uma correção com código. Inclua análise de possíveis efeitos colaterais da correção. Bug: {{input}}",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"id": "d65f086f-d5de-4b9c-abe1-834ce96fd1d2",
|
||||
"agentId": "aacbde57-6952-407d-8388-a62230b06c0b",
|
||||
"order": 1,
|
||||
"inputTemplate": "Com base na correção proposta abaixo, crie testes de regressão que cubram o cenário do bug e cenários relacionados. Verifique se a correção não introduz novos problemas. Correção: {{input}}",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"id": "44fc4194-fc4b-481e-8b22-38ec9bbb9ecf",
|
||||
"agentId": "d026be9f-0b16-492e-9e3c-833f839f6d72",
|
||||
"order": 2,
|
||||
"inputTemplate": "Verifique se a correção de bug e os testes abaixo não introduzem vulnerabilidades de segurança. Analise se o bug original tinha implicações de segurança. Correção e testes: {{input}}",
|
||||
"description": ""
|
||||
}
|
||||
],
|
||||
"status": "active",
|
||||
"created_at": "2026-02-26T03:32:06.849Z",
|
||||
"updated_at": "2026-02-26T03:32:06.849Z"
|
||||
},
|
||||
{
|
||||
"id": "ce04edc0-64e7-460c-964e-9a06e3954d59",
|
||||
"name": "Pipeline de Otimização de Performance",
|
||||
"description": "Pipeline para identificar e resolver problemas de performance: análise de banco de dados, otimização de código backend e frontend, caching e monitoramento.",
|
||||
"steps": [
|
||||
{
|
||||
"id": "107b4ac9-c600-4dbf-aeda-530c78dac864",
|
||||
"agentId": "7a9b05ab-3f87-4e70-9394-14fda4136d59",
|
||||
"order": 0,
|
||||
"inputTemplate": "Analise as queries e o schema do banco de dados relacionados ao problema de performance abaixo. Identifique queries lentas, índices faltantes, N+1 problems e sugira otimizações com EXPLAIN ANALYZE. Problema: {{input}}",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"id": "01c0a13b-8c2f-4b16-adb3-71f2d53ef497",
|
||||
"agentId": "4212b6b0-f519-41dc-891d-0c8b4a8f6843",
|
||||
"order": 1,
|
||||
"inputTemplate": "Com base nas otimizações de banco sugeridas, otimize o código backend: implemente caching com Redis, otimize algoritmos, reduza chamadas de rede e melhore o uso de recursos. Otimizações DB: {{input}}",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"id": "89459a69-6b09-4bef-b422-647472b42dbb",
|
||||
"agentId": "a763246a-f411-4895-aa0d-8324af490d2e",
|
||||
"order": 2,
|
||||
"inputTemplate": "Otimize o frontend para melhor performance: implemente lazy loading, code splitting, memoização, virtualização de listas longas e otimize re-renders. Analise o bundle size. Contexto: {{input}}",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"id": "7e65b80f-e652-4ad3-a1fd-0971950c64a3",
|
||||
"agentId": "7075eeef-1f11-441b-9a18-e5a715f28099",
|
||||
"order": 3,
|
||||
"inputTemplate": "Configure monitoramento de performance para as otimizações implementadas: dashboards no Grafana, alertas de latência, métricas de throughput e configuração de auto-scaling. Otimizações realizadas: {{input}}",
|
||||
"description": ""
|
||||
}
|
||||
],
|
||||
"status": "active",
|
||||
"created_at": "2026-02-26T03:32:06.852Z",
|
||||
"updated_at": "2026-02-26T03:32:06.852Z"
|
||||
},
|
||||
{
|
||||
"id": "6e698339-f089-4b5d-aedb-8bb2e4f6bb21",
|
||||
"name": "Pipeline de Deploy para Produção",
|
||||
"description": "Pipeline completo de preparação e execução de deploy: checklist de pré-deploy, configuração de infraestrutura, estratégia de rollback e monitoramento pós-deploy.",
|
||||
"steps": [
|
||||
{
|
||||
"id": "5d218cf9-507a-42b9-9d0f-4dfe4a2d1b71",
|
||||
"agentId": "aacbde57-6952-407d-8388-a62230b06c0b",
|
||||
"order": 0,
|
||||
"inputTemplate": "Execute o checklist de pré-deploy: verifique se todos os testes passam, valide a cobertura mínima, execute smoke tests e confirme que não há regressões. Gere relatório de go/no-go. Release: {{input}}",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"id": "7894833e-8272-4259-9f12-8f0103306bb3",
|
||||
"agentId": "d026be9f-0b16-492e-9e3c-833f839f6d72",
|
||||
"order": 1,
|
||||
"inputTemplate": "Realize verificação de segurança pré-deploy: scan de vulnerabilidades em dependências, verificação de secrets, análise de configurações de produção e checklist OWASP. Relatório QA: {{input}}",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"id": "0640c41a-61e1-4d80-bd16-64f4947955a8",
|
||||
"agentId": "7075eeef-1f11-441b-9a18-e5a715f28099",
|
||||
"order": 2,
|
||||
"inputTemplate": "Prepare a infraestrutura para o deploy: configure blue-green deployment, prepare scripts de rollback, atualize configurações de produção e configure health checks. Relatórios anteriores: {{input}}",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"id": "f98508bd-476b-4339-b114-4ae812ce0bd0",
|
||||
"agentId": "7075eeef-1f11-441b-9a18-e5a715f28099",
|
||||
"order": 3,
|
||||
"inputTemplate": "Configure monitoramento pós-deploy: alertas de error rate, latência p99, dashboards de KPIs, log aggregation e runbook de incidentes. Defina critérios de rollback automático. Deploy info: {{input}}",
|
||||
"description": ""
|
||||
}
|
||||
],
|
||||
"status": "active",
|
||||
"created_at": "2026-02-26T03:32:06.855Z",
|
||||
"updated_at": "2026-02-26T03:32:06.855Z"
|
||||
},
|
||||
{
|
||||
"id": "18308362-793a-4fdd-b85c-a16bb41cf0c1",
|
||||
"name": "Auto-Evolução do Sistema",
|
||||
"description": "Pipeline completa de auto-melhoria: análise → planejamento → implementação backend → implementação frontend → validação. Os agentes trabalham em sequência, cada um recebendo o output do anterior.",
|
||||
"steps": [
|
||||
{
|
||||
"id": "9fc6ac16-ad6e-4a3a-a1ea-a87bfe496b7c",
|
||||
"agentId": "51202705-ce9d-4d96-acb5-00c6fe9d6b9e",
|
||||
"order": 0,
|
||||
"inputTemplate": "Analise o codebase do projeto em: {{input}}\n\nNavegue até o diretório, leia TODOS os arquivos principais e gere o relatório de análise conforme seu system prompt. Limite a saída a no máximo 3000 palavras. Inclua a seção META com caminho e nota.",
|
||||
"description": "Análise de Código",
|
||||
"requiresApproval": false
|
||||
},
|
||||
{
|
||||
"id": "ec14ac5e-a82a-41df-9250-cead5600a8fc",
|
||||
"agentId": "5f0be2a6-e549-44f7-8cc1-2a0634500321",
|
||||
"order": 1,
|
||||
"inputTemplate": "Relatório de análise:\n---\n{{input}}\n---\n\nExtraia o caminho do projeto da seção META. Navegue ao projeto, valide os achados e projete o plano de melhorias conforme seu system prompt.\n\nREGRAS CRÍTICAS:\n- Máximo 10 itens no plano\n- Cada item DEVE listar arquivos exatos a modificar\n- Cada item DEVE ter backend E frontend se aplicável\n- NUNCA proponha mudar defaults ou adicionar requisitos obrigatórios\n- Priorize: bugs > segurança > performance > UX > features novas",
|
||||
"description": "Planejamento de Features",
|
||||
"requiresApproval": true
|
||||
},
|
||||
{
|
||||
"id": "472ac286-eee1-454e-9e5a-42678e91137b",
|
||||
"agentId": "db116f20-f663-4d98-ab04-5ddeb09e2c0d",
|
||||
"order": 2,
|
||||
"inputTemplate": "Plano de implementação:\n---\n{{input}}\n---\n\nExtraia o caminho do projeto da seção META. Implemente APENAS os itens de BACKEND do plano.\n\nREGRAS INVIOLÁVEIS:\n- SÓ modifique arquivos de backend (server.js, src/**/*.js, package.json)\n- NUNCA toque no frontend (public/**)\n- NUNCA mude permissionMode, AUTH_TOKEN, ou outros defaults\n- NUNCA adicione dependências obrigatórias que quebrem o sistema se ausentes\n- Execute `node --check` em CADA arquivo modificado\n- Ao finalizar: inicie o servidor, teste com curl /api/health e pelo menos 3 endpoints afetados\n- Retorne o resumo conforme seu system prompt",
|
||||
"description": "Implementação Backend",
|
||||
"requiresApproval": true
|
||||
},
|
||||
{
|
||||
"id": "5bcc2eab-6ba6-4586-b073-b7d2cc96f8e6",
|
||||
"agentId": "7aad9f1c-fcbe-4c36-8cf4-26e8efed09c0",
|
||||
"order": 3,
|
||||
"inputTemplate": "Resumo do backend implementado + plano original:\n---\n{{input}}\n---\n\nExtraia o caminho do projeto da seção META. Implemente APENAS os itens de FRONTEND do plano.\n\nREGRAS INVIOLÁVEIS:\n- SÓ modifique arquivos do frontend (public/**)\n- NUNCA toque no backend (server.js, src/**)\n- Para cada rota criada pelo backend, implemente o client em api.js\n- SEMPRE chame Utils.refreshIcons() após inserir HTML dinâmico\n- SEMPRE escape conteúdo com Utils.escapeHtml()\n- Execute `node --check` em CADA arquivo modificado\n- Retorne o resumo conforme seu system prompt",
|
||||
"description": "Implementação Frontend",
|
||||
"requiresApproval": true
|
||||
},
|
||||
{
|
||||
"id": "33daf19f-6ae1-410b-934b-c21e2d1c0c86",
|
||||
"agentId": "e1e81038-d4cc-4772-a1cf-f1469e65351f",
|
||||
"order": 4,
|
||||
"inputTemplate": "Resumo das implementações:\n---\n{{input}}\n---\n\nExtraia o caminho do projeto da seção META. Execute a validação completa conforme seu system prompt:\n1. node --check em TODOS os .js\n2. Validar imports com node -e\n3. Iniciar servidor e testar health\n4. Testar TODOS os endpoints\n5. Verificar integração frontend-backend\n6. CORRIGIR qualquer problema encontrado\n7. Re-validar após correções\n\nRetorne o relatório de validação conforme seu system prompt.",
|
||||
"description": "Validação e Integração",
|
||||
"requiresApproval": true
|
||||
}
|
||||
],
|
||||
"status": "active",
|
||||
"created_at": "2026-02-26T23:50:38.506Z",
|
||||
"updated_at": "2026-02-27T04:56:19.540Z"
|
||||
},
|
||||
{
|
||||
"id": "3a1cddd2-adc9-48da-b0dc-103a6b96af15",
|
||||
"name": "Avaliação Completa de Sistema",
|
||||
"description": "Pipeline completa de avaliação com 6 especialistas: Arquitetura → Segurança → Performance → Qualidade → Infraestrutura → Consolidação. Recebe o caminho do projeto como input. Nenhum arquivo é modificado.",
|
||||
"steps": [
|
||||
{
|
||||
"id": "d455cc61-a4e3-492f-aa73-bd24c5cd4c51",
|
||||
"agentId": "0f089f30-2776-48ec-a20b-e70b97e946a6",
|
||||
"order": 0,
|
||||
"inputTemplate": "Analise a ARQUITETURA do projeto localizado em: {{input}}\n\nNavegue até o diretório e faça sua análise completa. Siga o formato de saída do seu system prompt. Mantenha o relatório com no máximo 3000 palavras.",
|
||||
"description": "",
|
||||
"requiresApproval": false
|
||||
},
|
||||
{
|
||||
"id": "d886d627-2e1e-4a71-b1fc-b1d02cf4da76",
|
||||
"agentId": "0b2b35c5-42c2-4ad5-8a4e-65370680e6f7",
|
||||
"order": 1,
|
||||
"inputTemplate": "Relatório anterior (Arquitetura):\n---\n{{input}}\n---\n\nExtraia o caminho do projeto da seção META acima. Analise a SEGURANÇA desse mesmo projeto. Use sudo se necessário. Siga o formato de saída do seu system prompt. Mantenha o relatório com no máximo 3000 palavras. Inclua a seção META com caminho e nota.",
|
||||
"description": "",
|
||||
"requiresApproval": false
|
||||
},
|
||||
{
|
||||
"id": "32d5acf9-6475-4b03-a6b4-f60024165ce7",
|
||||
"agentId": "8c157f2d-fb99-4ccc-9feb-f8777d00ea10",
|
||||
"order": 2,
|
||||
"inputTemplate": "Relatórios anteriores:\n---\n{{input}}\n---\n\nExtraia o caminho do projeto da seção META acima. Analise a PERFORMANCE desse mesmo projeto. Siga o formato de saída do seu system prompt. Mantenha o relatório com no máximo 3000 palavras. Inclua a seção META com caminho e nota.",
|
||||
"description": "",
|
||||
"requiresApproval": false
|
||||
},
|
||||
{
|
||||
"id": "880a3c64-5897-4f63-8d7b-39f537390c7c",
|
||||
"agentId": "5cbf86aa-c342-4d3e-8a73-3b4a700876d7",
|
||||
"order": 3,
|
||||
"inputTemplate": "Relatórios anteriores:\n---\n{{input}}\n---\n\nExtraia o caminho do projeto da seção META acima. Analise a QUALIDADE DO CÓDIGO desse mesmo projeto. Siga o formato de saída do seu system prompt. Mantenha o relatório com no máximo 3000 palavras. Inclua a seção META com caminho e nota.",
|
||||
"description": "",
|
||||
"requiresApproval": false
|
||||
},
|
||||
{
|
||||
"id": "c8b22cf4-04b5-4411-be7d-ba1e8b15053a",
|
||||
"agentId": "a6540e40-1973-4c06-8214-047e353f7202",
|
||||
"order": 4,
|
||||
"inputTemplate": "Relatórios anteriores:\n---\n{{input}}\n---\n\nExtraia o caminho do projeto da seção META acima. Analise a INFRAESTRUTURA desse mesmo projeto. Use sudo para inspecionar Docker, systemd, portas, etc. Siga o formato de saída do seu system prompt. Mantenha o relatório com no máximo 3000 palavras. Inclua a seção META com caminho e nota.",
|
||||
"description": "",
|
||||
"requiresApproval": false
|
||||
},
|
||||
{
|
||||
"id": "00d165b1-0c07-4417-846e-c88f9d4f4386",
|
||||
"agentId": "dafa71f1-4ad8-49d1-9e1d-ef8e591f537d",
|
||||
"order": 5,
|
||||
"inputTemplate": "Consolide TODOS os relatórios abaixo em um parecer técnico unificado:\n---\n{{input}}\n---\n\nExtraia as notas de cada seção META, calcule a nota ponderada, e gere o parecer final conforme o formato do seu system prompt.",
|
||||
"description": "",
|
||||
"requiresApproval": false
|
||||
}
|
||||
],
|
||||
"status": "active",
|
||||
"created_at": "2026-02-27T02:34:57.004Z",
|
||||
"updated_at": "2026-02-27T16:24:21.116Z"
|
||||
},
|
||||
{
|
||||
"id": "8119e34c-3586-4ad9-b1cd-7ac47fc32b37",
|
||||
"name": "Pipeline de Desenvolvimento",
|
||||
"description": "Fluxo completo: Tech Lead → PO → Desenvolvedor → QA → Code Review → DevOps",
|
||||
"steps": [
|
||||
{
|
||||
"id": "c5bf6a17-53c0-40dc-8673-d5de044d40a7",
|
||||
"agentId": "927f157c-e005-4973-ae6a-21f29fd11a0f",
|
||||
"order": 0,
|
||||
"inputTemplate": "Analise a demanda a seguir e produza uma ESPECIFICAÇÃO TÉCNICA detalhada para o desenvolvedor.\n\n<demanda>\n{{input}}\n</demanda>\n\n<regras>\n- Você é o PO. Seu papel é PLANEJAR, não implementar.\n- NÃO escreva código. NÃO crie arquivos. NÃO use Write, Bash ou WebSearch.\n- Apenas LEIA o codebase existente (se houver) para entender o contexto.\n</regras>\n\n<formato_obrigatorio>\n## Resumo da Demanda\n[2-3 linhas descrevendo o que foi solicitado]\n\n## Diretório do Projeto\n[caminho completo do projeto, ex: /home/fred/projetos/meu-projeto]\n\n## User Stories\n- Como [persona], quero [funcionalidade] para [benefício]\n\n## Critérios de Aceite\n- [ ] [critério específico e testável]\n\n## Requisitos Técnicos\n- [stack, padrões, restrições]\n\n## Escopo\n### Incluído\n- [o que DEVE ser feito]\n### Excluído\n- [o que NÃO deve ser feito]\n\n## Pontos de Atenção\n- [riscos, dependências, edge cases]\n</formato_obrigatorio>",
|
||||
"description": "",
|
||||
"requiresApproval": false
|
||||
},
|
||||
{
|
||||
"id": "f6b9f9b4-cce6-4739-a8cd-be9420937a7d",
|
||||
"agentId": "5c82ae64-4fcc-4299-ad5a-b8d55db1e951",
|
||||
"order": 1,
|
||||
"inputTemplate": "Implemente o código com base na especificação do Product Owner abaixo.\n\n<especificacao_po>\n{{input}}\n</especificacao_po>\n\n<instrucoes>\n- Extraia o diretório do projeto da especificação e trabalhe nele.\n- Siga os requisitos técnicos e critérios de aceite definidos pelo PO.\n- Implemente APENAS o que está no escopo.\n- Ao finalizar, produza o relatório abaixo (NÃO repita a especificação do PO).\n</instrucoes>\n\n<formato_obrigatorio>\n## Relatório de Implementação\n\n### Diretório do Projeto\n[caminho completo]\n\n### Arquivos Criados/Modificados\n| Arquivo | Ação | Descrição |\n|---------|------|-----------|\n| caminho/arquivo | criado/modificado | o que foi feito |\n\n### Decisões Técnicas\n- [decisão tomada e justificativa]\n\n### Como Testar\n- [passos para validar a implementação]\n\n### Pontos de Atenção para QA\n- [áreas que merecem teste especial]\n</formato_obrigatorio>",
|
||||
"description": "",
|
||||
"requiresApproval": false
|
||||
},
|
||||
{
|
||||
"id": "a47c9037-8460-4f8f-b04f-5c5f2cc91dc0",
|
||||
"agentId": "61718d7b-f118-403c-8ba0-094b5c8ba733",
|
||||
"order": 2,
|
||||
"inputTemplate": "Realize testes de qualidade com base no relatório de implementação do Desenvolvedor.\n\n<relatorio_desenvolvedor>\n{{input}}\n</relatorio_desenvolvedor>\n\n<instrucoes>\n- Extraia o diretório do projeto do relatório.\n- Navegue até o projeto e LEIA os arquivos mencionados.\n- Valide cada critério de aceite e cada ponto de atenção.\n- Execute testes se possível (abrir no browser, rodar scripts, etc).\n- NÃO corrija bugs — apenas DOCUMENTE-OS.\n</instrucoes>\n\n<formato_obrigatorio>\n## Relatório de QA\n\n### Diretório do Projeto\n[caminho]\n\n### Resultado Geral\n[APROVADO / APROVADO COM RESSALVAS / REPROVADO]\n\n### Bugs Encontrados\n| # | Severidade | Descrição | Arquivo | Linha |\n|---|-----------|-----------|---------|-------|\n\n### Critérios de Aceite Validados\n- [x] ou [ ] [cada critério]\n\n### Observações e Recomendações\n- [notas adicionais]\n</formato_obrigatorio>",
|
||||
"description": "",
|
||||
"requiresApproval": false
|
||||
},
|
||||
{
|
||||
"id": "20c496b4-dc71-4258-994f-8aaf134dcd83",
|
||||
"agentId": "3f1a5442-ffe9-461c-a8e1-2f7239a8f025",
|
||||
"order": 3,
|
||||
"inputTemplate": "Realize code review com base no relatório de QA e na implementação.\n\n<relatorio_qa>\n{{input}}\n</relatorio_qa>\n\n<instrucoes>\n- Extraia o diretório do projeto.\n- Navegue até o projeto e LEIA TODO o código dos arquivos mencionados.\n- Analise segurança, performance, qualidade e padrões.\n- NÃO corrija nada — apenas DOCUMENTE os problemas.\n</instrucoes>\n\n<formato_obrigatorio>\n## Code Review\n\n### Diretório do Projeto\n[caminho]\n\n### Veredicto\n[APROVADO / APROVADO COM RESSALVAS / REPROVADO]\n\n### Issues Encontradas\n| # | Severidade | Tipo | Arquivo | Descrição | Correção Sugerida |\n|---|-----------|------|---------|-----------|-------------------|\n\n### Aprovações\n- [o que está bem feito]\n\n### Correções Obrigatórias (para o Desenvolvedor)\n1. [correção específica com arquivo e o que fazer]\n2. [...]\n</formato_obrigatorio>",
|
||||
"description": "",
|
||||
"requiresApproval": false
|
||||
},
|
||||
{
|
||||
"id": "c9e01b94-37db-42ad-b9f1-0205e97f7eab",
|
||||
"agentId": "5c82ae64-4fcc-4299-ad5a-b8d55db1e951",
|
||||
"order": 4,
|
||||
"inputTemplate": "Aplique as correções apontadas pelo Code Reviewer abaixo.\n\n<code_review>\n{{input}}\n</code_review>\n\n<instrucoes>\n- Extraia o diretório do projeto e as correções obrigatórias do code review.\n- Aplique CADA correção listada na seção \"Correções Obrigatórias\".\n- Se o veredicto foi APROVADO, apenas confirme que não há pendências.\n- NÃO adicione features novas. Apenas corrija o que foi apontado.\n</instrucoes>\n\n<formato_obrigatorio>\n## Relatório de Correções\n\n### Correções Aplicadas\n| # | Issue | Arquivo | Status |\n|---|-------|---------|--------|\n| 1 | [descrição] | [arquivo] | Corrigido |\n\n### Verificação\n- [resultado dos testes após correções]\n\n### Status Final\n[Todas as correções aplicadas / Pendências restantes]\n</formato_obrigatorio>",
|
||||
"description": "",
|
||||
"requiresApproval": false
|
||||
}
|
||||
],
|
||||
"status": "active",
|
||||
"created_at": "2026-02-27T17:19:39.329Z",
|
||||
"updated_at": "2026-02-27T20:08:42.379Z"
|
||||
}
|
||||
]
|
||||
62
data/schedules.json
Normal file
62
data/schedules.json
Normal file
@@ -0,0 +1,62 @@
|
||||
[
|
||||
{
|
||||
"id": "8e84dadd-ef88-4188-bb6e-8bf9fee9d2af",
|
||||
"agentId": "d026be9f-0b16-492e-9e3c-833f839f6d72",
|
||||
"agentName": "Analista de Segurança do Sistema",
|
||||
"taskDescription": "Execute uma auditoria completa de segurança do sistema operacional. Siga estas etapas:\n\n1. **Identificação do Sistema**: Colete informações sobre o kernel, distribuição, hostname e uptime.\n\n2. **Atualizações de Segurança**: Verifique pacotes desatualizados com `apt list --upgradable 2>/dev/null || dnf check-update 2>/dev/null`. Identifique quais possuem patches de segurança pendentes.\n\n3. **Portas e Serviços Expostos**: Execute `ss -tlnp` para listar todas as portas TCP em escuta. Avalie se cada serviço exposto é necessário e se está configurado de forma segura.\n\n4. **Análise de Usuários e Acessos**:\n - Verifique contas com UID 0 além do root\n - Identifique usuários sem senha ou com shells interativos desnecessários\n - Analise o histórico de logins com `last` e tentativas falhadas com `lastb` ou logs em /var/log/auth.log\n\n5. **Permissões Perigosas**:\n - Busque binários SUID/SGID com `find / -perm -4000 -type f 2>/dev/null`\n - Identifique arquivos world-writable em diretórios críticos\n - Verifique permissões de /etc/passwd, /etc/shadow, /etc/sudoers\n\n6. **Firewall**: Verifique as regras ativas com `iptables -L -n` ou `ufw status verbose`. Reporte se o firewall está inativo.\n\n7. **Configuração SSH**: Analise /etc/ssh/sshd_config verificando: PermitRootLogin, PasswordAuthentication, PermitEmptyPasswords, MaxAuthTries, AllowUsers/AllowGroups.\n\n8. **Processos e Cron Jobs**: Liste processos ativos com `ps aux` e tarefas agendadas com `crontab -l` e `ls -la /etc/cron.*`. Identifique qualquer processo ou tarefa suspeita.\n\n9. **Uso de Disco e Logs**: Verifique espaço em disco com `df -h` e o crescimento dos logs. Alerte se alguma partição estiver acima de 85%.\n\n10. **Comparação com Auditoria Anterior**: Se existirem relatórios anteriores em /home/fred/security-reports/, compare os resultados e destaque novos achados ou problemas que persistem.\n\nSalve o relatório completo em /home/fred/security-reports/audit-$(date +%Y%m%d-%H%M).md com resumo executivo, nota de segurança (0-10), achados por severidade e as 5 correções mais urgentes.\n\nIMPORTANTE: Apenas diagnostique e reporte. NÃO faça nenhuma alteração no sistema. Crie o diretório /home/fred/security-reports/ se ele não existir.",
|
||||
"cronExpression": "0 13 * * *",
|
||||
"active": true,
|
||||
"created_at": "2026-02-26T04:05:08.137Z",
|
||||
"updated_at": "2026-02-27T07:10:14.897Z"
|
||||
},
|
||||
{
|
||||
"id": "ee6ebdb2-8fc5-4fa8-b55b-f0a2167ce5c8",
|
||||
"agentId": "07cfd2ae-e6a4-4254-891b-0989ff472381",
|
||||
"agentName": "Analisador de Armazenamento",
|
||||
"taskDescription": "Faça uma análise completa do armazenamento do sistema e gere o relatório detalhado conforme suas instruções.",
|
||||
"cronExpression": "20 13 * * *",
|
||||
"active": true,
|
||||
"created_at": "2026-02-26T06:34:31.668Z",
|
||||
"updated_at": "2026-02-27T07:10:37.766Z"
|
||||
},
|
||||
{
|
||||
"id": "7686b73f-61b9-4971-b1f1-8fede183165e",
|
||||
"agentId": "a53e89a1-2f82-4188-91b8-142705f94f47",
|
||||
"agentName": "Monitor SAE",
|
||||
"taskDescription": "Executar análise diária do sistema SAE no AMBIENTE REMOTO DHT (NÃO na máquina local).\n\nREGRAS DO AMBIENTE:\n- O DHT é um ambiente remoto de homologação da CAPES. NÃO roda em Docker/containers.\n- NÃO faça nenhuma modificação. Apenas leitura e diagnóstico.\n- Use o MCP (tools de consulta Oracle via unified-db ou consulta-refactor) para inspecionar os bancos de dados.\n- Alternativamente, use SQLPlus com as credenciais disponíveis no .env do projeto.\n- Os schemas Oracle relevantes para o SAE incluem: SAE, CORPORATIVO, FINANCEIRO, SEGURANCA, PARAMETRO.\n\nO QUE INSPECIONAR:\n1. Verificar namespaces e schemas dos bancos Oracle — listar tabelas principais do schema SAE e verificar se estão acessíveis.\n2. Consultar logs de erro das últimas 24h via Graylog (streams do SAE).\n3. Verificar execução dos cron jobs CADIN — consultar as tabelas de CADIN no schema SAE para ver se houve processamento recente.\n4. Verificar estatísticas de registros nas tabelas principais (PESSOA_EVENTO, EVENTO, etc.) — comparar contagens com dias anteriores se possível.\n5. Consultar health check geral via MCP (health_check()).\n\nGerar relatório diagnóstico em Markdown. Salvar em /home/fred/agent_reports/.",
|
||||
"cronExpression": "0 14 * * *",
|
||||
"active": true,
|
||||
"created_at": "2026-02-27T06:58:51.495Z",
|
||||
"updated_at": "2026-02-27T07:08:47.070Z"
|
||||
},
|
||||
{
|
||||
"id": "6406456d-837a-46da-b5d8-a3cce648a5e9",
|
||||
"agentId": "c954e0cd-48ab-4c6c-bc38-2d360bf417ae",
|
||||
"agentName": "Monitor CONSPRE",
|
||||
"taskDescription": "Executar análise diária do sistema CONSPRE no AMBIENTE REMOTO DHT (NÃO na máquina local).\n\nREGRAS DO AMBIENTE:\n- O DHT é um ambiente remoto de homologação da CAPES. NÃO roda em Docker/containers.\n- NÃO faça nenhuma modificação. Apenas leitura e diagnóstico.\n- Use o MCP (tools de consulta Oracle via unified-db ou consulta-refactor) para inspecionar os bancos de dados.\n- Alternativamente, use SQLPlus com as credenciais disponíveis no .env do projeto.\n- O schema Oracle relevante é CONSPRE. As coleções SODA estão em SODA_CONSPRE (CERTIFICADOS, INSCRICOES, INSCRICOES_2, ORCID).\n\nO QUE INSPECIONAR:\n1. PRIORIDADE: Verificar se a carga diária SODA (03:15) executou com sucesso — consultar as coleções SODA_CONSPRE e verificar registros recentes (últimas 24h).\n2. Verificar namespaces e schemas dos bancos Oracle — listar tabelas do schema CONSPRE e verificar acessibilidade.\n3. Consultar logs de erro das últimas 24h via Graylog (streams do CONSPRE).\n4. Verificar contagens de registros nas coleções SODA e tabelas principais — detectar anomalias.\n5. Consultar health check geral via MCP (health_check()).\n\nGerar relatório diagnóstico em Markdown. Salvar em /home/fred/agent_reports/.",
|
||||
"cronExpression": "20 14 * * *",
|
||||
"active": true,
|
||||
"created_at": "2026-02-27T06:58:51.561Z",
|
||||
"updated_at": "2026-02-27T07:09:07.156Z"
|
||||
},
|
||||
{
|
||||
"id": "7287ee31-9bb2-4f3a-8da4-b08085c9e9b6",
|
||||
"agentId": "1568189e-92c5-4139-8f18-035eca8e3753",
|
||||
"agentName": "Monitor AtuaCAPES",
|
||||
"taskDescription": "Executar análise diária do sistema AtuaCAPES no AMBIENTE REMOTO DHT (NÃO na máquina local).\n\nREGRAS DO AMBIENTE:\n- O DHT é um ambiente remoto de homologação da CAPES. NÃO roda em Docker/containers.\n- NÃO faça nenhuma modificação. Apenas leitura e diagnóstico.\n- Use o MCP (tools de consulta Elasticsearch via unified-db ou consulta-refactor) para inspecionar o cluster.\n- Alternativamente, use SQLPlus com as credenciais disponíveis no .env do projeto para consultas Oracle auxiliares.\n- O Elasticsearch é o data store principal do AtuaCAPES (índice ATUACAPES com campos nested: atuacoes, bolsas).\n\nO QUE INSPECIONAR:\n1. PRIORIDADE: Verificar saúde do cluster Elasticsearch — usar get_es_mapping() e search_elasticsearch com query de health.\n2. Verificar distribuição de tipos de atuação (Projeto, Docência, Inscrição Prêmio, Emprego, Evento, Consultor) — comparar volumes com referência.\n3. Consultar logs de erro das últimas 24h via Graylog (streams do AtuaCAPES).\n4. Verificar se existem schemas Oracle auxiliares acessíveis — usar health_check().\n5. Verificar contagem total de documentos no índice e detectar anomalias de volume.\n\nGerar relatório diagnóstico em Markdown. Salvar em /home/fred/agent_reports/.",
|
||||
"cronExpression": "40 14 * * *",
|
||||
"active": true,
|
||||
"created_at": "2026-02-27T06:58:51.660Z",
|
||||
"updated_at": "2026-02-27T07:09:30.438Z"
|
||||
},
|
||||
{
|
||||
"id": "2d92f8d0-0f82-4c29-9b48-9799f42e64d8",
|
||||
"agentId": "d5ca1c47-872c-47cc-8d80-5ae5491f8207",
|
||||
"agentName": "Monitor Consolidado CAPES",
|
||||
"taskDescription": "Executar análise CONSOLIDADA de todos os 3 sistemas CAPES no AMBIENTE REMOTO DHT (NÃO na máquina local).\n\nREGRAS DO AMBIENTE:\n- O DHT é um ambiente remoto de homologação da CAPES. NÃO roda em Docker/containers.\n- NÃO faça nenhuma modificação. Apenas leitura e diagnóstico.\n- Use o MCP (tools de consulta Oracle/Graylog/Elasticsearch via unified-db ou consulta-refactor).\n- Alternativamente, use SQLPlus com as credenciais disponíveis no .env.\n- Comece com health_check() para visão geral de todos os serviços.\n\nSISTEMAS PARA INSPECIONAR:\n1. SAE: schemas Oracle (SAE, CORPORATIVO, FINANCEIRO, SEGURANCA, PARAMETRO). Verificar tabelas principais, cron CADIN, logs Graylog.\n2. CONSPRE: schema Oracle CONSPRE + coleções SODA (SODA_CONSPRE). Verificar carga diária SODA, registros recentes, logs Graylog.\n3. AtuaCAPES: cluster Elasticsearch (índice ATUACAPES, campos nested). Verificar saúde do cluster, volumes, logs Graylog.\n\nPARA CADA SISTEMA:\n- Verificar namespaces e acessibilidade dos schemas/índices\n- Consultar logs de erro das últimas 24h via Graylog\n- Verificar contagens e detectar anomalias de volume\n\nComparar com relatórios anteriores em /home/fred/agent_reports/ se existirem. Gerar relatório executivo unificado em Markdown com nota de saúde (0-10) para cada sistema. Salvar em /home/fred/agent_reports/.",
|
||||
"cronExpression": "0 15 * * *",
|
||||
"active": true,
|
||||
"created_at": "2026-02-27T06:58:51.758Z",
|
||||
"updated_at": "2026-02-27T07:09:45.715Z"
|
||||
}
|
||||
]
|
||||
1
data/secrets.json
Normal file
1
data/secrets.json
Normal file
@@ -0,0 +1 @@
|
||||
[]
|
||||
5
data/settings.json
Normal file
5
data/settings.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"defaultModel": "claude-sonnet-4-6",
|
||||
"defaultWorkdir": "/home/fred/projetos",
|
||||
"maxConcurrent": 5
|
||||
}
|
||||
218
data/tasks.json
Normal file
218
data/tasks.json
Normal file
@@ -0,0 +1,218 @@
|
||||
[
|
||||
{
|
||||
"id": "55e69e93-0923-4131-8216-fff07b48116d",
|
||||
"name": "Análise de Arquitetura do Sistema",
|
||||
"category": "code-review",
|
||||
"description": "Analisar a arquitetura atual do sistema, identificar pontos de melhoria, gargalos de escalabilidade e propor refatorações arquiteturais. Gerar relatório com diagramas C4 e recomendações priorizadas.",
|
||||
"created_at": "2026-02-26T03:28:01.818Z",
|
||||
"updated_at": "2026-02-26T03:28:01.818Z"
|
||||
},
|
||||
{
|
||||
"id": "da1372c4-8420-4377-8981-b3a113da09d4",
|
||||
"name": "Criar API REST de Autenticação",
|
||||
"category": "code-review",
|
||||
"description": "Desenvolver endpoints de autenticação completos: registro, login, refresh token, logout, recuperação de senha. Implementar JWT com refresh tokens, rate limiting e validação de entrada.",
|
||||
"created_at": "2026-02-26T03:28:01.821Z",
|
||||
"updated_at": "2026-02-26T03:28:01.821Z"
|
||||
},
|
||||
{
|
||||
"id": "dd4bcce5-4948-459b-ba2c-9a7cec2dd5df",
|
||||
"name": "Desenvolver Dashboard de Monitoramento",
|
||||
"category": "code-review",
|
||||
"description": "Criar interface de dashboard responsiva com gráficos em tempo real, KPIs principais, filtros dinâmicos e exportação de dados. Utilizar React, TypeScript e bibliotecas de gráficos como Recharts.",
|
||||
"created_at": "2026-02-26T03:28:01.828Z",
|
||||
"updated_at": "2026-02-26T03:28:01.828Z"
|
||||
},
|
||||
{
|
||||
"id": "596361ac-78f1-436a-9270-bf8302f6a4a6",
|
||||
"name": "Criar Plano de Testes E2E",
|
||||
"category": "tests",
|
||||
"description": "Elaborar plano completo de testes end-to-end cobrindo todos os fluxos críticos do sistema. Implementar testes automatizados com Playwright, incluindo testes de regressão, smoke tests e testes de performance.",
|
||||
"created_at": "2026-02-26T03:28:01.832Z",
|
||||
"updated_at": "2026-02-26T03:28:01.832Z"
|
||||
},
|
||||
{
|
||||
"id": "b98993fb-a4ab-4333-aa1a-3e2273074685",
|
||||
"name": "Configurar Pipeline CI/CD",
|
||||
"category": "code-review",
|
||||
"description": "Configurar pipeline completo de CI/CD com GitHub Actions: build, lint, testes unitários, testes de integração, análise estática, build de Docker, deploy em staging e produção com aprovação manual.",
|
||||
"created_at": "2026-02-26T03:28:01.834Z",
|
||||
"updated_at": "2026-02-26T03:28:01.834Z"
|
||||
},
|
||||
{
|
||||
"id": "bb45f1af-965c-49ba-8d0f-fc3da89e8b36",
|
||||
"name": "Auditoria de Segurança do Código",
|
||||
"category": "security",
|
||||
"description": "Realizar auditoria completa de segurança no código-fonte: análise de vulnerabilidades OWASP Top 10, verificação de dependências (SCA), análise de secrets expostos, revisão de autenticação/autorização e compliance LGPD.",
|
||||
"created_at": "2026-02-26T03:28:01.837Z",
|
||||
"updated_at": "2026-02-26T03:28:01.837Z"
|
||||
},
|
||||
{
|
||||
"id": "29f4e223-2e82-415a-8487-6a7cde118298",
|
||||
"name": "Otimização de Queries SQL",
|
||||
"category": "performance",
|
||||
"description": "Analisar e otimizar queries SQL lentas identificadas no monitoramento. Usar EXPLAIN ANALYZE, criar índices estratégicos, reescrever queries N+1 e implementar estratégias de caching com Redis.",
|
||||
"created_at": "2026-02-26T03:28:01.839Z",
|
||||
"updated_at": "2026-02-26T03:28:01.839Z"
|
||||
},
|
||||
{
|
||||
"id": "14535b01-855e-4f2a-92b6-84bef154f8cb",
|
||||
"name": "Refatorar Módulo de Pagamentos",
|
||||
"category": "refactor",
|
||||
"description": "Refatorar o módulo de pagamentos aplicando Clean Architecture, separando camadas de domínio, aplicação e infraestrutura. Implementar Strategy Pattern para múltiplos gateways e adicionar circuit breaker.",
|
||||
"created_at": "2026-02-26T03:28:01.841Z",
|
||||
"updated_at": "2026-02-26T03:28:01.841Z"
|
||||
},
|
||||
{
|
||||
"id": "c324a8dc-cf81-4e31-9b96-5e2c17af2047",
|
||||
"name": "Documentar API com OpenAPI/Swagger",
|
||||
"category": "docs",
|
||||
"description": "Criar documentação completa da API usando especificação OpenAPI 3.0. Incluir descrições detalhadas de endpoints, schemas de request/response, exemplos, autenticação e códigos de erro.",
|
||||
"created_at": "2026-02-26T03:28:01.845Z",
|
||||
"updated_at": "2026-02-26T03:28:01.845Z"
|
||||
},
|
||||
{
|
||||
"id": "020ac1a7-11db-44e6-99d5-6892cd7ee4c7",
|
||||
"name": "Testes de Carga e Performance",
|
||||
"category": "performance",
|
||||
"description": "Executar testes de carga com K6 simulando cenários realistas: carga normal, pico, stress test e soak test. Gerar relatório com métricas de latência (p50, p95, p99), throughput e identificar bottlenecks.",
|
||||
"created_at": "2026-02-26T03:28:01.847Z",
|
||||
"updated_at": "2026-02-26T03:28:01.847Z"
|
||||
},
|
||||
{
|
||||
"id": "4cfdbae6-84bd-40a8-afd6-cb660c3bc450",
|
||||
"name": "Implementar Sistema de Cache",
|
||||
"category": "performance",
|
||||
"description": "Projetar e implementar estratégia de caching em múltiplas camadas: cache de aplicação (in-memory), Redis para cache distribuído, CDN para assets estáticos. Definir políticas de invalidação e TTL.",
|
||||
"created_at": "2026-02-26T03:28:01.849Z",
|
||||
"updated_at": "2026-02-26T03:28:01.849Z"
|
||||
},
|
||||
{
|
||||
"id": "6daefc78-6d0b-40e0-997a-6f9f5e02690c",
|
||||
"name": "Criar Componentes de Design System",
|
||||
"category": "code-review",
|
||||
"description": "Desenvolver biblioteca de componentes reutilizáveis seguindo Design System: Button, Input, Modal, Table, Card, Toast, Dropdown. Com variantes, acessibilidade WCAG AA, testes e documentação Storybook.",
|
||||
"created_at": "2026-02-26T03:28:01.852Z",
|
||||
"updated_at": "2026-02-26T03:28:01.852Z"
|
||||
},
|
||||
{
|
||||
"id": "a66caa2f-d8d3-4846-ab76-2bf74c199340",
|
||||
"name": "Migração de Banco de Dados",
|
||||
"category": "refactor",
|
||||
"description": "Planejar e executar migração de banco de dados com zero downtime. Criar scripts de migração, rollback, validação de dados e estratégia de blue-green deployment para o banco.",
|
||||
"created_at": "2026-02-26T03:28:01.855Z",
|
||||
"updated_at": "2026-02-26T03:28:01.855Z"
|
||||
},
|
||||
{
|
||||
"id": "8d4b625a-4b3b-4b3d-8347-26cff931ee5a",
|
||||
"name": "Implementar Observabilidade",
|
||||
"category": "code-review",
|
||||
"description": "Configurar stack completa de observabilidade: logs estruturados (Winston/Pino), métricas (Prometheus), tracing distribuído (OpenTelemetry), dashboards (Grafana) e alertas inteligentes.",
|
||||
"created_at": "2026-02-26T03:28:01.858Z",
|
||||
"updated_at": "2026-02-26T03:28:01.858Z"
|
||||
},
|
||||
{
|
||||
"id": "58b015e9-65d9-4fe9-848d-5bc888ef6c3d",
|
||||
"name": "Code Review de Pull Request",
|
||||
"category": "code-review",
|
||||
"description": "Realizar revisão detalhada de código em pull requests: verificar qualidade, padrões, segurança, performance, testes, documentação e aderência à arquitetura definida. Fornecer feedback construtivo.",
|
||||
"created_at": "2026-02-26T03:28:01.860Z",
|
||||
"updated_at": "2026-02-26T03:28:01.860Z"
|
||||
},
|
||||
{
|
||||
"id": "fa012b57-55a8-487f-962d-2cfc6fd9ce9d",
|
||||
"name": "Geração de CRUD Completo a partir de Schema de Entidade",
|
||||
"category": "automation",
|
||||
"description": "Receber um schema de entidade (JSON ou YAML) e gerar automaticamente a stack completa: model com validações, repository com queries otimizadas, service com regras de negócio, controller REST com tratamento de erros, rotas com middleware de autenticação, validação de entrada com Zod/Joi, testes unitários e de integração para cada camada, e migration de banco de dados. O output deve seguir rigorosamente a arquitetura e convenções do projeto-alvo, incluindo nomenclatura de arquivos, padrão de imports e estrutura de diretórios.",
|
||||
"created_at": "2026-02-28T03:38:20.972Z",
|
||||
"updated_at": "2026-02-28T03:38:20.972Z"
|
||||
},
|
||||
{
|
||||
"id": "c327e65b-f217-492a-801c-efa1991ecf54",
|
||||
"name": "Análise de Débito Técnico e Backlog Priorizado",
|
||||
"category": "code-review",
|
||||
"description": "Varrer o codebase completo utilizando métricas objetivas para identificar: code smells (funções longas, parâmetros excessivos, acoplamento alto), complexidade ciclomática acima do threshold, código duplicado com percentual de similaridade, dependências desatualizadas com CVEs conhecidos, TODO/FIXME/HACK abandonados há mais de 30 dias, módulos sem cobertura de testes, e violações de padrões arquiteturais. Gerar relatório estruturado com score de saúde do projeto (0-100), heatmap de áreas problemáticas, e backlog priorizado por matriz impacto × esforço com estimativas em story points.",
|
||||
"created_at": "2026-02-28T03:38:21.032Z",
|
||||
"updated_at": "2026-02-28T03:38:21.032Z"
|
||||
},
|
||||
{
|
||||
"id": "6b90b793-7666-4d8d-b81f-d21e1a2ab25f",
|
||||
"name": "Geração de Testes para Módulos Sem Cobertura",
|
||||
"category": "tests",
|
||||
"description": "Identificar automaticamente todos os módulos com cobertura de testes abaixo de 80%, analisar o código-fonte para compreender comportamento esperado, e gerar suíte de testes abrangente cobrindo: cenários de sucesso (happy path), edge cases e valores limítrofes, tratamento de erros e exceções, mocks/stubs de dependências externas (APIs, banco, filesystem), e testes de contrato para interfaces públicas. Usar o framework de testes do projeto (Jest/Vitest), seguir padrão AAA (Arrange-Act-Assert), e garantir que cada teste seja independente e determinístico. Output inclui os arquivos de teste e relatório de cobertura antes/depois.",
|
||||
"created_at": "2026-02-28T03:38:21.092Z",
|
||||
"updated_at": "2026-02-28T03:38:21.093Z"
|
||||
},
|
||||
{
|
||||
"id": "35222595-5e4d-4c6e-9ed0-12a577987480",
|
||||
"name": "Scaffolding de Microsserviço Production-Ready",
|
||||
"category": "automation",
|
||||
"description": "Gerar estrutura completa de um novo microsserviço pronto para produção, incluindo: Dockerfile multi-stage otimizado (build + runtime), docker-compose com dependências (banco, cache, message broker), endpoint /health com readiness e liveness checks, graceful shutdown com drain de conexões, logging estruturado em JSON (correlationId, requestId, userId), middleware global de tratamento de erros com códigos padronizados, autenticação via JWT com validação de roles, configuração 12-factor via variáveis de ambiente com validação no startup, Makefile com comandos de desenvolvimento, e README com instruções detalhadas de setup local, testes e deploy. O serviço deve seguir o template e stack tecnológica padrão da organização.",
|
||||
"created_at": "2026-02-28T03:38:21.150Z",
|
||||
"updated_at": "2026-02-28T03:38:21.150Z"
|
||||
},
|
||||
{
|
||||
"id": "0e076cef-b19e-42a1-9c82-592fa496ac11",
|
||||
"name": "Migração Incremental de JavaScript para TypeScript",
|
||||
"category": "migration",
|
||||
"description": "Executar migração incremental de módulos JavaScript para TypeScript com zero breaking changes: analisar o codebase para mapear dependências entre módulos, inferir tipos a partir de uso real e JSDoc existente, criar interfaces e types para DTOs e payloads de API, tipar parâmetros de funções e retornos com generics onde apropriado, configurar tsconfig.json com strict mode progressivo (começar permissivo e apertar gradualmente), resolver todos os erros de compilação, configurar path aliases, e atualizar scripts de build. Priorizar módulos core e compartilhados primeiro (utils, models, services), depois controllers e rotas. Cada módulo migrado deve compilar sem erros e manter 100% de compatibilidade com os consumidores existentes.",
|
||||
"created_at": "2026-02-28T03:38:21.211Z",
|
||||
"updated_at": "2026-02-28T03:38:21.211Z"
|
||||
},
|
||||
{
|
||||
"id": "4f1ca1ff-098c-400f-8989-797ef7e6a73c",
|
||||
"name": "Relatório de Compliance LGPD no Código-Fonte",
|
||||
"category": "compliance",
|
||||
"description": "Realizar varredura completa do codebase para mapear o ciclo de vida de dados pessoais: identificar campos PII (CPF, email, telefone, endereço, dados biométricos) em models, DTOs e schemas de banco; rastrear pontos de coleta (formulários, APIs, webhooks); verificar mecanismos de consentimento e base legal para cada tratamento; auditar logs e rastreamentos que possam conter dados sensíveis; verificar criptografia em trânsito (TLS) e em repouso; identificar compartilhamento com terceiros (APIs externas, analytics, SDKs); validar implementação do direito de exclusão e portabilidade. Gerar relatório RIPD (Relatório de Impacto à Proteção de Dados) com gaps identificados, classificação de risco (alto/médio/baixo), e plano de remediação priorizado com prazos sugeridos.",
|
||||
"created_at": "2026-02-28T03:38:21.272Z",
|
||||
"updated_at": "2026-02-28T03:38:21.272Z"
|
||||
},
|
||||
{
|
||||
"id": "297e370a-9b96-471f-8d4a-082e2d795305",
|
||||
"name": "Documentação de Onboarding para Novos Desenvolvedores",
|
||||
"category": "docs",
|
||||
"description": "Analisar o projeto completo e gerar documentação abrangente de onboarding: guia passo-a-passo de setup do ambiente local (dependências, variáveis de ambiente, banco de dados, serviços externos), mapa da arquitetura com diagramas C4 (contexto, container, componente), glossário de termos de domínio/negócio com exemplos, walkthrough comentado dos 5 fluxos mais críticos do sistema (da requisição HTTP até a persistência), catálogo de padrões e convenções do projeto (nomenclatura, estrutura de pastas, patterns utilizados), guia de troubleshooting com os 10 problemas mais comuns e suas soluções, e checklist de primeira semana com tarefas progressivas para o novo desenvolvedor ganhar confiança no codebase.",
|
||||
"created_at": "2026-02-28T03:38:21.343Z",
|
||||
"updated_at": "2026-02-28T03:38:21.343Z"
|
||||
},
|
||||
{
|
||||
"id": "7813b9df-a1e7-4d60-b2c3-61a224d4629c",
|
||||
"name": "Changelog Automatizado e Release Notes para Stakeholders",
|
||||
"category": "automation",
|
||||
"description": "Analisar todos os commits e PRs mergeados desde a última tag de release, classificar automaticamente por tipo (feature, bugfix, refactor, performance, docs, breaking change) usando Conventional Commits, extrair descrições significativas de cada mudança, identificar breaking changes com guia de migração, listar contribuidores e PRs associados. Gerar dois outputs: (1) CHANGELOG.md técnico seguindo o padrão Keep a Changelog com seções Added/Changed/Fixed/Removed/Security, e (2) Release Notes em linguagem acessível para stakeholders não-técnicos, destacando novas funcionalidades visíveis ao usuário, melhorias de performance com métricas quando disponíveis, e bugs corrigidos que impactavam a experiência do usuário. Incluir resumo executivo de 3-5 linhas no topo.",
|
||||
"created_at": "2026-02-28T03:38:21.405Z",
|
||||
"updated_at": "2026-02-28T03:38:21.405Z"
|
||||
},
|
||||
{
|
||||
"id": "82b9aec1-9853-4819-ae2a-3a8bbf97032c",
|
||||
"name": "Auditoria de Dependências e Plano de Atualização Segura",
|
||||
"category": "maintenance",
|
||||
"description": "Executar auditoria completa de todas as dependências diretas e transitivas do projeto: identificar vulnerabilidades conhecidas (CVEs) com severidade CVSS, pacotes deprecated ou abandonados (sem commits há 12+ meses), licenças incompatíveis com uso comercial, dependências com major versions defasadas (2+ majors atrás), pacotes duplicados em versões diferentes na árvore de dependências, e dependências com alternativas mais leves/mantidas. Gerar relatório com: inventário completo de dependências com versão atual vs mais recente, matriz de risco (probabilidade × impacto), breaking changes documentados entre versões, e plano de atualização faseado ordenado por criticidade — atualizações de segurança primeiro, depois minor/patch seguras, e por último majors com breaking changes. Cada atualização deve incluir o changelog relevante e testes de regressão sugeridos.",
|
||||
"created_at": "2026-02-28T03:38:21.467Z",
|
||||
"updated_at": "2026-02-28T03:38:21.467Z"
|
||||
},
|
||||
{
|
||||
"id": "483e22eb-aa2b-4d08-8377-af50ec02037a",
|
||||
"name": "Implementar API de Webhooks com Retry e Monitoramento",
|
||||
"category": "code-generation",
|
||||
"description": "Projetar e implementar sistema completo de webhooks outbound: endpoint de registro de webhooks com validação de URL (SSRF prevention) e secret para assinatura HMAC-SHA256, sistema de dispatch assíncrono com fila (Bull/BullMQ ou similar), política de retry com backoff exponencial (1s, 5s, 30s, 2min, 15min) e max 5 tentativas, circuit breaker por destino para evitar sobrecarga em endpoints instáveis, payload padronizado com envelope (event_type, timestamp, delivery_id, data), verificação de assinatura documentada para consumidores, endpoint de logs de entrega com status (pending, delivered, failed, exhausted) e latência, dashboard de monitoramento com taxa de sucesso por webhook e alertas para falhas recorrentes. Incluir testes de integração simulando cenários de falha (timeout, 5xx, rede) e documentação da API para integradores externos.",
|
||||
"created_at": "2026-02-28T03:38:21.530Z",
|
||||
"updated_at": "2026-02-28T03:38:21.530Z"
|
||||
},
|
||||
{
|
||||
"id": "b98c3d99-1e05-4c3e-b392-49e047b1789a",
|
||||
"name": "Implementar Feature Flags com Rollout Gradual",
|
||||
"category": "automation",
|
||||
"description": "Projetar e implementar sistema de feature flags completo para deploys seguros: criar módulo server-side com avaliação de flags por contexto (usuário, ambiente, percentual, segmento), SDK client-side leve (<5KB) com cache local e fallback offline, estratégias de rollout configuráveis (canary 1%→5%→25%→100%, por tenant, por região, por role), toggle kill-switch para desabilitar features instantaneamente sem redeploy, persistência de configurações em banco com versionamento e audit log de alterações, API REST para gestão das flags com controle de acesso, e integração com stack de observabilidade para correlacionar métricas de negócio com ativação de flags. Incluir testes para cada estratégia de rollout e documentação com exemplos de uso para a equipe.",
|
||||
"created_at": "2026-02-28T03:38:40.215Z",
|
||||
"updated_at": "2026-02-28T03:38:40.215Z"
|
||||
},
|
||||
{
|
||||
"id": "92fafb45-d568-47e8-85da-f7f14a930a9c",
|
||||
"name": "Revisão e Hardening de Configurações de Infraestrutura",
|
||||
"category": "security",
|
||||
"description": "Auditar e fortalecer todas as configurações de infraestrutura como código (IaC) do projeto: analisar Dockerfiles verificando imagem base (não usar latest, preferir Alpine/distroless), usuário não-root, multi-stage build, .dockerignore adequado, e scan de vulnerabilidades na imagem; revisar docker-compose validando rede isolada, limites de recursos (CPU/memória), volumes com permissões mínimas, e secrets não hardcoded; auditar CI/CD pipelines verificando permissões de tokens (princípio do menor privilégio), secrets management, pinning de actions/versões, e SAST/DAST integrados; verificar variáveis de ambiente sensíveis (senhas, tokens, chaves de API) garantindo que não estejam em código, logs ou imagens; e validar configurações de CORS, CSP, HSTS e demais headers de segurança. Gerar relatório com findings categorizados por severidade (crítico/alto/médio/baixo), evidência do problema, impacto potencial, e fix sugerido com código.",
|
||||
"created_at": "2026-02-28T03:38:40.288Z",
|
||||
"updated_at": "2026-02-28T03:38:40.288Z"
|
||||
}
|
||||
]
|
||||
14
data/webhooks.json
Normal file
14
data/webhooks.json
Normal file
@@ -0,0 +1,14 @@
|
||||
[
|
||||
{
|
||||
"id": "b5b42ba4-e811-4f34-b399-a47267b60b31",
|
||||
"name": "Web Hook de Segurança",
|
||||
"targetType": "agent",
|
||||
"targetId": "d026be9f-0b16-492e-9e3c-833f839f6d72",
|
||||
"token": "069e296d1b756b99003d69e0cc9a48ec051f7f0f2b901e4d",
|
||||
"active": true,
|
||||
"lastTriggeredAt": "2026-02-27T01:48:07.754Z",
|
||||
"triggerCount": 2,
|
||||
"created_at": "2026-02-27T01:29:21.901Z",
|
||||
"updated_at": "2026-02-27T01:48:07.754Z"
|
||||
}
|
||||
]
|
||||
BIN
docs/dashboard.png
Normal file
BIN
docs/dashboard.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 146 KiB |
230
package-lock.json
generated
230
package-lock.json
generated
@@ -1,14 +1,18 @@
|
||||
{
|
||||
"name": "agents-orchestrator",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "agents-orchestrator",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"dependencies": {
|
||||
"compression": "^1.8.1",
|
||||
"express": "^4.21.0",
|
||||
"express-rate-limit": "^8.2.1",
|
||||
"helmet": "^8.1.0",
|
||||
"multer": "^2.0.2",
|
||||
"node-cron": "^3.0.3",
|
||||
"uuid": "^10.0.0",
|
||||
"ws": "^8.18.0"
|
||||
@@ -27,6 +31,12 @@
|
||||
"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": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
|
||||
@@ -57,6 +67,23 @@
|
||||
"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": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||
@@ -95,6 +122,60 @@
|
||||
"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": {
|
||||
"version": "0.5.4",
|
||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
|
||||
@@ -279,6 +360,24 @@
|
||||
"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": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
|
||||
@@ -397,6 +496,15 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/helmet": {
|
||||
"version": "8.1.0",
|
||||
"resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz",
|
||||
"integrity": "sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/http-errors": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
||||
@@ -435,6 +543,15 @@
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"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": {
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||
@@ -513,12 +630,51 @@
|
||||
"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": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||
"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": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
|
||||
@@ -549,6 +705,15 @@
|
||||
"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": {
|
||||
"version": "1.13.4",
|
||||
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
||||
@@ -573,6 +738,15 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/on-headers": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz",
|
||||
"integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/parseurl": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
||||
@@ -640,6 +814,20 @@
|
||||
"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": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||
@@ -798,6 +986,23 @@
|
||||
"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": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
|
||||
@@ -820,6 +1025,12 @@
|
||||
"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": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
|
||||
@@ -829,6 +1040,12 @@
|
||||
"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": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
|
||||
@@ -880,6 +1097,15 @@
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,9 +9,13 @@
|
||||
"dev": "node --watch server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"compression": "^1.8.1",
|
||||
"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",
|
||||
"uuid": "^10.0.0"
|
||||
"uuid": "^10.0.0",
|
||||
"ws": "^8.18.0"
|
||||
}
|
||||
}
|
||||
|
||||
1478
public/app.html
Normal file
1478
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
1574
public/index.html
1574
public/index.html
File diff suppressed because it is too large
Load Diff
102
public/js/api.js
102
public/js/api.js
@@ -38,11 +38,29 @@ const API = {
|
||||
create(data) { return API.request('POST', '/agents', data); },
|
||||
update(id, data) { return API.request('PUT', `/agents/${id}`, data); },
|
||||
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}`); },
|
||||
continue(id, sessionId, message) { return API.request('POST', `/agents/${id}/continue`, { sessionId, message }); },
|
||||
export(id) { return API.request('GET', `/agents/${id}/export`); },
|
||||
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: {
|
||||
@@ -66,14 +84,17 @@ const API = {
|
||||
create(data) { return API.request('POST', '/pipelines', data); },
|
||||
update(id, data) { return API.request('PUT', `/pipelines/${id}`, data); },
|
||||
delete(id) { return API.request('DELETE', `/pipelines/${id}`); },
|
||||
execute(id, input, workingDirectory) {
|
||||
execute(id, input, workingDirectory, contextFiles, repoName, repoBranch) {
|
||||
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);
|
||||
},
|
||||
cancel(id) { return API.request('POST', `/pipelines/${id}/cancel`); },
|
||||
approve(id) { return API.request('POST', `/pipelines/${id}/approve`); },
|
||||
reject(id) { return API.request('POST', `/pipelines/${id}/reject`); },
|
||||
resume(executionId) { return API.request('POST', `/pipelines/resume/${executionId}`); },
|
||||
},
|
||||
|
||||
webhooks: {
|
||||
@@ -81,16 +102,26 @@ const API = {
|
||||
create(data) { return API.request('POST', '/webhooks', data); },
|
||||
update(id, data) { return API.request('PUT', `/webhooks/${id}`, data); },
|
||||
delete(id) { return API.request('DELETE', `/webhooks/${id}`); },
|
||||
test(id) { return API.request('POST', `/webhooks/${id}/test`); },
|
||||
},
|
||||
|
||||
stats: {
|
||||
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: {
|
||||
status() { return API.request('GET', '/system/status'); },
|
||||
info() { return API.request('GET', '/system/info'); },
|
||||
activeExecutions() { return API.request('GET', '/executions/active'); },
|
||||
cancelAll() { return API.request('POST', '/executions/cancel-all'); },
|
||||
},
|
||||
|
||||
settings: {
|
||||
@@ -98,6 +129,58 @@ const API = {
|
||||
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`); },
|
||||
},
|
||||
|
||||
projects: {
|
||||
browse(path) { return API.request('GET', `/browse?path=${encodeURIComponent(path || '/home')}`); },
|
||||
importLocal(sourcePath, repoName) { return API.request('POST', '/projects/import', { sourcePath, repoName }); },
|
||||
async upload(files, paths, repoName) {
|
||||
const form = new FormData();
|
||||
form.append('repoName', repoName);
|
||||
form.append('paths', JSON.stringify(paths));
|
||||
for (const f of files) form.append('files', f);
|
||||
const response = await fetch('/api/projects/upload', {
|
||||
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;
|
||||
},
|
||||
},
|
||||
|
||||
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 }); },
|
||||
commitPush(path, message) { return API.request('POST', '/files/commit-push', { path, message }); },
|
||||
},
|
||||
|
||||
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: {
|
||||
recent(limit = 20) { return API.request('GET', `/executions/recent?limit=${limit}`); },
|
||||
history(params = {}) {
|
||||
@@ -107,6 +190,19 @@ const API = {
|
||||
get(id) { return API.request('GET', `/executions/history/${id}`); },
|
||||
delete(id) { return API.request('DELETE', `/executions/history/${id}`); },
|
||||
clearAll() { return API.request('DELETE', '/executions/history'); },
|
||||
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);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
298
public/js/app.js
298
public/js/app.js
@@ -5,6 +5,8 @@ const App = {
|
||||
wsReconnectTimer: null,
|
||||
_initialized: false,
|
||||
_lastAgentName: '',
|
||||
_executeDropzone: null,
|
||||
_pipelineDropzone: null,
|
||||
|
||||
sectionTitles: {
|
||||
dashboard: 'Dashboard',
|
||||
@@ -15,21 +17,58 @@ const App = {
|
||||
webhooks: 'Webhooks',
|
||||
terminal: 'Terminal',
|
||||
history: 'Histórico',
|
||||
import: 'Importar Projeto',
|
||||
files: 'Projetos',
|
||||
settings: 'Configurações',
|
||||
},
|
||||
|
||||
sections: ['dashboard', 'agents', 'tasks', 'schedules', 'pipelines', 'webhooks', 'terminal', 'history', 'import', 'files', 'settings'],
|
||||
|
||||
init() {
|
||||
if (App._initialized) return;
|
||||
App._initialized = true;
|
||||
|
||||
const savedTheme = localStorage.getItem('theme') || 'dark';
|
||||
document.documentElement.setAttribute('data-theme', savedTheme);
|
||||
|
||||
App.setupNavigation();
|
||||
App.setupWebSocket();
|
||||
App.setupEventListeners();
|
||||
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();
|
||||
|
||||
Terminal.restoreIfActive();
|
||||
|
||||
const initialSection = location.hash.replace('#', '') || 'dashboard';
|
||||
App.navigateTo(App.sections.includes(initialSection) ? initialSection : 'dashboard');
|
||||
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() {
|
||||
@@ -47,6 +86,12 @@ const App = {
|
||||
},
|
||||
|
||||
navigateTo(section) {
|
||||
if (location.hash !== `#${section}`) {
|
||||
history.pushState(null, '', `#${section}`);
|
||||
}
|
||||
|
||||
if (typeof FlowEditor !== 'undefined') FlowEditor._teardown();
|
||||
|
||||
document.querySelectorAll('.section').forEach((el) => {
|
||||
const isActive = el.id === section;
|
||||
el.classList.toggle('active', isActive);
|
||||
@@ -75,6 +120,8 @@ const App = {
|
||||
case 'pipelines': await PipelinesUI.load(); break;
|
||||
case 'webhooks': await WebhooksUI.load(); break;
|
||||
case 'history': await HistoryUI.load(); break;
|
||||
case 'import': await ImportUI.load(); break;
|
||||
case 'files': await FilesUI.load(); break;
|
||||
case 'settings': await SettingsUI.load(); break;
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -138,8 +185,18 @@ const App = {
|
||||
|
||||
case 'execution_output': {
|
||||
Terminal.stopProcessing();
|
||||
const evtType = data.data?.type || 'chunk';
|
||||
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);
|
||||
}
|
||||
App._updateActiveBadge();
|
||||
@@ -174,23 +231,55 @@ const App = {
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof NotificationsUI !== 'undefined') {
|
||||
NotificationsUI.loadCount();
|
||||
NotificationsUI.showBrowserNotification('Execução concluída', data.agentName || 'Agente');
|
||||
}
|
||||
|
||||
Toast.success('Execução concluída');
|
||||
App.refreshCurrentSection();
|
||||
App._updateActiveBadge();
|
||||
App._checkStopTimer();
|
||||
break;
|
||||
}
|
||||
|
||||
case 'execution_error':
|
||||
Terminal.stopProcessing();
|
||||
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'}`);
|
||||
App._updateActiveBadge();
|
||||
App._checkStopTimer();
|
||||
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': {
|
||||
Terminal.stopProcessing();
|
||||
const stepEvtType = data.data?.type || 'chunk';
|
||||
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);
|
||||
}
|
||||
break;
|
||||
@@ -198,6 +287,7 @@ const App = {
|
||||
|
||||
case 'pipeline_step_start':
|
||||
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.startProcessing(data.agentName);
|
||||
break;
|
||||
@@ -210,13 +300,18 @@ const App = {
|
||||
|
||||
case 'pipeline_complete':
|
||||
Terminal.stopProcessing();
|
||||
Terminal._hideTimer();
|
||||
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');
|
||||
App.refreshCurrentSection();
|
||||
break;
|
||||
|
||||
case 'pipeline_error':
|
||||
Terminal.stopProcessing();
|
||||
Terminal._hideTimer();
|
||||
Terminal.addLine(`Erro no passo ${data.stepIndex + 1}: ${data.error}`, 'error');
|
||||
Toast.error('Erro no pipeline');
|
||||
break;
|
||||
@@ -241,7 +336,56 @@ const App = {
|
||||
|
||||
case 'pipeline_status':
|
||||
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) {
|
||||
@@ -253,7 +397,7 @@ const App = {
|
||||
<div class="approval-icon"><i data-lucide="shield-alert"></i></div>
|
||||
<div class="approval-text">
|
||||
<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 class="approval-actions">
|
||||
<button class="btn btn--primary btn--sm" id="approval-approve-btn" type="button">Aprovar</button>
|
||||
@@ -264,7 +408,7 @@ const App = {
|
||||
container.hidden = false;
|
||||
container.dataset.pipelineId = pipelineId;
|
||||
|
||||
if (window.lucide) lucide.createIcons({ nodes: [container] });
|
||||
Utils.refreshIcons(container);
|
||||
|
||||
document.getElementById('approval-approve-btn')?.addEventListener('click', () => {
|
||||
App._handleApproval(pipelineId, true);
|
||||
@@ -424,6 +568,18 @@ const App = {
|
||||
|
||||
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', () => {
|
||||
Terminal.clear();
|
||||
Terminal.disableChat();
|
||||
@@ -538,6 +694,8 @@ const App = {
|
||||
case 'edit': AgentsUI.openEditModal(id); break;
|
||||
case 'export': AgentsUI.export(id); break;
|
||||
case 'delete': AgentsUI.delete(id); break;
|
||||
case 'duplicate': AgentsUI.duplicate(id); break;
|
||||
case 'versions': AgentsUI.openVersionsModal(id); break;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -587,6 +745,7 @@ const App = {
|
||||
switch (action) {
|
||||
case 'execute-pipeline': PipelinesUI.execute(id); break;
|
||||
case 'edit-pipeline': PipelinesUI.openEditModal(id); break;
|
||||
case 'flow-pipeline': FlowEditor.open(id); break;
|
||||
case 'delete-pipeline': PipelinesUI.delete(id); break;
|
||||
}
|
||||
});
|
||||
@@ -598,6 +757,8 @@ const App = {
|
||||
switch (action) {
|
||||
case 'view-execution': HistoryUI.viewDetail(id); break;
|
||||
case 'delete-execution': HistoryUI.deleteExecution(id); break;
|
||||
case 'retry': HistoryUI.retryExecution(id); break;
|
||||
case 'resume-pipeline': HistoryUI.resumePipeline(id); break;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -610,6 +771,23 @@ const App = {
|
||||
case 'delete-webhook': WebhooksUI.delete(id); break;
|
||||
case 'copy-webhook-url': WebhooksUI.copyUrl(url); 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 'commit-push': FilesUI.commitPush(path); break;
|
||||
case 'publish-project': FilesUI.publishProject(path); break;
|
||||
case 'delete-entry': FilesUI.deleteEntry(path, el.dataset.entryType); break;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -624,6 +802,7 @@ const App = {
|
||||
case 'move-up': PipelinesUI.moveStep(stepIndex, -1); break;
|
||||
case 'move-down': PipelinesUI.moveStep(stepIndex, 1); break;
|
||||
case 'remove': PipelinesUI.removeStep(stepIndex); break;
|
||||
case 'toggle-mode': PipelinesUI.toggleMode(stepIndex); break;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -661,8 +840,8 @@ const App = {
|
||||
hidden.value = JSON.stringify(tags);
|
||||
chips.innerHTML = tags.map((t) => `
|
||||
<span class="tag-chip">
|
||||
${t}
|
||||
<button type="button" class="tag-remove" data-tag="${t}" aria-label="Remover tag ${t}">×</button>
|
||||
${Utils.escapeHtml(t)}
|
||||
<button type="button" class="tag-remove" data-tag="${Utils.escapeHtml(t)}" aria-label="Remover tag ${Utils.escapeHtml(t)}">×</button>
|
||||
</span>
|
||||
`).join('');
|
||||
};
|
||||
@@ -693,6 +872,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() {
|
||||
const agentId = document.getElementById('execute-agent-select')?.value
|
||||
|| document.getElementById('execute-agent-id')?.value;
|
||||
@@ -709,16 +943,28 @@ const App = {
|
||||
}
|
||||
|
||||
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 {
|
||||
const selectEl = document.getElementById('execute-agent-select');
|
||||
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();
|
||||
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');
|
||||
App.navigateTo('terminal');
|
||||
Toast.info('Execução iniciada');
|
||||
@@ -768,14 +1014,32 @@ const App = {
|
||||
return;
|
||||
}
|
||||
|
||||
const isTyping = ['INPUT', 'TEXTAREA', 'SELECT'].includes(document.activeElement?.tagName);
|
||||
if (isTyping) return;
|
||||
const isInInput = ['INPUT', 'TEXTAREA', 'SELECT'].includes(document.activeElement?.tagName);
|
||||
if (isInInput) return;
|
||||
|
||||
if (e.key === 'n' || e.key === 'N') {
|
||||
if (App.currentSection === 'agents') {
|
||||
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 +1058,9 @@ const App = {
|
||||
if (countEl) countEl.textContent = count;
|
||||
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');
|
||||
if (terminalSelect && Array.isArray(active)) {
|
||||
const existing = new Set(
|
||||
@@ -816,6 +1083,15 @@ const App = {
|
||||
}
|
||||
},
|
||||
|
||||
async _checkStopTimer() {
|
||||
try {
|
||||
const active = await API.system.activeExecutions();
|
||||
if (!Array.isArray(active) || active.length === 0) {
|
||||
Terminal._hideTimer();
|
||||
}
|
||||
} catch {}
|
||||
},
|
||||
|
||||
startPeriodicRefresh() {
|
||||
setInterval(async () => {
|
||||
await App._updateActiveBadge();
|
||||
|
||||
@@ -39,8 +39,19 @@ const AgentsUI = {
|
||||
|
||||
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();
|
||||
agents.forEach((agent) => {
|
||||
sorted.forEach((agent) => {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.innerHTML = AgentsUI.renderCard(agent);
|
||||
fragment.appendChild(wrapper.firstElementChild);
|
||||
@@ -48,7 +59,7 @@ const AgentsUI = {
|
||||
|
||||
grid.appendChild(fragment);
|
||||
|
||||
if (window.lucide) lucide.createIcons({ nodes: [grid] });
|
||||
Utils.refreshIcons(grid);
|
||||
},
|
||||
|
||||
filter(searchText, statusFilter) {
|
||||
@@ -76,23 +87,33 @@ const AgentsUI = {
|
||||
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 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 `
|
||||
<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-top">
|
||||
<div class="agent-avatar" style="background-color: ${color}" aria-hidden="true">
|
||||
<span>${initials}</span>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${agent.description ? `<p class="agent-description">${agent.description}</p>` : ''}
|
||||
${agent.description ? `<p class="agent-description">${Utils.escapeHtml(agent.description)}</p>` : ''}
|
||||
${tags}
|
||||
|
||||
<div class="agent-meta">
|
||||
@@ -112,18 +133,25 @@ const AgentsUI = {
|
||||
<i data-lucide="play"></i>
|
||||
Executar
|
||||
</button>
|
||||
<button class="btn btn-ghost btn-sm" data-action="edit" data-id="${agent.id}">
|
||||
<div class="agent-actions-icons">
|
||||
<button class="btn btn-ghost btn-icon btn-sm" data-action="edit" data-id="${agent.id}" title="Editar agente">
|
||||
<i data-lucide="pencil"></i>
|
||||
Editar
|
||||
</button>
|
||||
<button class="btn btn-ghost btn-icon btn-sm" data-action="duplicate" data-id="${agent.id}" title="Duplicar agente">
|
||||
<i data-lucide="copy"></i>
|
||||
</button>
|
||||
<button class="btn btn-ghost btn-icon btn-sm" data-action="export" data-id="${agent.id}" title="Exportar agente">
|
||||
<i data-lucide="download"></i>
|
||||
</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>
|
||||
`;
|
||||
},
|
||||
|
||||
@@ -152,10 +180,31 @@ const AgentsUI = {
|
||||
const maxTurns = document.getElementById('agent-max-turns');
|
||||
if (maxTurns) maxTurns.value = '0';
|
||||
|
||||
const workdir = document.getElementById('agent-workdir');
|
||||
if (workdir) workdir.value = '/home/projetos/';
|
||||
|
||||
const permissionMode = document.getElementById('agent-permission-mode');
|
||||
if (permissionMode) permissionMode.value = '';
|
||||
|
||||
const retryToggle = document.getElementById('agent-retry-toggle');
|
||||
if (retryToggle) retryToggle.checked = false;
|
||||
|
||||
const retryMaxGroup = document.getElementById('agent-retry-max-group');
|
||||
if (retryMaxGroup) retryMaxGroup.style.display = 'none';
|
||||
|
||||
const retryMax = document.getElementById('agent-retry-max');
|
||||
if (retryMax) retryMax.value = '3';
|
||||
|
||||
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');
|
||||
AgentsUI._setupModalListeners();
|
||||
},
|
||||
|
||||
async openEditModal(agentId) {
|
||||
@@ -196,7 +245,25 @@ const AgentsUI = {
|
||||
).join('');
|
||||
}
|
||||
|
||||
const retryToggle = document.getElementById('agent-retry-toggle');
|
||||
const retryOnFailure = agent.config && agent.config.retryOnFailure;
|
||||
if (retryToggle) retryToggle.checked = !!retryOnFailure;
|
||||
|
||||
const retryMaxGroup = document.getElementById('agent-retry-max-group');
|
||||
if (retryMaxGroup) retryMaxGroup.style.display = retryOnFailure ? '' : 'none';
|
||||
|
||||
const retryMax = document.getElementById('agent-retry-max');
|
||||
if (retryMax) retryMax.value = (agent.config && agent.config.maxRetries) || '3';
|
||||
|
||||
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');
|
||||
AgentsUI._setupModalListeners();
|
||||
} catch (err) {
|
||||
Toast.error(`Erro ao carregar agente: ${err.message}`);
|
||||
}
|
||||
@@ -234,6 +301,9 @@ const AgentsUI = {
|
||||
allowedTools: document.getElementById('agent-allowed-tools')?.value.trim() || '',
|
||||
maxTurns: parseInt(document.getElementById('agent-max-turns')?.value) || 0,
|
||||
permissionMode: document.getElementById('agent-permission-mode')?.value || '',
|
||||
retryOnFailure: !!document.getElementById('agent-retry-toggle')?.checked,
|
||||
maxRetries: parseInt(document.getElementById('agent-retry-max')?.value) || 3,
|
||||
delegateTo: document.getElementById('agent-delegate-to')?.value || '',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -279,7 +349,7 @@ const AgentsUI = {
|
||||
selectEl.innerHTML = '<option value="">Selecionar agente...</option>' +
|
||||
allAgents
|
||||
.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('');
|
||||
|
||||
selectEl.value = agentId;
|
||||
@@ -294,8 +364,21 @@ const AgentsUI = {
|
||||
const instructionsEl = document.getElementById('execute-instructions');
|
||||
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();
|
||||
|
||||
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');
|
||||
} catch (err) {
|
||||
Toast.error(`Erro ao abrir modal de execução: ${err.message}`);
|
||||
@@ -311,7 +394,7 @@ const AgentsUI = {
|
||||
savedTaskSelect.innerHTML = '<option value="">Digitar manualmente...</option>' +
|
||||
tasks.map((t) => {
|
||||
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('');
|
||||
AgentsUI._savedTasksCache = tasks;
|
||||
} catch {
|
||||
@@ -322,6 +405,16 @@ const AgentsUI = {
|
||||
|
||||
_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) {
|
||||
try {
|
||||
const data = await API.agents.export(agentId);
|
||||
@@ -393,6 +486,231 @@ const AgentsUI = {
|
||||
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;
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
const DashboardUI = {
|
||||
charts: {},
|
||||
|
||||
async load() {
|
||||
try {
|
||||
const [status, recentExecs] = await Promise.all([
|
||||
@@ -9,11 +11,253 @@ const DashboardUI = {
|
||||
DashboardUI.updateMetrics(status);
|
||||
DashboardUI.updateRecentActivity(recentExecs || []);
|
||||
DashboardUI.updateSystemStatus(status);
|
||||
DashboardUI.setupChartPeriod();
|
||||
DashboardUI.loadCharts();
|
||||
} catch (err) {
|
||||
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) {
|
||||
const metrics = {
|
||||
'metric-total-agents': status.agents?.total ?? 0,
|
||||
@@ -71,15 +315,15 @@ const DashboardUI = {
|
||||
<span>Nenhuma execução recente</span>
|
||||
</li>
|
||||
`;
|
||||
if (window.lucide) lucide.createIcons({ nodes: [list] });
|
||||
Utils.refreshIcons(list);
|
||||
return;
|
||||
}
|
||||
|
||||
list.innerHTML = executions.map((exec) => {
|
||||
const statusClass = DashboardUI._statusBadgeClass(exec.status);
|
||||
const statusLabel = DashboardUI._statusLabel(exec.status);
|
||||
const name = exec.agentName || exec.pipelineName || exec.agentId || 'Execução';
|
||||
const taskText = exec.task || exec.input || '';
|
||||
const name = Utils.escapeHtml(exec.agentName || exec.pipelineName || exec.agentId || 'Execução');
|
||||
const taskText = Utils.escapeHtml(exec.task || exec.input || '');
|
||||
const typeBadge = exec.type === 'pipeline'
|
||||
? '<span class="badge badge--purple" style="font-size:0.6rem;padding:1px 5px;">Pipeline</span> '
|
||||
: '';
|
||||
@@ -110,6 +354,14 @@ const DashboardUI = {
|
||||
}).join('');
|
||||
},
|
||||
|
||||
setupChartPeriod() {
|
||||
const chartPeriod = document.getElementById('chart-period');
|
||||
if (chartPeriod && !chartPeriod._listenerAdded) {
|
||||
chartPeriod._listenerAdded = true;
|
||||
chartPeriod.addEventListener('change', () => DashboardUI.loadCharts());
|
||||
}
|
||||
},
|
||||
|
||||
updateSystemStatus(status) {
|
||||
const wsBadge = document.getElementById('system-ws-status-badge');
|
||||
if (wsBadge) {
|
||||
@@ -117,6 +369,18 @@ const DashboardUI = {
|
||||
wsBadge.textContent = wsConnected ? 'Conectado' : 'Desconectado';
|
||||
wsBadge.className = `badge ${wsConnected ? 'badge--green' : 'badge--red'}`;
|
||||
}
|
||||
|
||||
const claudeBadge = document.getElementById('system-claude-status-badge');
|
||||
if (claudeBadge) {
|
||||
API.system.info().then((info) => {
|
||||
const available = info.claudeVersion && info.claudeVersion !== 'N/A';
|
||||
claudeBadge.textContent = available ? info.claudeVersion : 'Indisponível';
|
||||
claudeBadge.className = `badge ${available ? 'badge--green' : 'badge--red'}`;
|
||||
}).catch(() => {
|
||||
claudeBadge.textContent = 'Indisponível';
|
||||
claudeBadge.className = 'badge badge--red';
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
_statusBadgeClass(status) {
|
||||
|
||||
236
public/js/components/files.js
Normal file
236
public/js/components/files.js
Normal file
@@ -0,0 +1,236 @@
|
||||
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 commitPushBtn = isRootDir
|
||||
? `<button class="btn btn--ghost btn--sm btn-commit-push" data-action="commit-push" data-path="${Utils.escapeHtml(fullPath)}" title="Commit & Push para o Gitea"><i data-lucide="git-commit-horizontal" style="width:14px;height:14px"></i></button>`
|
||||
: '';
|
||||
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}${commitPushBtn}${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 commitPush(path) {
|
||||
const name = path.split('/').pop();
|
||||
const message = await Modal.prompt(
|
||||
'Commit & Push',
|
||||
`Mensagem do commit para <strong>${name}</strong>:`,
|
||||
`Atualização - ${new Date().toLocaleDateString('pt-BR')} ${new Date().toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' })}`
|
||||
);
|
||||
if (message === null) return;
|
||||
|
||||
try {
|
||||
Toast.info('Realizando commit e push...');
|
||||
const result = await API.files.commitPush(path, message || undefined);
|
||||
|
||||
if (result.status === 'clean') {
|
||||
Toast.info(result.message);
|
||||
} else {
|
||||
Toast.success(`${result.changes} arquivo(s) enviados ao Gitea`);
|
||||
}
|
||||
} catch (err) {
|
||||
Toast.error(`Erro no commit/push: ${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;
|
||||
766
public/js/components/flow-editor.js
Normal file
766
public/js/components/flow-editor.js
Normal file
@@ -0,0 +1,766 @@
|
||||
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;
|
||||
}
|
||||
|
||||
FlowEditor._teardown();
|
||||
},
|
||||
|
||||
_teardown() {
|
||||
const overlay = FlowEditor._overlay;
|
||||
if (!overlay || overlay.hidden) 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;
|
||||
FlowEditor._dirty = false;
|
||||
},
|
||||
};
|
||||
|
||||
window.FlowEditor = FlowEditor;
|
||||
@@ -7,7 +7,17 @@ const HistoryUI = {
|
||||
_currentType: '',
|
||||
_currentStatus: '',
|
||||
|
||||
_exportListenerAdded: false,
|
||||
|
||||
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 };
|
||||
if (HistoryUI._currentType) params.type = HistoryUI._currentType;
|
||||
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>
|
||||
</div>
|
||||
`;
|
||||
if (window.lucide) lucide.createIcons({ nodes: [container] });
|
||||
Utils.refreshIcons(container);
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = HistoryUI.executions.map((exec) => HistoryUI._renderCard(exec)).join('');
|
||||
if (window.lucide) lucide.createIcons({ nodes: [container] });
|
||||
Utils.refreshIcons(container);
|
||||
},
|
||||
|
||||
_renderCard(exec) {
|
||||
@@ -55,9 +65,10 @@ const HistoryUI = {
|
||||
const name = exec.type === 'pipeline'
|
||||
? (exec.pipelineName || 'Pipeline')
|
||||
: (exec.agentName || 'Agente');
|
||||
const task = exec.type === 'pipeline'
|
||||
const taskRaw = exec.type === 'pipeline'
|
||||
? (exec.input || '')
|
||||
: (exec.task || '');
|
||||
const task = taskRaw.length > 150 ? taskRaw.slice(0, 150) + '…' : taskRaw;
|
||||
const date = HistoryUI._formatDate(exec.startedAt);
|
||||
const duration = HistoryUI._formatDuration(exec.startedAt, exec.endedAt);
|
||||
const cost = exec.costUsd || exec.totalCostUsd || 0;
|
||||
@@ -70,28 +81,36 @@ const HistoryUI = {
|
||||
<div class="history-card-header">
|
||||
<div class="history-card-identity">
|
||||
${typeBadge}
|
||||
<span class="history-card-name">${HistoryUI._escapeHtml(name)}</span>
|
||||
</div>
|
||||
<div class="history-card-status">
|
||||
<span class="history-card-name">${Utils.escapeHtml(name)}</span>
|
||||
${statusBadge}
|
||||
<span class="history-card-date">${date}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="history-card-meta">
|
||||
<span class="history-card-task">${HistoryUI._escapeHtml(task)}</span>
|
||||
<span class="history-card-duration-group">
|
||||
<div class="history-card-task" title="${Utils.escapeHtml(taskRaw)}">${Utils.escapeHtml(task)}</div>
|
||||
<div class="history-card-info">
|
||||
<span class="history-card-date">
|
||||
<i data-lucide="calendar" aria-hidden="true"></i>
|
||||
${date}
|
||||
</span>
|
||||
<span class="history-card-duration">
|
||||
<i data-lucide="clock" aria-hidden="true"></i>
|
||||
${duration}
|
||||
</span>
|
||||
${costHtml}
|
||||
</span>
|
||||
</div>
|
||||
<div class="history-card-actions">
|
||||
<button class="btn btn-ghost btn-sm" data-action="view-execution" data-id="${exec.id}" type="button">
|
||||
<i data-lucide="eye"></i>
|
||||
Ver detalhes
|
||||
</button>
|
||||
${(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">
|
||||
<i data-lucide="trash-2"></i>
|
||||
</button>
|
||||
@@ -132,7 +151,7 @@ const HistoryUI = {
|
||||
</div>
|
||||
`;
|
||||
|
||||
if (window.lucide) lucide.createIcons({ nodes: [container] });
|
||||
Utils.refreshIcons(container);
|
||||
|
||||
document.getElementById('history-prev-btn')?.addEventListener('click', () => {
|
||||
HistoryUI.page--;
|
||||
@@ -172,7 +191,11 @@ const HistoryUI = {
|
||||
: HistoryUI._renderAgentDetail(exec);
|
||||
|
||||
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) => {
|
||||
btn.addEventListener('click', () => {
|
||||
@@ -195,18 +218,24 @@ const HistoryUI = {
|
||||
const endDate = exec.endedAt ? HistoryUI._formatDate(exec.endedAt) : '—';
|
||||
|
||||
const resultBlock = exec.result
|
||||
? `<div class="execution-result" role="region" aria-label="Resultado da execução">${HistoryUI._escapeHtml(exec.result)}</div>`
|
||||
? `<div class="execution-result" role="region" aria-label="Resultado da execução">${Utils.escapeHtml(exec.result)}</div>`
|
||||
: '';
|
||||
|
||||
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 `
|
||||
${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-row">
|
||||
<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 class="execution-detail-row">
|
||||
<span class="execution-detail-label">Status</span>
|
||||
@@ -243,7 +272,7 @@ const HistoryUI = {
|
||||
${exec.task ? `
|
||||
<div class="execution-detail-section">
|
||||
<h3 class="execution-detail-section-title">Tarefa</h3>
|
||||
<p class="execution-detail-task">${HistoryUI._escapeHtml(exec.task)}</p>
|
||||
<p class="execution-detail-task">${Utils.escapeHtml(exec.task)}</p>
|
||||
</div>` : ''}
|
||||
${resultBlock ? `
|
||||
<div class="execution-detail-section">
|
||||
@@ -279,7 +308,7 @@ const HistoryUI = {
|
||||
<div class="pipeline-step-detail">
|
||||
<div class="pipeline-step-header">
|
||||
<div class="pipeline-step-identity">
|
||||
<span class="pipeline-step-agent">${HistoryUI._escapeHtml(step.agentName || step.agentId || 'Agente')}</span>
|
||||
<span class="pipeline-step-agent">${Utils.escapeHtml(step.agentName || step.agentId || 'Agente')}</span>
|
||||
${HistoryUI._statusBadge(step.status)}
|
||||
</div>
|
||||
<span class="pipeline-step-meta-group">
|
||||
@@ -297,13 +326,13 @@ const HistoryUI = {
|
||||
Prompt utilizado
|
||||
</button>
|
||||
<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>` : ''}
|
||||
${step.result ? `
|
||||
<div class="pipeline-step-result">
|
||||
<span class="pipeline-step-result-label">Resultado</span>
|
||||
<div class="execution-result">${HistoryUI._escapeHtml(step.result)}</div>
|
||||
<div class="execution-result">${Utils.escapeHtml(step.result)}</div>
|
||||
</div>` : ''}
|
||||
${step.status === 'error' ? `
|
||||
<div class="execution-result execution-result--error">Passo falhou.</div>` : ''}
|
||||
@@ -312,11 +341,18 @@ const HistoryUI = {
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
const hasResults = steps.some(s => s.result);
|
||||
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-row">
|
||||
<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 class="execution-detail-row">
|
||||
<span class="execution-detail-label">Status</span>
|
||||
@@ -343,7 +379,7 @@ const HistoryUI = {
|
||||
${exec.input ? `
|
||||
<div class="execution-detail-section">
|
||||
<h3 class="execution-detail-section-title">Input Inicial</h3>
|
||||
<p class="execution-detail-task">${HistoryUI._escapeHtml(exec.input)}</p>
|
||||
<p class="execution-detail-task">${Utils.escapeHtml(exec.input)}</p>
|
||||
</div>` : ''}
|
||||
${steps.length > 0 ? `
|
||||
<div class="execution-detail-section">
|
||||
@@ -355,11 +391,61 @@ const HistoryUI = {
|
||||
${exec.error ? `
|
||||
<div class="execution-detail-section">
|
||||
<h3 class="execution-detail-section-title">Erro</h3>
|
||||
<div class="execution-result execution-result--error">${HistoryUI._escapeHtml(exec.error)}</div>
|
||||
<div class="execution-result execution-result--error">${Utils.escapeHtml(exec.error)}</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) {
|
||||
const confirmed = await Modal.confirm(
|
||||
'Excluir execução',
|
||||
@@ -435,15 +521,6 @@ const HistoryUI = {
|
||||
});
|
||||
},
|
||||
|
||||
_escapeHtml(str) {
|
||||
if (!str) return '';
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
},
|
||||
};
|
||||
|
||||
window.HistoryUI = HistoryUI;
|
||||
|
||||
309
public/js/components/import.js
Normal file
309
public/js/components/import.js
Normal file
@@ -0,0 +1,309 @@
|
||||
const ImportUI = {
|
||||
_selectedFiles: [],
|
||||
_selectedPaths: [],
|
||||
_folderName: '',
|
||||
_importing: false,
|
||||
|
||||
_excludedDirs: ['.git', 'node_modules', '__pycache__', '.next', '.nuxt', 'venv', '.venv', '.cache', '.parcel-cache', 'dist', 'build', '.output', '.svelte-kit', 'vendor', 'target', '.gradle', '.idea', '.vs', 'coverage', '.nyc_output'],
|
||||
_excludedFiles: ['.git', '.DS_Store', 'Thumbs.db', 'desktop.ini', '*.pyc', '*.pyo', '*.class', '*.o', '*.so', '*.dll'],
|
||||
|
||||
async load() {
|
||||
const container = document.getElementById('import-container');
|
||||
if (!container) return;
|
||||
|
||||
let repos = [];
|
||||
try { repos = await API.repos.list(); } catch {}
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="import-layout">
|
||||
<div class="card import-card">
|
||||
<div class="card-header">
|
||||
<h2 class="card-title"><i data-lucide="upload-cloud" style="width:20px;height:20px"></i> Importar Projeto</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="import-desc">Selecione uma pasta do seu computador para enviar ao Gitea. Arquivos ignorados pelo <code>.gitignore</code> e pastas como <code>node_modules</code> serão filtrados automaticamente.</p>
|
||||
|
||||
<input type="file" id="import-folder-input" webkitdirectory directory multiple hidden />
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Pasta do projeto</label>
|
||||
<div class="import-path-row">
|
||||
<div class="import-folder-display" id="import-folder-display">
|
||||
<i data-lucide="folder-open" style="width:18px;height:18px;color:var(--text-muted)"></i>
|
||||
<span class="text-muted">Nenhuma pasta selecionada</span>
|
||||
</div>
|
||||
<button class="btn btn--primary btn--sm" id="import-select-btn" type="button">
|
||||
<i data-lucide="folder-search" style="width:16px;height:16px"></i> Selecionar Pasta
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="import-preview" class="import-preview" hidden></div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Nome do repositório no Gitea</label>
|
||||
<input type="text" class="form-input" id="import-repo-name" placeholder="meu-projeto" />
|
||||
<span class="form-hint">Letras minúsculas, números e hífens</span>
|
||||
</div>
|
||||
|
||||
<button class="btn btn--primary" id="import-submit-btn" type="button" disabled>
|
||||
<i data-lucide="upload-cloud" style="width:16px;height:16px"></i> Importar para o Gitea
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2 class="card-title"><i data-lucide="git-branch" style="width:20px;height:20px"></i> Repositórios no Gitea</h2>
|
||||
<span class="badge badge--accent">${repos.length}</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
${repos.length === 0 ? '<p class="text-muted">Nenhum repositório encontrado</p>' : ''}
|
||||
<div class="import-repos-grid">
|
||||
${repos.map(r => ImportUI._renderRepoCard(r)).join('')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
Utils.refreshIcons(container);
|
||||
ImportUI._bindEvents();
|
||||
},
|
||||
|
||||
_renderRepoCard(repo) {
|
||||
const domain = 'nitro-cloud.duckdns.org';
|
||||
const repoUrl = `https://git.${domain}/${repo.full_name || repo.name}`;
|
||||
const updated = repo.updated_at ? new Date(repo.updated_at).toLocaleDateString('pt-BR') : '';
|
||||
const size = repo.size ? ImportUI._fmtSize(repo.size * 1024) : '';
|
||||
|
||||
return `
|
||||
<div class="import-repo-card">
|
||||
<div class="import-repo-header">
|
||||
<i data-lucide="git-branch" style="width:16px;height:16px;color:var(--accent)"></i>
|
||||
<a href="${Utils.escapeHtml(repoUrl)}" target="_blank" class="import-repo-name">${Utils.escapeHtml(repo.name)}</a>
|
||||
</div>
|
||||
${repo.description ? `<p class="import-repo-desc">${Utils.escapeHtml(repo.description)}</p>` : ''}
|
||||
<div class="import-repo-meta">
|
||||
${updated ? `<span><i data-lucide="calendar" style="width:12px;height:12px"></i> ${updated}</span>` : ''}
|
||||
${size ? `<span><i data-lucide="hard-drive" style="width:12px;height:12px"></i> ${size}</span>` : ''}
|
||||
${repo.default_branch ? `<span><i data-lucide="git-commit" style="width:12px;height:12px"></i> ${Utils.escapeHtml(repo.default_branch)}</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
},
|
||||
|
||||
_fmtSize(bytes) {
|
||||
if (!bytes) return '';
|
||||
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];
|
||||
},
|
||||
|
||||
_bindEvents() {
|
||||
const selectBtn = document.getElementById('import-select-btn');
|
||||
const folderInput = document.getElementById('import-folder-input');
|
||||
const submitBtn = document.getElementById('import-submit-btn');
|
||||
|
||||
if (selectBtn && folderInput) {
|
||||
selectBtn.addEventListener('click', () => folderInput.click());
|
||||
folderInput.addEventListener('change', () => ImportUI._onFolderSelected(folderInput.files));
|
||||
}
|
||||
|
||||
if (submitBtn) {
|
||||
submitBtn.addEventListener('click', () => ImportUI._doUpload());
|
||||
}
|
||||
},
|
||||
|
||||
_shouldExclude(relativePath) {
|
||||
const parts = relativePath.split('/');
|
||||
for (const part of parts.slice(0, -1)) {
|
||||
if (ImportUI._excludedDirs.includes(part)) return true;
|
||||
}
|
||||
const fileName = parts[parts.length - 1];
|
||||
for (const pattern of ImportUI._excludedFiles) {
|
||||
if (pattern.startsWith('*.')) {
|
||||
if (fileName.endsWith(pattern.slice(1))) return true;
|
||||
} else {
|
||||
if (fileName === pattern) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
_parseGitignore(content) {
|
||||
const patterns = [];
|
||||
for (const raw of content.split('\n')) {
|
||||
const line = raw.trim();
|
||||
if (!line || line.startsWith('#') || line.startsWith('!')) continue;
|
||||
patterns.push(line.replace(/\/$/, ''));
|
||||
}
|
||||
return patterns;
|
||||
},
|
||||
|
||||
_matchesGitignore(relativePath, patterns) {
|
||||
const parts = relativePath.split('/');
|
||||
for (const pattern of patterns) {
|
||||
if (pattern.includes('/')) {
|
||||
if (relativePath.startsWith(pattern + '/') || relativePath === pattern) return true;
|
||||
} else if (pattern.startsWith('*.')) {
|
||||
const ext = pattern.slice(1);
|
||||
if (relativePath.endsWith(ext)) return true;
|
||||
} else {
|
||||
for (const part of parts) {
|
||||
if (part === pattern) return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
_onFolderSelected(fileList) {
|
||||
if (!fileList || fileList.length === 0) return;
|
||||
|
||||
const allFiles = Array.from(fileList);
|
||||
const firstPath = allFiles[0].webkitRelativePath || '';
|
||||
ImportUI._folderName = firstPath.split('/')[0] || 'projeto';
|
||||
|
||||
let gitignorePatterns = [];
|
||||
const gitignoreFile = allFiles.find(f => {
|
||||
const rel = f.webkitRelativePath || '';
|
||||
const parts = rel.split('/');
|
||||
return parts.length === 2 && parts[1] === '.gitignore';
|
||||
});
|
||||
|
||||
if (gitignoreFile) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
gitignorePatterns = ImportUI._parseGitignore(e.target.result);
|
||||
ImportUI._applyFilter(allFiles, gitignorePatterns);
|
||||
};
|
||||
reader.readAsText(gitignoreFile);
|
||||
} else {
|
||||
ImportUI._applyFilter(allFiles, []);
|
||||
}
|
||||
},
|
||||
|
||||
_applyFilter(allFiles, gitignorePatterns) {
|
||||
const filtered = [];
|
||||
const paths = [];
|
||||
let totalSize = 0;
|
||||
let excluded = 0;
|
||||
|
||||
for (const file of allFiles) {
|
||||
const fullRel = file.webkitRelativePath || file.name;
|
||||
const relWithoutRoot = fullRel.split('/').slice(1).join('/');
|
||||
if (!relWithoutRoot) continue;
|
||||
|
||||
if (ImportUI._shouldExclude(relWithoutRoot)) { excluded++; continue; }
|
||||
if (gitignorePatterns.length > 0 && ImportUI._matchesGitignore(relWithoutRoot, gitignorePatterns)) { excluded++; continue; }
|
||||
|
||||
filtered.push(file);
|
||||
paths.push(fullRel);
|
||||
totalSize += file.size;
|
||||
}
|
||||
|
||||
ImportUI._selectedFiles = filtered;
|
||||
ImportUI._selectedPaths = paths;
|
||||
|
||||
const display = document.getElementById('import-folder-display');
|
||||
if (display) {
|
||||
display.innerHTML = `
|
||||
<i data-lucide="folder" style="width:18px;height:18px;color:var(--warning)"></i>
|
||||
<strong>${Utils.escapeHtml(ImportUI._folderName)}</strong>
|
||||
`;
|
||||
Utils.refreshIcons(display);
|
||||
}
|
||||
|
||||
const preview = document.getElementById('import-preview');
|
||||
if (preview) {
|
||||
preview.hidden = false;
|
||||
preview.innerHTML = `
|
||||
<div class="import-preview-stats">
|
||||
<div class="import-stat">
|
||||
<i data-lucide="file" style="width:16px;height:16px"></i>
|
||||
<span><strong>${filtered.length}</strong> arquivos selecionados</span>
|
||||
</div>
|
||||
<div class="import-stat">
|
||||
<i data-lucide="hard-drive" style="width:16px;height:16px"></i>
|
||||
<span><strong>${ImportUI._fmtSize(totalSize)}</strong> total</span>
|
||||
</div>
|
||||
${excluded > 0 ? `<div class="import-stat import-stat--muted">
|
||||
<i data-lucide="eye-off" style="width:16px;height:16px"></i>
|
||||
<span>${excluded} arquivos ignorados (.gitignore / node_modules / etc.)</span>
|
||||
</div>` : ''}
|
||||
</div>
|
||||
`;
|
||||
Utils.refreshIcons(preview);
|
||||
}
|
||||
|
||||
const nameInput = document.getElementById('import-repo-name');
|
||||
if (nameInput && !nameInput.value.trim()) {
|
||||
nameInput.value = ImportUI._folderName.toLowerCase().replace(/[^a-z0-9-]/g, '-');
|
||||
}
|
||||
|
||||
const submitBtn = document.getElementById('import-submit-btn');
|
||||
if (submitBtn) submitBtn.disabled = filtered.length === 0;
|
||||
},
|
||||
|
||||
async _doUpload() {
|
||||
if (ImportUI._importing) return;
|
||||
if (ImportUI._selectedFiles.length === 0) { Toast.warning('Selecione uma pasta primeiro'); return; }
|
||||
|
||||
const nameInput = document.getElementById('import-repo-name');
|
||||
const submitBtn = document.getElementById('import-submit-btn');
|
||||
const repoName = (nameInput?.value || '').trim().toLowerCase().replace(/[^a-z0-9-]/g, '-');
|
||||
if (!repoName) { Toast.warning('Informe o nome do repositório'); return; }
|
||||
|
||||
ImportUI._importing = true;
|
||||
if (submitBtn) {
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.innerHTML = '<i data-lucide="loader" style="width:16px;height:16px" class="spin"></i> Enviando...';
|
||||
Utils.refreshIcons(submitBtn);
|
||||
}
|
||||
|
||||
try {
|
||||
Toast.info(`Enviando ${ImportUI._selectedFiles.length} arquivos...`);
|
||||
const result = await API.projects.upload(ImportUI._selectedFiles, ImportUI._selectedPaths, repoName);
|
||||
|
||||
Toast.success('Projeto importado 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 Importado';
|
||||
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>Diretório:</strong> <code>${Utils.escapeHtml(result.projectDir)}</code></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 class="publish-result-steps">
|
||||
<strong>Passos:</strong>
|
||||
<ul>${(result.steps || []).map(s => `<li>${Utils.escapeHtml(s)}</li>`).join('')}</ul>
|
||||
</div>
|
||||
</div>`;
|
||||
Modal.open('execution-detail-modal-overlay');
|
||||
}
|
||||
|
||||
ImportUI._selectedFiles = [];
|
||||
ImportUI._selectedPaths = [];
|
||||
ImportUI._folderName = '';
|
||||
if (nameInput) nameInput.value = '';
|
||||
App._reposCache = null;
|
||||
await ImportUI.load();
|
||||
} catch (err) {
|
||||
Toast.error(`Erro ao importar: ${err.message}`);
|
||||
} finally {
|
||||
ImportUI._importing = false;
|
||||
if (submitBtn) {
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.innerHTML = '<i data-lucide="upload-cloud" style="width:16px;height:16px"></i> Importar para o Gitea';
|
||||
Utils.refreshIcons(submitBtn);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
window.ImportUI = ImportUI;
|
||||
@@ -58,6 +58,33 @@ const Modal = {
|
||||
}
|
||||
},
|
||||
|
||||
_promptResolve: null,
|
||||
|
||||
prompt(title, message, defaultValue = '') {
|
||||
return new Promise((resolve) => {
|
||||
Modal._promptResolve = resolve;
|
||||
|
||||
const titleEl = document.getElementById('prompt-modal-title');
|
||||
const messageEl = document.getElementById('prompt-modal-message');
|
||||
const inputEl = document.getElementById('prompt-modal-input');
|
||||
|
||||
if (titleEl) titleEl.textContent = title;
|
||||
if (messageEl) messageEl.innerHTML = message;
|
||||
if (inputEl) inputEl.value = defaultValue;
|
||||
|
||||
Modal.open('prompt-modal-overlay');
|
||||
});
|
||||
},
|
||||
|
||||
_resolvePrompt(result) {
|
||||
const inputEl = document.getElementById('prompt-modal-input');
|
||||
Modal.close('prompt-modal-overlay');
|
||||
if (Modal._promptResolve) {
|
||||
Modal._promptResolve(result ? (inputEl?.value || '') : null);
|
||||
Modal._promptResolve = null;
|
||||
}
|
||||
},
|
||||
|
||||
_setupListeners() {
|
||||
document.addEventListener('click', (e) => {
|
||||
if (e.target.classList.contains('modal-overlay')) {
|
||||
@@ -65,6 +92,8 @@ const Modal = {
|
||||
|
||||
if (modalId === 'confirm-modal-overlay') {
|
||||
Modal._resolveConfirm(false);
|
||||
} else if (modalId === 'prompt-modal-overlay') {
|
||||
Modal._resolvePrompt(false);
|
||||
} else {
|
||||
Modal.close(modalId);
|
||||
}
|
||||
@@ -77,6 +106,8 @@ const Modal = {
|
||||
|
||||
if (targetId === 'confirm-modal-overlay') {
|
||||
Modal._resolveConfirm(false);
|
||||
} else if (targetId === 'prompt-modal-overlay') {
|
||||
Modal._resolvePrompt(false);
|
||||
} else {
|
||||
Modal.close(targetId);
|
||||
}
|
||||
@@ -91,6 +122,8 @@ const Modal = {
|
||||
|
||||
if (activeModal.id === 'confirm-modal-overlay') {
|
||||
Modal._resolveConfirm(false);
|
||||
} else if (activeModal.id === 'prompt-modal-overlay') {
|
||||
Modal._resolvePrompt(false);
|
||||
} else {
|
||||
Modal.close(activeModal.id);
|
||||
}
|
||||
@@ -98,6 +131,17 @@ const Modal = {
|
||||
|
||||
const confirmBtn = document.getElementById('confirm-modal-confirm-btn');
|
||||
if (confirmBtn) confirmBtn.addEventListener('click', () => Modal._resolveConfirm(true));
|
||||
|
||||
const promptConfirmBtn = document.getElementById('prompt-modal-confirm-btn');
|
||||
if (promptConfirmBtn) promptConfirmBtn.addEventListener('click', () => Modal._resolvePrompt(true));
|
||||
|
||||
const promptCancelBtn = document.getElementById('prompt-modal-cancel-btn');
|
||||
if (promptCancelBtn) promptCancelBtn.addEventListener('click', () => Modal._resolvePrompt(false));
|
||||
|
||||
const promptInput = document.getElementById('prompt-modal-input');
|
||||
if (promptInput) promptInput.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') Modal._resolvePrompt(true);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
153
public/js/components/notifications.js
Normal file
153
public/js/components/notifications.js
Normal 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;
|
||||
@@ -44,7 +44,7 @@ const PipelinesUI = {
|
||||
if (!emptyState) {
|
||||
grid.insertAdjacentHTML('beforeend', PipelinesUI.renderEmpty());
|
||||
}
|
||||
if (window.lucide) lucide.createIcons({ nodes: [grid] });
|
||||
Utils.refreshIcons(grid);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ const PipelinesUI = {
|
||||
|
||||
grid.appendChild(fragment);
|
||||
|
||||
if (window.lucide) lucide.createIcons({ nodes: [grid] });
|
||||
Utils.refreshIcons(grid);
|
||||
},
|
||||
|
||||
renderEmpty() {
|
||||
@@ -83,8 +83,7 @@ const PipelinesUI = {
|
||||
const stepCount = steps.length;
|
||||
|
||||
const flowHtml = steps.map((step, index) => {
|
||||
const agentName = step.agentName || step.agentId || 'Agente';
|
||||
const isLast = index === steps.length - 1;
|
||||
const agentName = Utils.escapeHtml(step.agentName || step.agentId || 'Agente');
|
||||
const approvalIcon = step.requiresApproval && index > 0
|
||||
? '<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>
|
||||
${approvalIcon}${agentName}
|
||||
</span>
|
||||
${!isLast ? '<span class="pipeline-flow-arrow">→</span>' : ''}
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
@@ -102,12 +100,14 @@ const PipelinesUI = {
|
||||
<div class="agent-card-body">
|
||||
<div class="agent-card-top">
|
||||
<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>
|
||||
</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">
|
||||
${flowHtml || '<span class="agent-description">Nenhum passo configurado</span>'}
|
||||
@@ -119,23 +119,29 @@ const PipelinesUI = {
|
||||
<i data-lucide="play"></i>
|
||||
Executar
|
||||
</button>
|
||||
<button class="btn btn-ghost btn-sm" data-action="edit-pipeline" data-id="${pipeline.id}">
|
||||
<div class="agent-actions-icons">
|
||||
<button class="btn btn-ghost btn-icon btn-sm" data-action="flow-pipeline" data-id="${pipeline.id}" title="Editor de fluxo">
|
||||
<i data-lucide="workflow"></i>
|
||||
</button>
|
||||
<button class="btn btn-ghost btn-icon btn-sm" data-action="edit-pipeline" data-id="${pipeline.id}" title="Editar pipeline">
|
||||
<i data-lucide="pencil"></i>
|
||||
Editar
|
||||
</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>
|
||||
`;
|
||||
},
|
||||
|
||||
_basePath: '/home/projetos/',
|
||||
|
||||
openCreateModal() {
|
||||
PipelinesUI._editingId = null;
|
||||
PipelinesUI._steps = [
|
||||
{ agentId: '', inputTemplate: '', requiresApproval: false },
|
||||
{ agentId: '', inputTemplate: '', requiresApproval: false },
|
||||
{ agentId: '', inputTemplate: '', description: '', promptMode: 'simple', requiresApproval: false },
|
||||
{ agentId: '', inputTemplate: '', description: '', promptMode: 'simple', requiresApproval: false },
|
||||
];
|
||||
|
||||
const titleEl = document.getElementById('pipeline-modal-title');
|
||||
@@ -150,6 +156,9 @@ const PipelinesUI = {
|
||||
const descEl = document.getElementById('pipeline-description');
|
||||
if (descEl) descEl.value = '';
|
||||
|
||||
const workdirEl = document.getElementById('pipeline-workdir');
|
||||
if (workdirEl) workdirEl.value = PipelinesUI._basePath;
|
||||
|
||||
PipelinesUI.renderSteps();
|
||||
Modal.open('pipeline-modal-overlay');
|
||||
},
|
||||
@@ -160,7 +169,13 @@ const PipelinesUI = {
|
||||
|
||||
PipelinesUI._editingId = pipelineId;
|
||||
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');
|
||||
@@ -175,6 +190,9 @@ const PipelinesUI = {
|
||||
const descEl = document.getElementById('pipeline-description');
|
||||
if (descEl) descEl.value = pipeline.description || '';
|
||||
|
||||
const workdirEl = document.getElementById('pipeline-workdir');
|
||||
if (workdirEl) workdirEl.value = pipeline.workingDirectory || PipelinesUI._basePath;
|
||||
|
||||
PipelinesUI.renderSteps();
|
||||
Modal.open('pipeline-modal-overlay');
|
||||
} catch (err) {
|
||||
@@ -192,7 +210,7 @@ const PipelinesUI = {
|
||||
}
|
||||
|
||||
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('');
|
||||
|
||||
container.innerHTML = PipelinesUI._steps.map((step, index) => {
|
||||
@@ -211,6 +229,46 @@ const PipelinesUI = {
|
||||
</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><tags></code> XML para organizar melhor. Ex: <code><contexto></code> <code><regras></code> <code><formato_saida></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><tags></code> XML para organizar. Ex: <code><contexto>{{input}}</contexto></code> <code><regras></code> <code><formato_saida></code>
|
||||
</span>
|
||||
</div>`;
|
||||
|
||||
const modeIcon = isSimple ? 'code' : 'text';
|
||||
const modeLabel = isSimple ? 'Avançado' : 'Simples';
|
||||
|
||||
return `
|
||||
<div class="pipeline-step-row" data-step-index="${index}">
|
||||
<span class="pipeline-step-number-lg">${index + 1}</span>
|
||||
@@ -219,14 +277,14 @@ const PipelinesUI = {
|
||||
<option value="">Selecionar agente...</option>
|
||||
${agentOptions}
|
||||
</select>
|
||||
<textarea
|
||||
class="textarea"
|
||||
rows="2"
|
||||
placeholder="{{input}} será substituído pelo output anterior"
|
||||
data-step-field="inputTemplate"
|
||||
data-step-index="${index}"
|
||||
>${step.inputTemplate || ''}</textarea>
|
||||
${promptHtml}
|
||||
<div class="pipeline-step-footer">
|
||||
${approvalHtml}
|
||||
<button type="button" class="pipeline-mode-toggle" data-step-action="toggle-mode" data-step-index="${index}" title="Alternar entre modo simples e avançado">
|
||||
<i data-lucide="${modeIcon}" style="width:12px;height:12px"></i>
|
||||
${modeLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<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' : ''}>
|
||||
@@ -249,7 +307,7 @@ const PipelinesUI = {
|
||||
select.value = PipelinesUI._steps[index].agentId || '';
|
||||
});
|
||||
|
||||
if (window.lucide) lucide.createIcons({ nodes: [container] });
|
||||
Utils.refreshIcons(container);
|
||||
},
|
||||
|
||||
_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() {
|
||||
PipelinesUI._syncStepsFromDOM();
|
||||
PipelinesUI._steps.push({ agentId: '', inputTemplate: '', requiresApproval: false });
|
||||
PipelinesUI._steps.push({ agentId: '', inputTemplate: '', description: '', promptMode: 'simple', requiresApproval: false });
|
||||
PipelinesUI.renderSteps();
|
||||
},
|
||||
|
||||
@@ -311,14 +401,29 @@ const PipelinesUI = {
|
||||
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 = {
|
||||
name,
|
||||
description: document.getElementById('pipeline-description')?.value.trim() || '',
|
||||
steps: PipelinesUI._steps.map((s) => ({
|
||||
workingDirectory,
|
||||
steps: PipelinesUI._steps.map((s, index) => {
|
||||
const isSimple = s.promptMode !== 'advanced';
|
||||
const inputTemplate = isSimple
|
||||
? PipelinesUI._generateTemplate(s.description, index)
|
||||
: (s.inputTemplate || '');
|
||||
|
||||
return {
|
||||
agentId: s.agentId,
|
||||
inputTemplate: s.inputTemplate || '',
|
||||
inputTemplate,
|
||||
description: isSimple ? (s.description || '') : '',
|
||||
requiresApproval: !!s.requiresApproval,
|
||||
})),
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -367,7 +472,14 @@ const PipelinesUI = {
|
||||
if (inputEl) inputEl.value = '';
|
||||
|
||||
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');
|
||||
},
|
||||
@@ -382,8 +494,24 @@ const PipelinesUI = {
|
||||
return;
|
||||
}
|
||||
|
||||
if (workingDirectory && !workingDirectory.startsWith('/')) {
|
||||
Toast.warning('O diretório de trabalho deve ser um caminho absoluto (começar com /)');
|
||||
return;
|
||||
}
|
||||
|
||||
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');
|
||||
App.navigateTo('terminal');
|
||||
Toast.info('Pipeline iniciado');
|
||||
|
||||
@@ -28,7 +28,7 @@ const SchedulesUI = {
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
if (window.lucide) lucide.createIcons({ nodes: [tbody] });
|
||||
Utils.refreshIcons(tbody);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -44,8 +44,8 @@ const SchedulesUI = {
|
||||
|
||||
return `
|
||||
<tr>
|
||||
<td>${schedule.agentName || '—'}</td>
|
||||
<td class="schedule-task-cell" title="${schedule.taskDescription || ''}">${schedule.taskDescription || '—'}</td>
|
||||
<td>${Utils.escapeHtml(schedule.agentName || '—')}</td>
|
||||
<td class="schedule-task-cell" title="${Utils.escapeHtml(schedule.taskDescription || '')}">${Utils.escapeHtml(schedule.taskDescription || '—')}</td>
|
||||
<td>
|
||||
<code class="font-mono">${cronExpr}</code>
|
||||
</td>
|
||||
@@ -77,7 +77,7 @@ const SchedulesUI = {
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
if (window.lucide) lucide.createIcons({ nodes: [tbody] });
|
||||
Utils.refreshIcons(tbody);
|
||||
},
|
||||
|
||||
filter(searchText, statusFilter) {
|
||||
@@ -106,7 +106,7 @@ const SchedulesUI = {
|
||||
select.innerHTML = '<option value="">Selecionar agente...</option>' +
|
||||
agents
|
||||
.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('');
|
||||
}
|
||||
|
||||
@@ -208,7 +208,11 @@ const SchedulesUI = {
|
||||
if (!container) return;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -233,12 +237,12 @@ const SchedulesUI = {
|
||||
const duration = SchedulesUI._formatDuration(exec.startedAt, exec.endedAt);
|
||||
const cost = exec.costUsd || exec.totalCostUsd || 0;
|
||||
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 `
|
||||
<tr>
|
||||
<td>${SchedulesUI._escapeHtml(exec.agentName || '—')}</td>
|
||||
<td title="${SchedulesUI._escapeHtml(exec.task || '')}">${taskStr}</td>
|
||||
<td>${Utils.escapeHtml(exec.agentName || '—')}</td>
|
||||
<td title="${Utils.escapeHtml(exec.task || '')}">${taskStr}</td>
|
||||
<td>${status}</td>
|
||||
<td>${date}</td>
|
||||
<td>${duration}</td>
|
||||
@@ -256,7 +260,7 @@ const SchedulesUI = {
|
||||
</div>
|
||||
`;
|
||||
|
||||
if (window.lucide) lucide.createIcons({ nodes: [container] });
|
||||
Utils.refreshIcons(container);
|
||||
},
|
||||
|
||||
_statusBadge(status) {
|
||||
@@ -283,16 +287,6 @@ const SchedulesUI = {
|
||||
return `${minutes}m ${seconds}s`;
|
||||
},
|
||||
|
||||
_escapeHtml(str) {
|
||||
if (!str) return '';
|
||||
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
},
|
||||
|
||||
_truncate(str, max) {
|
||||
if (!str || str.length <= max) return str;
|
||||
return str.slice(0, max) + '…';
|
||||
},
|
||||
|
||||
cronToHuman(expression) {
|
||||
if (!expression) return '—';
|
||||
|
||||
|
||||
@@ -8,11 +8,20 @@ const SettingsUI = {
|
||||
|
||||
SettingsUI.populateForm(settings);
|
||||
SettingsUI.populateSystemInfo(info);
|
||||
SettingsUI.updateThemeInfo();
|
||||
} catch (err) {
|
||||
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) {
|
||||
const fields = {
|
||||
'settings-default-model': settings.defaultModel || 'claude-sonnet-4-6',
|
||||
|
||||
@@ -39,7 +39,7 @@ const TasksUI = {
|
||||
|
||||
container.appendChild(fragment);
|
||||
|
||||
if (window.lucide) lucide.createIcons({ nodes: [container] });
|
||||
Utils.refreshIcons(container);
|
||||
},
|
||||
|
||||
filter(searchText, categoryFilter) {
|
||||
@@ -65,10 +65,10 @@ const TasksUI = {
|
||||
return `
|
||||
<div class="task-card" data-task-id="${task.id}">
|
||||
<div class="task-card-header">
|
||||
<h4 class="task-card-name">${task.name}</h4>
|
||||
<span class="badge ${categoryClass}">${categoryLabel}</span>
|
||||
<h4 class="task-card-name">${Utils.escapeHtml(task.name)}</h4>
|
||||
<span class="badge ${categoryClass}">${Utils.escapeHtml(categoryLabel)}</span>
|
||||
</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">
|
||||
<span class="task-card-date">
|
||||
<i data-lucide="calendar"></i>
|
||||
@@ -117,7 +117,7 @@ const TasksUI = {
|
||||
<div class="task-card task-card--form" id="task-inline-form">
|
||||
<div class="form-group">
|
||||
<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 class="form-group">
|
||||
<label class="form-label" for="task-inline-category">Categoria</label>
|
||||
@@ -133,7 +133,7 @@ const TasksUI = {
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<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 class="form-actions">
|
||||
<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');
|
||||
if (selectEl) {
|
||||
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 = '';
|
||||
}
|
||||
|
||||
|
||||
@@ -5,9 +5,82 @@ const Terminal = {
|
||||
executionFilter: null,
|
||||
_processingInterval: null,
|
||||
_chatSession: null,
|
||||
searchMatches: [],
|
||||
searchIndex: -1,
|
||||
_toolbarInitialized: false,
|
||||
_storageKey: 'terminal_lines',
|
||||
_chatStorageKey: 'terminal_chat',
|
||||
_timerInterval: null,
|
||||
_timerStart: null,
|
||||
_timerStorageKey: 'terminal_timer_start',
|
||||
|
||||
_saveToStorage() {
|
||||
try {
|
||||
const data = JSON.stringify(Terminal.lines.slice(-Terminal.maxLines));
|
||||
sessionStorage.setItem(Terminal._storageKey, data);
|
||||
} catch {}
|
||||
},
|
||||
|
||||
_restoreFromStorage() {
|
||||
try {
|
||||
const data = sessionStorage.getItem(Terminal._storageKey);
|
||||
if (data) {
|
||||
Terminal.lines = JSON.parse(data);
|
||||
return true;
|
||||
}
|
||||
} catch {}
|
||||
return false;
|
||||
},
|
||||
|
||||
_clearStorage() {
|
||||
try {
|
||||
sessionStorage.removeItem(Terminal._storageKey);
|
||||
sessionStorage.removeItem(Terminal._chatStorageKey);
|
||||
} catch {}
|
||||
},
|
||||
|
||||
async restoreIfActive() {
|
||||
try {
|
||||
const active = await API.system.activeExecutions();
|
||||
const hasActive = Array.isArray(active) && active.length > 0;
|
||||
if (hasActive) {
|
||||
const exec = active[0];
|
||||
const serverBuffer = Array.isArray(exec.outputBuffer) ? exec.outputBuffer : [];
|
||||
|
||||
if (serverBuffer.length > 0) {
|
||||
Terminal.lines = serverBuffer.map((item) => {
|
||||
const time = new Date();
|
||||
return {
|
||||
content: item.content || '',
|
||||
type: item.type || 'default',
|
||||
timestamp: time.toTimeString().slice(0, 8),
|
||||
executionId: exec.executionId,
|
||||
};
|
||||
});
|
||||
Terminal._saveToStorage();
|
||||
} else {
|
||||
Terminal._restoreFromStorage();
|
||||
}
|
||||
|
||||
Terminal.render();
|
||||
const startedAt = exec.startedAt ? new Date(exec.startedAt).getTime() : null;
|
||||
const savedStart = sessionStorage.getItem(Terminal._timerStorageKey);
|
||||
Terminal._startTimer(savedStart ? Number(savedStart) : startedAt);
|
||||
Terminal.startProcessing(exec.agentConfig?.agent_name || 'Agente');
|
||||
try {
|
||||
const chatData = sessionStorage.getItem(Terminal._chatStorageKey);
|
||||
if (chatData) Terminal._chatSession = JSON.parse(chatData);
|
||||
} catch {}
|
||||
} else {
|
||||
Terminal._clearStorage();
|
||||
Terminal._hideTimer();
|
||||
}
|
||||
} catch {}
|
||||
},
|
||||
|
||||
enableChat(agentId, agentName, sessionId) {
|
||||
Terminal._chatSession = { agentId, agentName, sessionId };
|
||||
try { sessionStorage.setItem(Terminal._chatStorageKey, JSON.stringify(Terminal._chatSession)); } catch {}
|
||||
const bar = document.getElementById('terminal-input-bar');
|
||||
const ctx = document.getElementById('terminal-input-context');
|
||||
const input = document.getElementById('terminal-input');
|
||||
@@ -18,6 +91,7 @@ const Terminal = {
|
||||
|
||||
disableChat() {
|
||||
Terminal._chatSession = null;
|
||||
try { sessionStorage.removeItem(Terminal._chatStorageKey); } catch {}
|
||||
const bar = document.getElementById('terminal-input-bar');
|
||||
if (bar) bar.hidden = true;
|
||||
},
|
||||
@@ -40,13 +114,55 @@ const Terminal = {
|
||||
Terminal.lines.shift();
|
||||
}
|
||||
|
||||
Terminal._saveToStorage();
|
||||
Terminal.render();
|
||||
},
|
||||
|
||||
_startTimer(fromTimestamp) {
|
||||
Terminal._stopTimer();
|
||||
Terminal._timerStart = fromTimestamp || Date.now();
|
||||
try { sessionStorage.setItem(Terminal._timerStorageKey, String(Terminal._timerStart)); } catch {}
|
||||
|
||||
const timerEl = document.getElementById('terminal-timer');
|
||||
const valueEl = document.getElementById('terminal-timer-value');
|
||||
if (timerEl) timerEl.hidden = false;
|
||||
|
||||
const tick = () => {
|
||||
if (!valueEl) return;
|
||||
const elapsed = Math.floor((Date.now() - Terminal._timerStart) / 1000);
|
||||
const h = Math.floor(elapsed / 3600);
|
||||
const m = Math.floor((elapsed % 3600) / 60);
|
||||
const s = elapsed % 60;
|
||||
valueEl.textContent = h > 0
|
||||
? `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
|
||||
: `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
|
||||
};
|
||||
tick();
|
||||
Terminal._timerInterval = setInterval(tick, 1000);
|
||||
},
|
||||
|
||||
_stopTimer() {
|
||||
if (Terminal._timerInterval) {
|
||||
clearInterval(Terminal._timerInterval);
|
||||
Terminal._timerInterval = null;
|
||||
}
|
||||
try { sessionStorage.removeItem(Terminal._timerStorageKey); } catch {}
|
||||
},
|
||||
|
||||
_hideTimer() {
|
||||
Terminal._stopTimer();
|
||||
const timerEl = document.getElementById('terminal-timer');
|
||||
if (timerEl) timerEl.hidden = true;
|
||||
},
|
||||
|
||||
startProcessing(agentName) {
|
||||
Terminal.stopProcessing();
|
||||
Terminal.addLine(`Agente "${agentName}" processando tarefa...`, 'system');
|
||||
|
||||
if (!Terminal._timerInterval) {
|
||||
Terminal._startTimer();
|
||||
}
|
||||
|
||||
let dots = 0;
|
||||
Terminal._processingInterval = setInterval(() => {
|
||||
dots = (dots + 1) % 4;
|
||||
@@ -68,8 +184,10 @@ const Terminal = {
|
||||
|
||||
clear() {
|
||||
Terminal.stopProcessing();
|
||||
Terminal._hideTimer();
|
||||
Terminal.lines = [];
|
||||
Terminal.executionFilter = null;
|
||||
Terminal._clearStorage();
|
||||
Terminal.render();
|
||||
},
|
||||
|
||||
@@ -83,7 +201,121 @@ const Terminal = {
|
||||
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() {
|
||||
Terminal.initToolbar();
|
||||
const output = document.getElementById('terminal-output');
|
||||
if (!output) return;
|
||||
|
||||
@@ -102,7 +334,7 @@ const Terminal = {
|
||||
|
||||
const html = lines.map((line) => {
|
||||
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>');
|
||||
|
||||
return `<div class="terminal-line${typeClass}">
|
||||
@@ -120,14 +352,6 @@ const Terminal = {
|
||||
if (Terminal.autoScroll) Terminal.scrollToBottom();
|
||||
},
|
||||
|
||||
_escapeHtml(text) {
|
||||
return String(text)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
},
|
||||
};
|
||||
|
||||
window.Terminal = Terminal;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
const Toast = {
|
||||
iconMap: {
|
||||
success: 'check-circle',
|
||||
error: 'x-circle',
|
||||
success: 'circle-check',
|
||||
error: 'circle-x',
|
||||
info: 'info',
|
||||
warning: 'alert-triangle',
|
||||
warning: 'triangle-alert',
|
||||
},
|
||||
|
||||
colorMap: {
|
||||
@@ -35,9 +35,7 @@ const Toast = {
|
||||
|
||||
container.appendChild(toast);
|
||||
|
||||
if (window.lucide) {
|
||||
lucide.createIcons({ nodes: [toast] });
|
||||
}
|
||||
Utils.refreshIcons(toast);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
toast.classList.add('toast-show');
|
||||
|
||||
@@ -44,12 +44,12 @@ const WebhooksUI = {
|
||||
<p class="empty-state-desc">Crie webhooks para disparar agentes ou pipelines via HTTP.</p>
|
||||
</div>
|
||||
`;
|
||||
if (window.lucide) lucide.createIcons({ nodes: [container] });
|
||||
Utils.refreshIcons(container);
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = webhooks.map((w) => WebhooksUI._renderCard(w)).join('');
|
||||
if (window.lucide) lucide.createIcons({ nodes: [container] });
|
||||
Utils.refreshIcons(container);
|
||||
},
|
||||
|
||||
_renderCard(webhook) {
|
||||
@@ -71,7 +71,7 @@ const WebhooksUI = {
|
||||
<article class="webhook-card">
|
||||
<div class="webhook-card-header">
|
||||
<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}
|
||||
${statusBadge}
|
||||
</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'}">
|
||||
<i data-lucide="${webhook.active ? 'pause' : 'play'}"></i>
|
||||
</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">
|
||||
<i data-lucide="trash-2"></i>
|
||||
</button>
|
||||
@@ -87,7 +93,7 @@ const WebhooksUI = {
|
||||
<div class="webhook-card-body">
|
||||
<div class="webhook-card-target">
|
||||
<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 class="webhook-card-url">
|
||||
<span class="webhook-card-label">URL</span>
|
||||
@@ -141,19 +147,59 @@ const WebhooksUI = {
|
||||
WebhooksUI._updateTargetSelect('agent');
|
||||
}
|
||||
|
||||
const submitBtn = document.getElementById('webhook-form-submit');
|
||||
if (submitBtn) submitBtn.dataset.editId = '';
|
||||
|
||||
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) {
|
||||
const selectEl = document.getElementById('webhook-target-id');
|
||||
if (!selectEl) return;
|
||||
|
||||
if (targetType === 'agent') {
|
||||
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 {
|
||||
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 targetType = document.getElementById('webhook-target-type')?.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 (!targetId) { Toast.warning('Selecione um destino'); return; }
|
||||
|
||||
try {
|
||||
if (editId) {
|
||||
await API.webhooks.update(editId, { name, targetType, targetId });
|
||||
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();
|
||||
} 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
},
|
||||
};
|
||||
|
||||
window.WebhooksUI = WebhooksUI;
|
||||
|
||||
16688
public/js/lucide.js
Normal file
16688
public/js/lucide.js
Normal file
File diff suppressed because it is too large
Load Diff
114
public/js/utils.js
Normal file
114
public/js/utils.js
Normal file
@@ -0,0 +1,114 @@
|
||||
const Utils = {
|
||||
escapeHtml(str) {
|
||||
if (str === null || str === undefined) return '';
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
},
|
||||
|
||||
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">×</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
79
scripts/deploy.sh
Executable 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!"
|
||||
140
server.js
140
server.js
@@ -4,44 +4,123 @@ import { WebSocketServer } from 'ws';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname, join } from 'path';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import crypto from 'crypto';
|
||||
import rateLimit from 'express-rate-limit';
|
||||
import helmet from 'helmet';
|
||||
import compression from 'compression';
|
||||
import apiRouter, { setWsBroadcast, setWsBroadcastTo, hookRouter } from './src/routes/api.js';
|
||||
import * as manager from './src/agents/manager.js';
|
||||
import { setGlobalBroadcast } from './src/agents/manager.js';
|
||||
import { cancelAllExecutions } from './src/agents/executor.js';
|
||||
import { stopAll as stopAllSchedules } from './src/agents/scheduler.js';
|
||||
import { flushAllStores } from './src/store/db.js';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const PORT = process.env.PORT || 3000;
|
||||
const HOST = process.env.HOST || '127.0.0.1';
|
||||
const AUTH_TOKEN = process.env.AUTH_TOKEN || '';
|
||||
const ALLOWED_ORIGIN = process.env.ALLOWED_ORIGIN || '';
|
||||
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();
|
||||
app.set('trust proxy', 1);
|
||||
const httpServer = createServer(app);
|
||||
const wss = new WebSocketServer({ server: httpServer });
|
||||
|
||||
app.use((req, res, next) => {
|
||||
const origin = ALLOWED_ORIGIN || req.headers.origin || '*';
|
||||
res.setHeader('Access-Control-Allow-Origin', origin);
|
||||
res.setHeader('Access-Control-Allow-Origin', ALLOWED_ORIGIN);
|
||||
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);
|
||||
next();
|
||||
});
|
||||
|
||||
if (AUTH_TOKEN) {
|
||||
app.use((req, res, next) => {
|
||||
req.correlationId = req.headers['x-correlation-id'] || crypto.randomUUID();
|
||||
res.setHeader('X-Correlation-ID', req.correlationId);
|
||||
next();
|
||||
});
|
||||
|
||||
app.get('/api/health', (req, res) => {
|
||||
res.json({
|
||||
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 (token !== AUTH_TOKEN) {
|
||||
if (!timingSafeCompare(token, AUTH_TOKEN)) {
|
||||
return res.status(401).json({ error: 'Token de autenticação inválido' });
|
||||
}
|
||||
next();
|
||||
});
|
||||
}
|
||||
|
||||
app.use(express.json());
|
||||
app.use('/hook', hookRouter);
|
||||
app.use(express.static(join(__dirname, 'public')));
|
||||
app.use(express.json({
|
||||
verify: (req, res, buf) => { req.rawBody = buf || Buffer.alloc(0); },
|
||||
}));
|
||||
app.use('/hook', hookLimiter, verifyWebhookSignature, hookRouter);
|
||||
app.get('/', (req, res) => res.redirect('https://agen.nitro-cloud.duckdns.org/'));
|
||||
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);
|
||||
|
||||
const connectedClients = new Map();
|
||||
@@ -51,20 +130,30 @@ wss.on('connection', (ws, req) => {
|
||||
|
||||
if (AUTH_TOKEN) {
|
||||
const token = new URL(req.url, 'http://localhost').searchParams.get('token');
|
||||
if (token !== AUTH_TOKEN) {
|
||||
if (!timingSafeCompare(token, AUTH_TOKEN)) {
|
||||
ws.close(4001, 'Token inválido');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
ws.clientId = clientId;
|
||||
ws.isAlive = true;
|
||||
connectedClients.set(clientId, ws);
|
||||
|
||||
ws.on('pong', () => { ws.isAlive = true; });
|
||||
ws.on('close', () => connectedClients.delete(clientId));
|
||||
ws.on('error', () => connectedClients.delete(clientId));
|
||||
ws.send(JSON.stringify({ type: 'connected', clientId }));
|
||||
});
|
||||
|
||||
const wsHeartbeat = setInterval(() => {
|
||||
wss.clients.forEach(ws => {
|
||||
if (!ws.isAlive) return ws.terminate();
|
||||
ws.isAlive = false;
|
||||
ws.ping();
|
||||
});
|
||||
}, 30000);
|
||||
|
||||
function broadcast(message) {
|
||||
const payload = JSON.stringify(message);
|
||||
for (const [, client] of connectedClients) {
|
||||
@@ -86,30 +175,51 @@ setGlobalBroadcast(broadcast);
|
||||
function gracefulShutdown(signal) {
|
||||
console.log(`\nSinal ${signal} recebido. Encerrando servidor...`);
|
||||
|
||||
stopAllSchedules();
|
||||
console.log('Agendamentos parados.');
|
||||
|
||||
cancelAllExecutions();
|
||||
console.log('Execuções ativas canceladas.');
|
||||
|
||||
flushAllStores();
|
||||
console.log('Dados persistidos.');
|
||||
|
||||
clearInterval(wsHeartbeat);
|
||||
|
||||
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(() => {
|
||||
console.error('Forçando encerramento após timeout.');
|
||||
process.exit(1);
|
||||
}, 10000);
|
||||
}, 10000).unref();
|
||||
}
|
||||
|
||||
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
|
||||
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
|
||||
|
||||
process.on('uncaughtException', (err) => {
|
||||
console.error('[FATAL] Exceção não capturada:', err.message);
|
||||
console.error(err.stack);
|
||||
});
|
||||
|
||||
process.on('unhandledRejection', (reason) => {
|
||||
console.error('[WARN] Promise rejeitada não tratada:', reason);
|
||||
});
|
||||
|
||||
manager.restoreSchedules();
|
||||
|
||||
httpServer.listen(PORT, () => {
|
||||
console.log(`Painel administrativo disponível em http://localhost:${PORT}`);
|
||||
httpServer.listen(PORT, HOST, () => {
|
||||
console.log(`Painel administrativo disponível em http://${HOST}:${PORT}`);
|
||||
console.log(`WebSocket server ativo na mesma porta.`);
|
||||
if (AUTH_TOKEN) console.log('Autenticação por token ativada.');
|
||||
});
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
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 { 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 activeExecutions = new Map();
|
||||
const executionOutputBuffers = new Map();
|
||||
const MAX_OUTPUT_SIZE = 512 * 1024;
|
||||
const MAX_ERROR_SIZE = 100 * 1024;
|
||||
const MAX_BUFFER_LINES = 1000;
|
||||
const ALLOWED_DIRECTORIES = (process.env.ALLOWED_DIRECTORIES || '').split(',').map(d => d.trim()).filter(Boolean);
|
||||
|
||||
let maxConcurrent = settingsStore.get().maxConcurrent || 5;
|
||||
|
||||
@@ -12,6 +21,12 @@ export function updateMaxConcurrent(value) {
|
||||
maxConcurrent = Math.max(1, Math.min(20, parseInt(value) || 5));
|
||||
}
|
||||
|
||||
function isDirectoryAllowed(dir) {
|
||||
if (ALLOWED_DIRECTORIES.length === 0) return true;
|
||||
const resolved = path.resolve(dir);
|
||||
return ALLOWED_DIRECTORIES.some(allowed => resolved.startsWith(path.resolve(allowed)));
|
||||
}
|
||||
|
||||
function resolveBin() {
|
||||
if (process.env.CLAUDE_BIN) return process.env.CLAUDE_BIN;
|
||||
const home = process.env.HOME || '';
|
||||
@@ -34,19 +49,26 @@ function sanitizeText(str) {
|
||||
.slice(0, 50000);
|
||||
}
|
||||
|
||||
function cleanEnv() {
|
||||
function cleanEnv(agentSecrets) {
|
||||
const env = { ...process.env };
|
||||
delete env.CLAUDECODE;
|
||||
delete env.ANTHROPIC_API_KEY;
|
||||
if (!env.CLAUDE_CODE_MAX_OUTPUT_TOKENS) {
|
||||
env.CLAUDE_CODE_MAX_OUTPUT_TOKENS = '128000';
|
||||
if (!env.SHELL) env.SHELL = '/bin/bash';
|
||||
if (!env.HOME) env.HOME = process.env.HOME || '/root';
|
||||
if (agentSecrets && typeof agentSecrets === 'object') {
|
||||
Object.assign(env, agentSecrets);
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
function buildArgs(agentConfig, prompt) {
|
||||
function buildArgs(agentConfig) {
|
||||
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) {
|
||||
args.push('--system-prompt', agentConfig.systemPrompt);
|
||||
@@ -111,58 +133,119 @@ function extractText(event) {
|
||||
return null;
|
||||
}
|
||||
|
||||
export function execute(agentConfig, task, callbacks = {}) {
|
||||
if (activeExecutions.size >= maxConcurrent) {
|
||||
const err = new Error(`Limite de ${maxConcurrent} execuções simultâneas atingido`);
|
||||
if (callbacks.onError) callbacks.onError(err, uuidv4());
|
||||
function extractToolInfo(event) {
|
||||
if (!event) return null;
|
||||
|
||||
if (event.type === 'assistant' && event.message?.content) {
|
||||
const toolBlocks = event.message.content.filter((b) => b.type === 'tool_use');
|
||||
if (toolBlocks.length > 0) {
|
||||
return toolBlocks.map((b) => {
|
||||
const name = b.name || 'unknown';
|
||||
const input = b.input || {};
|
||||
let detail = '';
|
||||
if (input.command) detail = input.command.slice(0, 120);
|
||||
else if (input.file_path) detail = input.file_path;
|
||||
else if (input.pattern) detail = input.pattern;
|
||||
else if (input.query) detail = input.query;
|
||||
else if (input.path) detail = input.path;
|
||||
else if (input.prompt) detail = input.prompt.slice(0, 80);
|
||||
else if (input.description) detail = input.description.slice(0, 80);
|
||||
return { name, detail };
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (event.type === 'content_block_start' && event.content_block?.type === 'tool_use') {
|
||||
return [{ name: event.content_block.name || 'tool', detail: '' }];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const executionId = uuidv4();
|
||||
function extractSystemInfo(event) {
|
||||
if (!event) return null;
|
||||
|
||||
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 bufferLine(executionId, data) {
|
||||
let buf = executionOutputBuffers.get(executionId);
|
||||
if (!buf) {
|
||||
buf = [];
|
||||
executionOutputBuffers.set(executionId, buf);
|
||||
}
|
||||
buf.push(data);
|
||||
if (buf.length > MAX_BUFFER_LINES) buf.shift();
|
||||
}
|
||||
|
||||
function processChildOutput(child, executionId, callbacks, options = {}) {
|
||||
const { onData, onError, onComplete } = callbacks;
|
||||
|
||||
const prompt = buildPrompt(task.description || task, task.instructions);
|
||||
const args = buildArgs(agentConfig, prompt);
|
||||
|
||||
const spawnOptions = {
|
||||
env: cleanEnv(),
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
};
|
||||
|
||||
if (agentConfig.workingDirectory && agentConfig.workingDirectory.trim()) {
|
||||
if (!existsSync(agentConfig.workingDirectory)) {
|
||||
const err = new Error(`Diretório de trabalho não encontrado: ${agentConfig.workingDirectory}`);
|
||||
if (onError) onError(err, executionId);
|
||||
return executionId;
|
||||
}
|
||||
spawnOptions.cwd = agentConfig.workingDirectory;
|
||||
}
|
||||
|
||||
console.log(`[executor] Iniciando: ${executionId} | Modelo: ${agentConfig.model || 'claude-sonnet-4-6'}`);
|
||||
|
||||
const child = spawn(CLAUDE_BIN, args, spawnOptions);
|
||||
let hadError = false;
|
||||
|
||||
activeExecutions.set(executionId, {
|
||||
process: child,
|
||||
agentConfig,
|
||||
task,
|
||||
startedAt: new Date().toISOString(),
|
||||
executionId,
|
||||
});
|
||||
|
||||
const timeoutMs = options.timeout || 1800000;
|
||||
const sessionIdOverride = options.sessionIdOverride || null;
|
||||
let outputBuffer = '';
|
||||
let errorBuffer = '';
|
||||
let fullText = '';
|
||||
let resultMeta = null;
|
||||
let turnCount = 0;
|
||||
let hadError = false;
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
child.kill('SIGTERM');
|
||||
setTimeout(() => { if (!child.killed) child.kill('SIGKILL'); }, 5000);
|
||||
}, timeoutMs);
|
||||
|
||||
function processEvent(parsed) {
|
||||
if (!parsed) return;
|
||||
|
||||
const tools = extractToolInfo(parsed);
|
||||
if (tools) {
|
||||
for (const t of tools) {
|
||||
const msg = t.detail ? `${t.name}: ${t.detail}` : t.name;
|
||||
const data = { type: 'tool', content: msg, toolName: t.name };
|
||||
bufferLine(executionId, data);
|
||||
if (onData) onData(data, executionId);
|
||||
}
|
||||
}
|
||||
|
||||
const text = extractText(parsed);
|
||||
if (text) {
|
||||
if (fullText.length < MAX_OUTPUT_SIZE) {
|
||||
fullText += text;
|
||||
if (onData) onData({ type: 'chunk', content: text }, executionId);
|
||||
}
|
||||
const data = { type: 'chunk', content: text };
|
||||
bufferLine(executionId, data);
|
||||
if (onData) onData(data, executionId);
|
||||
}
|
||||
|
||||
const sysInfo = extractSystemInfo(parsed);
|
||||
if (sysInfo) {
|
||||
const data = { type: 'system', content: sysInfo };
|
||||
bufferLine(executionId, data);
|
||||
if (onData) onData(data, executionId);
|
||||
}
|
||||
|
||||
if (parsed.type === 'assistant') {
|
||||
turnCount++;
|
||||
const data = { type: 'turn', content: `Turno ${turnCount}`, turn: turnCount };
|
||||
bufferLine(executionId, data);
|
||||
if (onData) onData(data, executionId);
|
||||
}
|
||||
|
||||
if (parsed.type === 'result') {
|
||||
resultMeta = {
|
||||
costUsd: parsed.cost_usd || 0,
|
||||
@@ -170,7 +253,9 @@ export function execute(agentConfig, task, callbacks = {}) {
|
||||
durationMs: parsed.duration_ms || 0,
|
||||
durationApiMs: parsed.duration_api_ms || 0,
|
||||
numTurns: parsed.num_turns || 0,
|
||||
sessionId: parsed.session_id || '',
|
||||
sessionId: parsed.session_id || sessionIdOverride || '',
|
||||
isError: parsed.is_error || false,
|
||||
errors: parsed.errors || [],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -182,32 +267,117 @@ export function execute(agentConfig, task, callbacks = {}) {
|
||||
});
|
||||
|
||||
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) {
|
||||
const data = { type: 'stderr', content: line.trim() };
|
||||
bufferLine(executionId, data);
|
||||
if (onData) onData(data, executionId);
|
||||
}
|
||||
});
|
||||
|
||||
child.on('error', (err) => {
|
||||
clearTimeout(timeout);
|
||||
console.log(`[executor][error] ${err.message}`);
|
||||
hadError = true;
|
||||
activeExecutions.delete(executionId);
|
||||
executionOutputBuffers.delete(executionId);
|
||||
if (onError) onError(err, executionId);
|
||||
});
|
||||
|
||||
child.on('close', (code) => {
|
||||
clearTimeout(timeout);
|
||||
const wasCanceled = activeExecutions.get(executionId)?.canceled || false;
|
||||
activeExecutions.delete(executionId);
|
||||
executionOutputBuffers.delete(executionId);
|
||||
if (hadError) return;
|
||||
|
||||
if (outputBuffer.trim()) processEvent(parseStreamLine(outputBuffer));
|
||||
|
||||
if (resultMeta?.isError && resultMeta.errors?.length > 0) {
|
||||
const errorMsg = resultMeta.errors.join('; ');
|
||||
if (onError) onError(new Error(errorMsg), executionId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (onComplete) {
|
||||
onComplete({
|
||||
executionId,
|
||||
exitCode: code,
|
||||
result: fullText,
|
||||
stderr: errorBuffer,
|
||||
canceled: wasCanceled,
|
||||
...(resultMeta || {}),
|
||||
}, executionId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function validateWorkingDirectory(agentConfig, executionId, onError) {
|
||||
if (!agentConfig.workingDirectory || !agentConfig.workingDirectory.trim()) return true;
|
||||
|
||||
if (!isDirectoryAllowed(agentConfig.workingDirectory)) {
|
||||
const err = new Error(`Diretório de trabalho não permitido: ${agentConfig.workingDirectory}`);
|
||||
if (onError) onError(err, executionId);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!existsSync(agentConfig.workingDirectory)) {
|
||||
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;
|
||||
}
|
||||
@@ -222,6 +392,8 @@ export function resume(agentConfig, sessionId, message, callbacks = {}) {
|
||||
const executionId = uuidv4();
|
||||
const { onData, onError, onComplete } = callbacks;
|
||||
|
||||
if (!validateWorkingDirectory(agentConfig, executionId, onError)) return null;
|
||||
|
||||
const model = agentConfig.model || 'claude-sonnet-4-6';
|
||||
const args = [
|
||||
'--resume', sessionId,
|
||||
@@ -232,6 +404,10 @@ export function resume(agentConfig, sessionId, message, callbacks = {}) {
|
||||
'--permission-mode', agentConfig.permissionMode || 'bypassPermissions',
|
||||
];
|
||||
|
||||
if (existsSync(AGENT_SETTINGS)) {
|
||||
args.push('--settings', AGENT_SETTINGS);
|
||||
}
|
||||
|
||||
if (agentConfig.maxTurns && agentConfig.maxTurns > 0) {
|
||||
args.push('--max-turns', String(agentConfig.maxTurns));
|
||||
}
|
||||
@@ -242,18 +418,12 @@ export function resume(agentConfig, sessionId, message, callbacks = {}) {
|
||||
};
|
||||
|
||||
if (agentConfig.workingDirectory && agentConfig.workingDirectory.trim()) {
|
||||
if (!existsSync(agentConfig.workingDirectory)) {
|
||||
const err = new Error(`Diretório de trabalho não encontrado: ${agentConfig.workingDirectory}`);
|
||||
if (onError) onError(err, executionId);
|
||||
return executionId;
|
||||
}
|
||||
spawnOptions.cwd = agentConfig.workingDirectory;
|
||||
}
|
||||
|
||||
console.log(`[executor] Resumindo sessão: ${sessionId} | Execução: ${executionId}`);
|
||||
|
||||
const child = spawn(CLAUDE_BIN, args, spawnOptions);
|
||||
let hadError = false;
|
||||
|
||||
activeExecutions.set(executionId, {
|
||||
process: child,
|
||||
@@ -263,60 +433,9 @@ export function resume(agentConfig, sessionId, message, callbacks = {}) {
|
||||
executionId,
|
||||
});
|
||||
|
||||
let outputBuffer = '';
|
||||
let errorBuffer = '';
|
||||
let fullText = '';
|
||||
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);
|
||||
}
|
||||
processChildOutput(child, executionId, { onData, onError, onComplete }, {
|
||||
timeout: agentConfig.timeout || 1800000,
|
||||
sessionIdOverride: sessionId,
|
||||
});
|
||||
|
||||
return executionId;
|
||||
@@ -325,14 +444,15 @@ export function resume(agentConfig, sessionId, message, callbacks = {}) {
|
||||
export function cancel(executionId) {
|
||||
const execution = activeExecutions.get(executionId);
|
||||
if (!execution) return false;
|
||||
execution.canceled = true;
|
||||
execution.process.kill('SIGTERM');
|
||||
activeExecutions.delete(executionId);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function cancelAllExecutions() {
|
||||
for (const [, exec] of activeExecutions) exec.process.kill('SIGTERM');
|
||||
activeExecutions.clear();
|
||||
executionOutputBuffers.clear();
|
||||
}
|
||||
|
||||
export function getActiveExecutions() {
|
||||
@@ -340,9 +460,70 @@ export function getActiveExecutions() {
|
||||
executionId: exec.executionId,
|
||||
startedAt: exec.startedAt,
|
||||
agentConfig: exec.agentConfig,
|
||||
outputBuffer: executionOutputBuffers.get(exec.executionId) || [],
|
||||
}));
|
||||
}
|
||||
|
||||
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() {
|
||||
return CLAUDE_BIN;
|
||||
}
|
||||
|
||||
117
src/agents/git-integration.js
Normal file
117
src/agents/git-integration.js
Normal 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);
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
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 scheduler from './scheduler.js';
|
||||
import { generateAgentReport } from '../reports/generator.js';
|
||||
import * as gitIntegration from './git-integration.js';
|
||||
|
||||
const DEFAULT_CONFIG = {
|
||||
model: 'claude-sonnet-4-6',
|
||||
@@ -25,6 +28,14 @@ function getWsCallback(wsCallback) {
|
||||
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 dailyCountDate = new Date().toDateString();
|
||||
|
||||
@@ -90,6 +101,13 @@ export function createAgent(data) {
|
||||
export function updateAgent(id, data) {
|
||||
const existing = agentsStore.getById(id);
|
||||
if (!existing) return null;
|
||||
|
||||
agentVersionsStore.create({
|
||||
agentId: id,
|
||||
version: existing,
|
||||
changedFields: Object.keys(data).filter(k => k !== 'id'),
|
||||
});
|
||||
|
||||
const updateData = {};
|
||||
if (data.agent_name !== undefined) updateData.agent_name = data.agent_name;
|
||||
if (data.description !== undefined) updateData.description = data.description;
|
||||
@@ -105,16 +123,31 @@ export function deleteAgent(id) {
|
||||
return agentsStore.delete(id);
|
||||
}
|
||||
|
||||
function loadAgentSecrets(agentId) {
|
||||
const all = secretsStore.getAll();
|
||||
const agentSecrets = all.filter(s => s.agentId === agentId);
|
||||
if (agentSecrets.length === 0) return null;
|
||||
const env = {};
|
||||
for (const s of agentSecrets) env[s.name] = s.value;
|
||||
return env;
|
||||
}
|
||||
|
||||
export function executeTask(agentId, task, instructions, wsCallback, metadata = {}) {
|
||||
const agent = agentsStore.getById(agentId);
|
||||
if (!agent) throw new Error(`Agente ${agentId} não encontrado`);
|
||||
if (agent.status !== 'active') throw new Error(`Agente ${agentId} está inativo`);
|
||||
|
||||
const retryEnabled = agent.config?.retryOnFailure === true;
|
||||
const maxRetries = Math.min(Math.max(parseInt(agent.config?.maxRetries) || 1, 1), 3);
|
||||
const attempt = metadata._retryAttempt || 1;
|
||||
|
||||
const cb = getWsCallback(wsCallback);
|
||||
const taskText = typeof task === 'string' ? task : task.description;
|
||||
const startedAt = new Date().toISOString();
|
||||
|
||||
const historyRecord = executionsStore.create({
|
||||
const historyRecord = metadata._historyRecordId
|
||||
? { id: metadata._historyRecordId }
|
||||
: executionsStore.create({
|
||||
type: 'agent',
|
||||
...metadata,
|
||||
agentId,
|
||||
@@ -125,6 +158,10 @@ export function executeTask(agentId, task, instructions, wsCallback, metadata =
|
||||
startedAt,
|
||||
});
|
||||
|
||||
if (metadata._retryAttempt) {
|
||||
executionsStore.update(historyRecord.id, { status: 'running', error: null });
|
||||
}
|
||||
|
||||
const execRecord = {
|
||||
executionId: null,
|
||||
agentId,
|
||||
@@ -134,9 +171,28 @@ export function executeTask(agentId, task, instructions, wsCallback, metadata =
|
||||
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(
|
||||
agent.config,
|
||||
{ description: task, instructions },
|
||||
effectiveConfig,
|
||||
{ description: task, instructions: effectiveInstructions },
|
||||
{
|
||||
onData: (parsed, execId) => {
|
||||
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) => {
|
||||
const endedAt = new Date().toISOString();
|
||||
updateExecutionRecord(agentId, execId, { status: 'error', error: err.message, endedAt });
|
||||
|
||||
if (retryEnabled && attempt < maxRetries) {
|
||||
const delayMs = attempt * 5000;
|
||||
executionsStore.update(historyRecord.id, { status: 'retrying', error: err.message, attempt, endedAt });
|
||||
if (cb) cb({
|
||||
type: 'execution_retry',
|
||||
executionId: execId,
|
||||
agentId,
|
||||
data: { attempt, maxRetries, nextRetryIn: delayMs / 1000, reason: err.message },
|
||||
});
|
||||
setTimeout(() => {
|
||||
try {
|
||||
executeTask(agentId, task, instructions, wsCallback, {
|
||||
...metadata,
|
||||
_retryAttempt: attempt + 1,
|
||||
_historyRecordId: historyRecord.id,
|
||||
});
|
||||
} catch (retryErr) {
|
||||
executionsStore.update(historyRecord.id, { status: 'error', error: retryErr.message, endedAt: new Date().toISOString() });
|
||||
if (cb) cb({ type: 'execution_error', executionId: execId, agentId, data: { error: retryErr.message } });
|
||||
}
|
||||
}, delayMs);
|
||||
return;
|
||||
}
|
||||
|
||||
executionsStore.update(historyRecord.id, { status: 'error', error: err.message, endedAt });
|
||||
createNotification('error', 'Execução falhou', `Agente "${agent.agent_name}" encontrou um erro`, { agentId, executionId: execId });
|
||||
if (cb) cb({ type: 'execution_error', executionId: execId, agentId, data: { error: err.message } });
|
||||
},
|
||||
onComplete: (result, execId) => {
|
||||
@@ -161,9 +243,61 @@ export function executeTask(agentId, task, instructions, wsCallback, metadata =
|
||||
numTurns: result.numTurns || 0,
|
||||
sessionId: result.sessionId || '',
|
||||
});
|
||||
if (cb) cb({ type: 'execution_complete', executionId: execId, agentId, data: result });
|
||||
},
|
||||
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 });
|
||||
|
||||
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) {
|
||||
@@ -185,28 +319,46 @@ export function executeTask(agentId, task, instructions, wsCallback, metadata =
|
||||
return executionId;
|
||||
}
|
||||
|
||||
function updateRecentBuffer(executionId, updates) {
|
||||
const entry = recentExecBuffer.find((e) => e.executionId === executionId);
|
||||
if (entry) Object.assign(entry, updates);
|
||||
}
|
||||
|
||||
function updateExecutionRecord(agentId, executionId, updates) {
|
||||
async function updateExecutionRecord(agentId, executionId, updates) {
|
||||
await withLock(`agent:${agentId}`, () => {
|
||||
const agent = agentsStore.getById(agentId);
|
||||
if (!agent) return;
|
||||
const executions = (agent.executions || []).map((exec) =>
|
||||
exec.executionId === executionId ? { ...exec, ...updates } : exec
|
||||
);
|
||||
agentsStore.update(agentId, { executions });
|
||||
});
|
||||
}
|
||||
|
||||
export function getRecentExecutions(limit = 20) {
|
||||
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) {
|
||||
const agent = agentsStore.getById(agentId);
|
||||
if (!agent) throw new Error(`Agente ${agentId} não encontrado`);
|
||||
|
||||
if (!cron.validate(cronExpression)) {
|
||||
throw new Error(`Expressão cron inválida: ${cronExpression}`);
|
||||
}
|
||||
|
||||
const scheduleId = uuidv4();
|
||||
const items = schedulesStore.getAll();
|
||||
items.push({
|
||||
@@ -222,7 +374,9 @@ export function scheduleTask(agentId, taskDescription, cronExpression, wsCallbac
|
||||
schedulesStore.save(items);
|
||||
|
||||
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);
|
||||
|
||||
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;
|
||||
|
||||
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 });
|
||||
@@ -264,20 +420,11 @@ export function continueConversation(agentId, sessionId, message, wsCallback) {
|
||||
parentSessionId: sessionId,
|
||||
});
|
||||
|
||||
const executionId = executor.resume(
|
||||
agent.config,
|
||||
sessionId,
|
||||
message,
|
||||
{
|
||||
onData: (parsed, execId) => {
|
||||
const onData = (parsed, execId) => {
|
||||
if (cb) cb({ type: 'execution_output', executionId: execId, agentId, data: parsed });
|
||||
},
|
||||
onError: (err, execId) => {
|
||||
const endedAt = new Date().toISOString();
|
||||
executionsStore.update(historyRecord.id, { status: 'error', error: err.message, endedAt });
|
||||
if (cb) cb({ type: 'execution_error', executionId: execId, agentId, data: { error: err.message } });
|
||||
},
|
||||
onComplete: (result, execId) => {
|
||||
};
|
||||
|
||||
const onComplete = (result, execId) => {
|
||||
const endedAt = new Date().toISOString();
|
||||
executionsStore.update(historyRecord.id, {
|
||||
status: 'completed',
|
||||
@@ -290,9 +437,53 @@ export function continueConversation(agentId, sessionId, message, wsCallback) {
|
||||
numTurns: result.numTurns || 0,
|
||||
sessionId: result.sessionId || sessionId,
|
||||
});
|
||||
if (cb) cb({ type: 'execution_complete', executionId: execId, agentId, data: result });
|
||||
},
|
||||
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, agentName: agent.agent_name, data: result });
|
||||
};
|
||||
|
||||
const onError = (err, execId) => {
|
||||
const isSessionLost = err.message.includes('No conversation found') || err.message.includes('not a valid');
|
||||
|
||||
if (isSessionLost) {
|
||||
console.log(`[manager] Sessão perdida (${sessionId}), iniciando nova execução para agente ${agentId}`);
|
||||
if (cb) cb({ type: 'execution_output', executionId: execId, agentId, data: { type: 'system', content: 'Sessão anterior expirou. Iniciando nova execução...' } });
|
||||
|
||||
const secrets = secretsStore.getByAgent(agentId);
|
||||
const newExecId = executor.execute(
|
||||
agent.config,
|
||||
{ description: message },
|
||||
{ onData, onError: onErrorFinal, onComplete },
|
||||
Object.keys(secrets).length > 0 ? secrets : null,
|
||||
);
|
||||
|
||||
if (newExecId) {
|
||||
executionsStore.update(historyRecord.id, { executionId: newExecId, parentSessionId: null });
|
||||
} else {
|
||||
onErrorFinal(new Error('Falha ao iniciar nova execução'), execId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
onErrorFinal(err, execId);
|
||||
};
|
||||
|
||||
const onErrorFinal = (err, execId) => {
|
||||
const endedAt = new Date().toISOString();
|
||||
executionsStore.update(historyRecord.id, { status: 'error', error: err.message, endedAt });
|
||||
if (cb) cb({ type: 'execution_error', executionId: execId, agentId, data: { error: err.message } });
|
||||
};
|
||||
|
||||
const executionId = executor.resume(
|
||||
agent.config,
|
||||
sessionId,
|
||||
message,
|
||||
{ onData, onError, onComplete }
|
||||
);
|
||||
|
||||
if (!executionId) {
|
||||
@@ -343,10 +534,8 @@ export function importAgent(data) {
|
||||
|
||||
export function restoreSchedules() {
|
||||
scheduler.restoreSchedules((agentId, taskDescription, scheduleId) => {
|
||||
try {
|
||||
executeTask(agentId, taskDescription, null, null, { source: 'schedule', scheduleId });
|
||||
} catch (err) {
|
||||
executeWithRetry(agentId, taskDescription, { source: 'schedule', scheduleId }).catch(err => {
|
||||
console.log(`[manager] Erro ao executar tarefa agendada: ${err.message}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { pipelinesStore, agentsStore, executionsStore } from '../store/db.js';
|
||||
import * as executor from './executor.js';
|
||||
import * as gitIntegration from './git-integration.js';
|
||||
import { mem } from '../cache/index.js';
|
||||
import { generatePipelineReport } from '../reports/generator.js';
|
||||
|
||||
const activePipelines = new Map();
|
||||
const AGENT_MAP_TTL = 30_000;
|
||||
@@ -85,6 +87,7 @@ function executeStepAsPromise(agentConfig, prompt, pipelineState, wsCallback, pi
|
||||
costUsd: result.costUsd || 0,
|
||||
durationMs: result.durationMs || 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) => {
|
||||
const state = activePipelines.get(pipelineId);
|
||||
const state = activePipelines.get(executionId);
|
||||
if (!state) { resolve(false); return; }
|
||||
|
||||
state.pendingApproval = {
|
||||
@@ -115,6 +118,7 @@ function waitForApproval(pipelineId, stepIndex, previousOutput, agentName, wsCal
|
||||
wsCallback({
|
||||
type: 'pipeline_approval_required',
|
||||
pipelineId,
|
||||
executionId,
|
||||
stepIndex,
|
||||
agentName,
|
||||
previousOutput: previousOutput.slice(0, 3000),
|
||||
@@ -123,8 +127,16 @@ function waitForApproval(pipelineId, stepIndex, previousOutput, agentName, wsCal
|
||||
});
|
||||
}
|
||||
|
||||
export function approvePipelineStep(pipelineId) {
|
||||
const state = activePipelines.get(pipelineId);
|
||||
function findPipelineState(idOrExecId) {
|
||||
if (activePipelines.has(idOrExecId)) return activePipelines.get(idOrExecId);
|
||||
for (const [, state] of activePipelines) {
|
||||
if (state.pipelineId === idOrExecId) return state;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function approvePipelineStep(id) {
|
||||
const state = findPipelineState(id);
|
||||
if (!state?.pendingApproval) return false;
|
||||
const { resolve } = state.pendingApproval;
|
||||
state.pendingApproval = null;
|
||||
@@ -132,8 +144,8 @@ export function approvePipelineStep(pipelineId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
export function rejectPipelineStep(pipelineId) {
|
||||
const state = activePipelines.get(pipelineId);
|
||||
export function rejectPipelineStep(id) {
|
||||
const state = findPipelineState(id);
|
||||
if (!state?.pendingApproval) return false;
|
||||
const { resolve } = state.pendingApproval;
|
||||
state.pendingApproval = null;
|
||||
@@ -144,9 +156,11 @@ export function rejectPipelineStep(pipelineId) {
|
||||
export async function executePipeline(pipelineId, initialInput, wsCallback, options = {}) {
|
||||
const pl = pipelinesStore.getById(pipelineId);
|
||||
if (!pl) throw new Error(`Pipeline ${pipelineId} não encontrado`);
|
||||
if (pl.status !== 'active') throw new Error(`Pipeline "${pl.name}" está desativado`);
|
||||
|
||||
const pipelineState = { currentExecutionId: null, currentStep: 0, canceled: false, pendingApproval: null };
|
||||
activePipelines.set(pipelineId, pipelineState);
|
||||
const executionId = uuidv4();
|
||||
const pipelineState = { pipelineId, currentExecutionId: null, currentStep: 0, canceled: false, pendingApproval: null };
|
||||
activePipelines.set(executionId, pipelineState);
|
||||
|
||||
const historyRecord = executionsStore.create({
|
||||
type: 'pipeline',
|
||||
@@ -180,7 +194,7 @@ export async function executePipeline(pipelineId, initialInput, wsCallback, opti
|
||||
wsCallback({ type: 'pipeline_status', pipelineId, status: 'awaiting_approval', stepIndex: i });
|
||||
}
|
||||
|
||||
const approved = await waitForApproval(pipelineId, i, currentInput, prevAgentName, wsCallback);
|
||||
const approved = await waitForApproval(executionId, pipelineId, i, currentInput, prevAgentName, wsCallback);
|
||||
|
||||
if (!approved) {
|
||||
pipelineState.canceled = true;
|
||||
@@ -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`);
|
||||
|
||||
const stepConfig = { ...agent.config };
|
||||
if (options.workingDirectory) {
|
||||
stepConfig.workingDirectory = options.workingDirectory;
|
||||
const effectiveWorkdir = options.workingDirectory || pl.workingDirectory;
|
||||
if (effectiveWorkdir) {
|
||||
stepConfig.workingDirectory = effectiveWorkdir;
|
||||
}
|
||||
|
||||
const prompt = applyTemplate(step.inputTemplate, currentInput);
|
||||
@@ -225,7 +240,7 @@ export async function executePipeline(pipelineId, initialInput, wsCallback, opti
|
||||
|
||||
totalCost += stepResult.costUsd;
|
||||
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 savedSteps = current ? (current.steps || []) : [];
|
||||
@@ -254,9 +269,22 @@ export async function executePipeline(pipelineId, initialInput, wsCallback, opti
|
||||
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';
|
||||
executionsStore.update(historyRecord.id, {
|
||||
@@ -265,16 +293,48 @@ export async function executePipeline(pipelineId, initialInput, wsCallback, opti
|
||||
totalCostUsd: totalCost,
|
||||
});
|
||||
|
||||
if (!pipelineState.canceled && wsCallback) {
|
||||
wsCallback({ type: 'pipeline_complete', pipelineId, results, totalCostUsd: totalCost });
|
||||
if (!pipelineState.canceled) {
|
||||
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); }
|
||||
}
|
||||
|
||||
return results;
|
||||
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 { executionId, results };
|
||||
} catch (err) {
|
||||
activePipelines.delete(pipelineId);
|
||||
activePipelines.delete(executionId);
|
||||
executionsStore.update(historyRecord.id, {
|
||||
status: 'error',
|
||||
error: err.message,
|
||||
failedAtStep: pipelineState.currentStep,
|
||||
lastStepInput: currentInput.slice(0, 50000),
|
||||
endedAt: new Date().toISOString(),
|
||||
totalCostUsd: totalCost,
|
||||
});
|
||||
@@ -290,8 +350,14 @@ export async function executePipeline(pipelineId, initialInput, wsCallback, opti
|
||||
}
|
||||
}
|
||||
|
||||
export function cancelPipeline(pipelineId) {
|
||||
const state = activePipelines.get(pipelineId);
|
||||
export function cancelPipeline(id) {
|
||||
let executionId = id;
|
||||
let state = activePipelines.get(id);
|
||||
if (!state) {
|
||||
for (const [execId, s] of activePipelines) {
|
||||
if (s.pipelineId === id) { state = s; executionId = execId; break; }
|
||||
}
|
||||
}
|
||||
if (!state) return false;
|
||||
state.canceled = true;
|
||||
if (state.pendingApproval) {
|
||||
@@ -299,13 +365,190 @@ export function cancelPipeline(pipelineId) {
|
||||
state.pendingApproval = null;
|
||||
}
|
||||
if (state.currentExecutionId) executor.cancel(state.currentExecutionId);
|
||||
activePipelines.delete(pipelineId);
|
||||
activePipelines.delete(executionId);
|
||||
|
||||
const allExecs = executionsStore.getAll();
|
||||
const 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;
|
||||
}
|
||||
|
||||
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() {
|
||||
return Array.from(activePipelines.entries()).map(([id, state]) => ({
|
||||
pipelineId: id,
|
||||
executionId: id,
|
||||
pipelineId: state.pipelineId,
|
||||
currentStep: state.currentStep,
|
||||
currentExecutionId: state.currentExecutionId,
|
||||
pendingApproval: !!state.pendingApproval,
|
||||
@@ -318,6 +561,7 @@ export function createPipeline(data) {
|
||||
return pipelinesStore.create({
|
||||
name: data.name,
|
||||
description: data.description || '',
|
||||
workingDirectory: data.workingDirectory || '',
|
||||
steps: buildSteps(data.steps),
|
||||
status: data.status || 'active',
|
||||
});
|
||||
@@ -329,6 +573,7 @@ export function updatePipeline(id, data) {
|
||||
const updateData = {};
|
||||
if (data.name !== undefined) updateData.name = data.name;
|
||||
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.steps !== undefined) updateData.steps = buildSteps(data.steps);
|
||||
return pipelinesStore.update(id, updateData);
|
||||
|
||||
@@ -17,7 +17,11 @@ function addToHistory(entry) {
|
||||
|
||||
function matchesCronPart(part, value) {
|
||||
if (part === '*') return true;
|
||||
if (part.startsWith('*/')) return value % parseInt(part.slice(2)) === 0;
|
||||
if (part.startsWith('*/')) {
|
||||
const divisor = parseInt(part.slice(2));
|
||||
if (!divisor || divisor <= 0) return false;
|
||||
return value % divisor === 0;
|
||||
}
|
||||
if (part.includes(',')) return part.split(',').map(Number).includes(value);
|
||||
if (part.includes('-')) {
|
||||
const [start, end] = part.split('-').map(Number);
|
||||
@@ -66,6 +70,17 @@ export function schedule(taskId, cronExpr, callback, persist = true) {
|
||||
if (schedules.has(taskId)) unschedule(taskId, false);
|
||||
if (!cron.validate(cronExpr)) throw new Error(`Expressão cron inválida: ${cronExpr}`);
|
||||
|
||||
const MIN_INTERVAL_PARTS = cronExpr.split(' ');
|
||||
if (MIN_INTERVAL_PARTS[0] === '*' && MIN_INTERVAL_PARTS[1] === '*') {
|
||||
throw new Error('Intervalo mínimo de agendamento é 5 minutos. Use */5 ou maior.');
|
||||
}
|
||||
if (MIN_INTERVAL_PARTS[0].startsWith('*/')) {
|
||||
const interval = parseInt(MIN_INTERVAL_PARTS[0].slice(2));
|
||||
if (interval < 5 && MIN_INTERVAL_PARTS[1] === '*') {
|
||||
throw new Error(`Intervalo mínimo de agendamento é 5 minutos. Recebido: ${cronExpr}`);
|
||||
}
|
||||
}
|
||||
|
||||
const task = cron.schedule(
|
||||
cronExpr,
|
||||
() => {
|
||||
@@ -145,6 +160,13 @@ export function restoreSchedules(executeFn) {
|
||||
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) {
|
||||
emitter.on(event, listener);
|
||||
}
|
||||
|
||||
194
src/reports/generator.js
Normal file
194
src/reports/generator.js
Normal 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 };
|
||||
}
|
||||
1005
src/routes/api.js
1005
src/routes/api.js
File diff suppressed because it is too large
Load Diff
@@ -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 { fileURLToPath } from 'url';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
@@ -30,7 +31,16 @@ function readJson(path, fallback) {
|
||||
|
||||
function writeJson(path, data) {
|
||||
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) {
|
||||
@@ -41,6 +51,7 @@ function createStore(filePath) {
|
||||
let mem = null;
|
||||
let dirty = false;
|
||||
let timer = null;
|
||||
let maxSize = Infinity;
|
||||
|
||||
function boot() {
|
||||
if (mem !== null) return;
|
||||
@@ -54,7 +65,7 @@ function createStore(filePath) {
|
||||
timer = setTimeout(() => {
|
||||
timer = null;
|
||||
if (dirty) {
|
||||
writeJson(filePath, mem);
|
||||
writeJsonAsync(filePath, mem).catch((e) => console.error(`[db] Erro ao salvar ${filePath}:`, e.message));
|
||||
dirty = false;
|
||||
}
|
||||
}, DEBOUNCE_MS);
|
||||
@@ -72,6 +83,20 @@ function createStore(filePath) {
|
||||
return item ? clone(item) : null;
|
||||
},
|
||||
|
||||
findById(id) {
|
||||
return store.getById(id);
|
||||
},
|
||||
|
||||
count() {
|
||||
boot();
|
||||
return mem.length;
|
||||
},
|
||||
|
||||
filter(predicate) {
|
||||
boot();
|
||||
return mem.filter(predicate).map((item) => clone(item));
|
||||
},
|
||||
|
||||
create(data) {
|
||||
boot();
|
||||
const item = {
|
||||
@@ -81,6 +106,9 @@ function createStore(filePath) {
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
mem.push(item);
|
||||
if (maxSize !== Infinity && mem.length > maxSize) {
|
||||
mem.splice(0, mem.length - maxSize);
|
||||
}
|
||||
touch();
|
||||
return clone(item);
|
||||
},
|
||||
@@ -104,7 +132,8 @@ function createStore(filePath) {
|
||||
},
|
||||
|
||||
save(items) {
|
||||
mem = Array.isArray(items) ? items : mem;
|
||||
if (!Array.isArray(items)) return;
|
||||
mem = items;
|
||||
touch();
|
||||
},
|
||||
|
||||
@@ -118,6 +147,10 @@ function createStore(filePath) {
|
||||
dirty = false;
|
||||
}
|
||||
},
|
||||
|
||||
setMaxSize(n) {
|
||||
maxSize = n;
|
||||
},
|
||||
};
|
||||
|
||||
allStores.push(store);
|
||||
@@ -176,6 +209,21 @@ function createSettingsStore(filePath) {
|
||||
return store;
|
||||
}
|
||||
|
||||
const locks = new Map();
|
||||
|
||||
export async function withLock(key, fn) {
|
||||
while (locks.has(key)) await locks.get(key);
|
||||
let resolve;
|
||||
const promise = new Promise((r) => { resolve = r; });
|
||||
locks.set(key, promise);
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
locks.delete(key);
|
||||
resolve();
|
||||
}
|
||||
}
|
||||
|
||||
export function flushAllStores() {
|
||||
for (const s of allStores) s.flush();
|
||||
}
|
||||
@@ -185,5 +233,10 @@ export const tasksStore = createStore(`${DATA_DIR}/tasks.json`);
|
||||
export const pipelinesStore = createStore(`${DATA_DIR}/pipelines.json`);
|
||||
export const schedulesStore = createStore(`${DATA_DIR}/schedules.json`);
|
||||
export const executionsStore = createStore(`${DATA_DIR}/executions.json`);
|
||||
executionsStore.setMaxSize(5000);
|
||||
export const webhooksStore = createStore(`${DATA_DIR}/webhooks.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`);
|
||||
|
||||
Reference in New Issue
Block a user