Compare commits

..

25 Commits

Author SHA1 Message Date
Frederico Castro
1606efa09f Reescrever README com layout profissional e documentação completa 2026-02-28 04:42:00 -03:00
Frederico Castro
633b19f80d Integrar repositórios Git na execução de agentes e pipelines
- Módulo git-integration: clone/pull, commit/push automático, listagem de repos
- Seletor de repositório nos modais de execução (agente e pipeline)
- Seletor de branch carregado dinamicamente ao escolher repo
- Campo de diretório escondido quando repositório selecionado
- Auto-commit e push ao final da execução com mensagem descritiva
- Instrução injetada para agentes não fazerem operações git
- Rotas API: GET /repos, GET /repos/:name/branches
- Pipeline: commit automático ao final de todos os steps
2026-02-28 04:24:47 -03:00
Frederico Castro
2fae816162 Corrigir truncamento no terminal usando CSS grid 2026-02-28 04:05:07 -03:00
Frederico Castro
2201ac8699 Usar force-recreate ao reiniciar Caddy na publicação 2026-02-28 03:30:21 -03:00
Frederico Castro
a6bbe33e4b Adicionar grupo docker ao user node no Dockerfile 2026-02-28 03:18:52 -03:00
Frederico Castro
4c197eef91 Adicionar publicação automática de projetos
- Botão publicar (rocket) nas pastas raiz do explorador
- Cria repositório no Gitea, faz git init + push
- Atualiza Caddyfile com subdomínio e file_server
- Adiciona volume ao docker-compose e reinicia Caddy
- Botões lado a lado (download, publicar, excluir) no file explorer
- Dockerfile: adiciona git e docker-cli
2026-02-28 03:17:56 -03:00
Frederico Castro
e9f65c2845 Corrigir ícones e adicionar exclusão no explorador de arquivos
- Trocar ícone archive (lixeira) por download em todos os botões
- Adicionar botão de excluir com ícone trash-2 em cada entrada
- Rota DELETE /api/files com proteção contra exclusão da raiz
- Confirmação via modal antes de excluir
2026-02-28 03:00:45 -03:00
Frederico Castro
2fccaaac40 Adicionar botão de download da pasta raiz no explorador de arquivos 2026-02-28 02:42:57 -03:00
Frederico Castro
3178366e0e Corrigir truncamento de linhas longas no terminal
- Adicionar overflow-wrap: anywhere e min-width: 0 no .content
- Adicionar min-width: 0 no .terminal-line para flex shrink correto
- Definir overflow-x: hidden no .terminal-body
2026-02-28 02:27:27 -03:00
Frederico Castro
fa47538a8f Adicionar campo diretório de trabalho no modal de execução
- Campo execute-workdir no modal com valor pré-preenchido do agente
- Frontend envia workingDirectory na API de execução
- Backend aceita e aplica override de workingDirectory via metadata
- Pré-preenche com config do agente selecionado ao abrir modal
- Adiciona stopAll ao scheduler e limpa README
2026-02-28 02:20:09 -03:00
Frederico Castro
7a4ab2279d Corrigir classes CSS dos modais para convenção BEM (modal--lg) 2026-02-28 02:13:27 -03:00
Frederico Castro
7cbfcb2d0d Desabilitar cache de arquivos estáticos para evitar JS desatualizado 2026-02-28 02:10:41 -03:00
Frederico Castro
46a6ebc9dd Pré-preencher diretório de trabalho dos agentes com /home/projetos/ 2026-02-28 02:06:40 -03:00
Frederico Castro
f6bf7ce0ed Adicionar delegação automática entre agentes coordenadores 2026-02-28 01:59:38 -03:00
Frederico Castro
96733b55cd Adicionar file explorer para projetos criados pelos agentes 2026-02-28 01:38:26 -03:00
Frederico Castro
3ed285c9d1 Atualizar README com deploy automático, catálogo de tarefas e novos endpoints 2026-02-28 01:27:33 -03:00
Frederico Castro
6a21a4d711 Aumentar truncate dos cards de tarefas e adicionar margem no footer 2026-02-28 01:22:45 -03:00
Frederico Castro
bbfb9864bd Truncar textos dos cards de tarefas para layout padronizado
- Truncar descrição em 120 caracteres no JS com tooltip do texto completo
- Truncar nome com ellipsis via CSS
- Limitar descrição a 2 linhas com max-height
2026-02-28 00:59:25 -03:00
Frederico Castro
c29aa695d4 Merge branch 'main' of https://git.nitro-cloud.duckdns.org/fred/agents-orchestrator 2026-02-28 00:51:18 -03:00
Frederico Castro
4db351cb45 Truncar descrição dos cards de tarefas em 2 linhas 2026-02-28 00:47:22 -03:00
Frederico Castro
738ab12631 Desabilitar cache HTTP para arquivos HTML 2026-02-28 00:40:39 -03:00
Frederico Castro
1411c750e4 Adicionar landing page e redirecionar rota raiz
- Landing page profissional com hero, features, pricing (Starter/Pro/Enterprise), FAQ
- Animações no scroll, parallax, contadores animados, glassmorphism
- Dashboard movido para /app.html, landing page agora é a página inicial
- CTAs direcionam para /app.html
2026-02-28 00:32:05 -03:00
Frederico Castro
1ef5903da1 Rodar container como usuário node e corrigir resume do executor
- Dockerfile: usar USER node (UID 1000) para bypassPermissions funcionar
- Volumes mapeados para /home/node/ em vez de /root/
- Corrigir resume: voltar a usar -p para mensagens curtas de chat
- Manter stdin piping apenas em execute e summarize (prompts grandes)
2026-02-28 00:19:08 -03:00
Frederico Castro
d662860c61 Adicionar botão Interromper no terminal e corrigir botão Retomar
- Botão Interromper na toolbar do terminal para matar execuções ativas
- Endpoint POST /executions/cancel-all para cancelar agentes e pipelines
- Botão aparece/esconde automaticamente conforme execuções ativas
- Corrigir condição do botão Retomar para pipelines antigas sem failedAtStep
2026-02-28 00:03:44 -03:00
Frederico Castro
275d74b18c Corrigir E2BIG em pipelines, adicionar diretório de projeto e retomada
- Instalar Claude CLI no container Docker (npm install -g)
- Pipar prompt via stdin ao invés de argumento -p (resolve E2BIG)
- Adicionar campo workingDirectory na criação/edição de pipeline
- Pre-preencher com /home/projetos/ como base path
- Auto-criar diretório se não existir ao executar agente
- Salvar failedAtStep e lastStepInput quando pipeline falha
- Implementar retomada de pipeline a partir do passo que falhou
- Adicionar botão Retomar no histórico para pipelines com erro
- Configurar trust proxy para Express atrás de reverse proxy
2026-02-27 23:45:36 -03:00
17 changed files with 1506 additions and 295 deletions

View File

@@ -1,10 +1,12 @@
FROM node:22-alpine FROM node:22-alpine
RUN apk add --no-cache git docker-cli
WORKDIR /app WORKDIR /app
COPY package*.json ./ COPY package*.json ./
RUN npm ci --omit=dev RUN npm ci --omit=dev
RUN npm install -g @anthropic-ai/claude-code RUN npm install -g @anthropic-ai/claude-code
COPY . . COPY . .
RUN mkdir -p data && chown -R node:node /app 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 USER node
ENV HOST=0.0.0.0 ENV HOST=0.0.0.0
ENV PORT=3000 ENV PORT=3000

548
README.md
View File

@@ -1,339 +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 profissional. <h1 align="center">Agents Orchestrator</h1>
![Dashboard do Agents Orchestrator — visão geral com métricas, gráficos de execuções e custos, distribuição de status, ranking de agentes e taxa de sucesso](docs/dashboard.png) <p align="center">
<strong>Plataforma de orquestração de agentes IA com interface visual, pipelines automatizados e integração Git nativa.</strong>
</p>
## Acesso <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>
| Recurso | URL | <p align="center">
|---------|-----| <a href="#visao-geral">Visao Geral</a> &bull;
| **Aplicação** | https://agents.nitro-cloud.duckdns.org | <a href="#funcionalidades">Funcionalidades</a> &bull;
| **Repositório** | https://git.nitro-cloud.duckdns.org/fred/agents-orchestrator | <a href="#quick-start">Quick Start</a> &bull;
| **Portal Nitro Cloud** | https://nitro-cloud.duckdns.org | <a href="#arquitetura">Arquitetura</a> &bull;
<a href="#api">API</a> &bull;
<a href="#deploy">Deploy</a>
</p>
---
## Visao Geral
Agents Orchestrator e uma plataforma web para criar, configurar e executar agentes [Claude Code](https://docs.anthropic.com/en/docs/claude-code) de forma visual. Projetada para equipes de desenvolvimento e profissionais que precisam orquestrar multiplos agentes IA com diferentes especialidades, executar pipelines de trabalho automatizados e integrar com repositorios Git — tudo a partir de um painel administrativo elegante.
### Por que usar?
| Problema | Solucao |
|----------|---------|
| Gerenciar multiplos agentes via CLI e tedioso | Interface visual com cards, filtros e execucao com 1 clique |
| Saida do agente nao e visivel em tempo real | Terminal com streaming WebSocket chunk-a-chunk |
| Automatizar fluxos sequenciais e complexo | Pipelines visuais com aprovacao humana entre passos |
| Agentes nao tem acesso a repositorios remotos | Integracao Git nativa com clone, commit e push automatico |
| Deploy manual e propenso a erros | `git deploy` — um comando faz tudo |
---
## Funcionalidades ## Funcionalidades
### Gerenciamento de Agentes ### Agentes
- Crie agentes com nome, system prompt, modelo (Sonnet/Opus/Haiku), diretório de trabalho, ferramentas permitidas, modo de permissão e tags
- Ative, desative, edite, **duplique** ou exclua a qualquer momento
- Exporte/importe configurações completas em JSON
### Execução de Tarefas - Criacao com system prompt, modelo (Sonnet/Opus/Haiku), diretorio de trabalho, ferramentas permitidas e modo de permissao
- Execute tarefas sob demanda em qualquer agente ativo - Tags para organizacao e filtragem
- Templates rápidos incluídos (detecção de bugs, revisão OWASP, refatoração, testes, documentação, performance) - Duplicacao, importacao/exportacao JSON
- **Reexecute** tarefas que falharam ou foram canceladas com um clique - Delegacao automatica entre agentes (Tech Lead → PO)
- Continuação de conversa (resume session) no terminal - Agentes coordenadores recebem lista de agentes disponiveis injetada no prompt
### Terminal em Tempo Real ### Execucao
- Streaming chunk-a-chunk via WebSocket com indicador de conexão
- **Busca** no output do terminal com navegação entre ocorrências
- **Download** da saída completa como `.txt`
- **Copiar** saída para a área de transferência
- **Toggle de auto-scroll** para controle manual da rolagem
- Filtro por execução
### Dashboard com Gráficos - Modal de execucao com seletor de agente, tarefa, instrucoes adicionais e arquivos de contexto
- Métricas em tempo real (agentes, execuções, agendamentos, custo, webhooks) - **Seletor de repositorio Git** — escolha um repo do Gitea e o branch; o sistema clona/atualiza, executa e faz commit/push automatico
- **Gráfico de execuções** por dia (barras empilhadas sucesso/erro) - Templates rapidos: deteccao de bugs, revisao OWASP, refatoracao, testes, documentacao, performance
- **Gráfico de custo** por dia (linha com área preenchida) - Retry automatico configuravel por agente
- **Distribuição de status** (doughnut chart) - Continuacao de conversa (resume session)
- **Top 5 agentes** mais executados (barras horizontais) - Cancelamento individual ou em massa
- **Taxa de sucesso** geral (gauge com percentual)
- Seletor de período: 7, 14 ou 30 dias
### Agendamento Cron
- Agende tarefas recorrentes com expressões cron
- Presets incluídos (horário, diário, semanal, mensal)
- Histórico de execuções por agendamento com duração e custo
### Pipelines ### Pipelines
- Encadeie múltiplos agentes em fluxos sequenciais
- Saída de cada passo alimenta o próximo via template `{{input}}` - Encadeamento de multiplos agentes em fluxos sequenciais
- Portões de aprovação humana entre passos (human-in-the-loop) - Saida de cada passo alimenta o proximo via `{{input}}`
- Ideal para fluxos como "analisar → corrigir → testar" - **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 ### Webhooks
- Dispare execuções de agentes ou pipelines via HTTP externo
- **Edite** webhooks existentes (nome, alvo, status)
- **Teste** webhooks com um clique para verificar configuração
- Snippet cURL pronto para copiar
- Assinatura HMAC-SHA256 para validação de origem
### Notificações - Disparo de execucoes via HTTP externo
- **Centro de notificações** no header com badge de contagem - Edicao, teste com 1 clique e snippet cURL
- Notificações automáticas para execuções concluídas e com erro - Assinatura HMAC-SHA256
- **Notificações nativas do navegador** (Browser Notification API)
- Marcar como lidas / limpar todas
- Polling automático a cada 15 segundos
### Tema Claro/Escuro ### Notificacoes
- Toggle de tema no header com transições suaves
- Persistência da preferência em localStorage
- Terminal mantém fundo escuro em ambos os temas
### Exportação de Dados - Centro de notificacoes com badge de contagem
- **Exportar histórico** de execuções como CSV (UTF-8 com BOM) - Notificacoes nativas do navegador
- Exportar configuração de agentes em JSON - Polling automatico a cada 15 segundos
### Atalhos de Teclado ### Tema e UX
| Tecla | Ação |
|-------|------|
| `1``9` | Navegar entre seções |
| `N` | Novo agente |
| `Esc` | Fechar modal |
## Deploy - Tema claro/escuro com transicao suave
- Atalhos de teclado (`1`-`9` navegacao, `N` novo agente, `Esc` fechar modal)
- Exportacao de historico como CSV
A aplicação roda em container Docker na infraestrutura Nitro Cloud com HTTPS automático via Caddy + Let's Encrypt. ---
### Atualizar o sistema em produção ## Quick Start
### Requisitos
- Node.js >= 22
- [Claude CLI](https://docs.anthropic.com/en/docs/claude-code) instalado e autenticado
### Execucao local
```bash ```bash
# 1. Push das alterações para o Gitea git clone https://github.com/fredac100/agents-orchestrator.git
git push nitro main cd agents-orchestrator
npm install
# 2. Conectar no servidor npm start
ssh -p 2222 fred@192.168.1.151
# 3. Atualizar código e rebuild do container
cd ~/vps/apps/agents-orchestrator && git pull
cd ~/vps && docker compose up -d --build agents-orchestrator
``` ```
### Verificar status Acesse `http://localhost:3000`.
### Com Docker
```bash ```bash
ssh -p 2222 fred@192.168.1.151 "docker logs agents-orchestrator --tail 20" docker build -t agents-orchestrator .
docker run -p 3000:3000 \
-v $(pwd)/data:/app/data \
-v ~/.claude:/home/node/.claude \
agents-orchestrator
``` ```
### Reiniciar ---
```bash ## Arquitetura
ssh -p 2222 fred@192.168.1.151 "cd ~/vps && docker compose restart agents-orchestrator"
```
## Variáveis de Ambiente
| Variável | Descrição | Padrão |
|----------|-----------|--------|
| `PORT` | Porta do servidor | `3000` |
| `HOST` | Endereço de bind | `0.0.0.0` |
| `AUTH_TOKEN` | Token Bearer para autenticação da API | _(desabilitado)_ |
| `ALLOWED_ORIGIN` | Origin permitida para CORS | `https://agents.nitro-cloud.duckdns.org` |
| `WEBHOOK_SECRET` | Segredo HMAC para assinatura de webhooks | _(desabilitado)_ |
| `CLAUDE_BIN` | Caminho para o binário do Claude CLI | _(auto-detectado)_ |
| `REDIS_URL` | URL do Redis para cache L2 (opcional) | _(somente memória)_ |
## Como Funciona
### Criando um agente
1. Acesse https://agents.nitro-cloud.duckdns.org
2. Clique em **Novo Agente** no header ou na seção Agentes
3. Configure nome, system prompt, modelo e diretório de trabalho
4. 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. Marque passos que requerem aprovação humana antes de prosseguir
5. 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
## Infraestrutura
``` ```
Cliente (navegador) HTTPS (443)
|
▼ HTTPS (porta 443) [Caddy] ─── SSL automatico via DuckDNS
Caddy (reverse proxy + SSL automático) |
*.nitro-cloud.duckdns.org
▼ agents.nitro-cloud.duckdns.org |
Container Docker (agents-orchestrator) ┌──────────────┼──────────────┐
| | |
├── Express (HTTP API + arquivos estáticos) [agents.*] [git.*] [projeto.*]
└── WebSocket (streaming em tempo real) | | |
┌──────┴──────┐ [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)
``` ```
### Arquitetura Interna ### Estrutura do Projeto
``` ```
server.js Express + WebSocket + rate limiting + auth server.js HTTP + WebSocket + rate limiting + auth
src/ src/
routes/api.js API REST (/api/*)30+ endpoints routes/api.js API REST — 40+ endpoints
agents/ agents/
manager.js CRUD + orquestração + notificações manager.js CRUD + orquestracao + delegacao
executor.js Spawna o CLI claude como child_process executor.js Spawna o CLI claude como child_process
scheduler.js Agendamento cron (in-memory + persistido) scheduler.js Agendamento cron
pipeline.js Execução sequencial com aprovação humana pipeline.js Execucao sequencial + aprovacao humana
store/db.js Persistência em JSON com escrita atômica git-integration.js Clone, pull, commit, push automatico
cache/index.js Cache em 2 níveis (memória + Redis opcional) 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/ public/
index.html SPA single-page com hash routing app.html SPA com hash routing
css/styles.css Design system (dark/light themes) css/styles.css Design system (dark/light)
js/ js/
app.js Controlador principal + WebSocket + tema + routing app.js Controlador principal + WebSocket
api.js Client HTTP para a API api.js Client HTTP para a API
components/ UI por seção (15 módulos) components/ 16 modulos UI independentes
data/ scripts/
agents.json Agentes cadastrados deploy.sh Deploy automatizado via rsync + Docker
tasks.json Templates de tarefas data/ Persistencia em JSON (8 stores)
pipelines.json Pipelines configurados
schedules.json Agendamentos persistidos
executions.json Histórico de execuções (max 5000)
webhooks.json Configuração de webhooks
notifications.json Notificações do sistema
settings.json Configurações globais
``` ```
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. ---
## API REST ## API
### Agentes ### Agentes
| Método | Endpoint | Descrição | | Metodo | Endpoint | Descricao |
|--------|----------|-----------| |--------|----------|-----------|
| `GET` | `/api/agents` | Listar agentes | | `GET` | `/api/agents` | Listar agentes |
| `POST` | `/api/agents` | Criar agente | | `POST` | `/api/agents` | Criar agente |
| `GET` | `/api/agents/:id` | Obter agente | | `GET` | `/api/agents/:id` | Obter agente |
| `PUT` | `/api/agents/:id` | Atualizar agente | | `PUT` | `/api/agents/:id` | Atualizar agente |
| `DELETE` | `/api/agents/:id` | Excluir agente | | `DELETE` | `/api/agents/:id` | Excluir agente |
| `POST` | `/api/agents/:id/execute` | Executar tarefa no agente | | `POST` | `/api/agents/:id/execute` | Executar tarefa (aceita `repoName` e `repoBranch`) |
| `POST` | `/api/agents/:id/continue` | Continuar conversa (resume) | | `POST` | `/api/agents/:id/continue` | Continuar conversa |
| `POST` | `/api/agents/:id/cancel/:execId` | Cancelar execução | | `POST` | `/api/agents/:id/cancel/:execId` | Cancelar execucao |
| `GET` | `/api/agents/:id/export` | Exportar agente (JSON) | | `GET` | `/api/agents/:id/export` | Exportar agente |
| `POST` | `/api/agents/:id/duplicate` | Duplicar agente | | `POST` | `/api/agents/:id/duplicate` | Duplicar agente |
### Tarefas
| Método | Endpoint | Descrição |
|--------|----------|-----------|
| `GET` | `/api/tasks` | Listar tarefas |
| `POST` | `/api/tasks` | Criar tarefa |
| `PUT` | `/api/tasks/:id` | Atualizar tarefa |
| `DELETE` | `/api/tasks/:id` | Excluir tarefa |
### Agendamentos
| Método | Endpoint | Descrição |
|--------|----------|-----------|
| `GET` | `/api/schedules` | Listar agendamentos |
| `POST` | `/api/schedules` | Criar agendamento |
| `PUT` | `/api/schedules/:taskId` | Atualizar agendamento |
| `DELETE` | `/api/schedules/:taskId` | Remover agendamento |
| `GET` | `/api/schedules/history` | Histórico de execuções agendadas |
### Pipelines ### Pipelines
| Método | Endpoint | Descrição | | Metodo | Endpoint | Descricao |
|--------|----------|-----------| |--------|----------|-----------|
| `GET` | `/api/pipelines` | Listar pipelines | | `GET` | `/api/pipelines` | Listar pipelines |
| `POST` | `/api/pipelines` | Criar pipeline | | `POST` | `/api/pipelines` | Criar pipeline |
| `GET` | `/api/pipelines/:id` | Obter pipeline | | `POST` | `/api/pipelines/:id/execute` | Executar (aceita `repoName` e `repoBranch`) |
| `PUT` | `/api/pipelines/:id` | Atualizar pipeline |
| `DELETE` | `/api/pipelines/:id` | Excluir pipeline |
| `POST` | `/api/pipelines/:id/execute` | Executar pipeline |
| `POST` | `/api/pipelines/:id/cancel` | Cancelar pipeline |
| `POST` | `/api/pipelines/:id/approve` | Aprovar passo pendente | | `POST` | `/api/pipelines/:id/approve` | Aprovar passo pendente |
| `POST` | `/api/pipelines/:id/reject` | Rejeitar passo pendente | | `POST` | `/api/pipelines/:id/reject` | Rejeitar passo |
| `POST` | `/api/pipelines/resume/:execId` | Retomar pipeline falho |
### Webhooks ### Repositorios
| Método | Endpoint | Descrição | | Metodo | Endpoint | Descricao |
|--------|----------|-----------| |--------|----------|-----------|
| `GET` | `/api/webhooks` | Listar webhooks | | `GET` | `/api/repos` | Listar repositorios do Gitea |
| `POST` | `/api/webhooks` | Criar webhook | | `GET` | `/api/repos/:name/branches` | Listar branches de um repo |
| `PUT` | `/api/webhooks/:id` | Atualizar webhook |
| `DELETE` | `/api/webhooks/:id` | Excluir webhook |
| `POST` | `/api/webhooks/:id/test` | Testar webhook |
### Execuções e Histórico ### Arquivos e Publicacao
| Método | Endpoint | Descrição | | Metodo | Endpoint | Descricao |
|--------|----------|-----------| |--------|----------|-----------|
| `GET` | `/api/executions/active` | Execuções em andamento | | `GET` | `/api/files` | Listar diretorio |
| `GET` | `/api/executions/history` | Histórico paginado com filtros | | `GET` | `/api/files/download` | Download de arquivo |
| `GET` | `/api/executions/recent` | Execuções recentes | | `GET` | `/api/files/download-folder` | Download de pasta (.tar.gz) |
| `GET` | `/api/executions/export` | Exportar histórico como CSV | | `DELETE` | `/api/files` | Excluir arquivo ou pasta |
| `GET` | `/api/executions/:id` | Detalhes de uma execução | | `POST` | `/api/files/publish` | Publicar projeto (repo + deploy + subdominio) |
| `DELETE` | `/api/executions/:id` | Excluir execução do histórico |
| `POST` | `/api/executions/:id/retry` | Reexecutar execução falha |
| `DELETE` | `/api/executions` | Limpar histórico |
### Notificações
| Método | Endpoint | Descrição |
|--------|----------|-----------|
| `GET` | `/api/notifications` | Listar notificações |
| `POST` | `/api/notifications/:id/read` | Marcar como lida |
| `POST` | `/api/notifications/read-all` | Marcar todas como lidas |
| `DELETE` | `/api/notifications` | Limpar notificações |
### Sistema ### Sistema
| Método | Endpoint | Descrição | | Metodo | Endpoint | Descricao |
|--------|----------|-----------| |--------|----------|-----------|
| `GET` | `/api/health` | Health check (sem auth) | | `GET` | `/api/health` | Health check |
| `GET` | `/api/system/status` | Status geral do sistema | | `GET` | `/api/system/status` | Status geral |
| `GET` | `/api/system/info` | Informações do servidor | | `GET` | `/api/stats/costs` | Estatisticas de custo |
| `GET` | `/api/stats/costs` | Estatísticas de custo | | `GET` | `/api/stats/charts` | Dados para graficos |
| `GET` | `/api/stats/charts` | Dados para gráficos do dashboard |
| `GET/PUT` | `/api/settings` | Configurações globais | ---
## Deploy
### Deploy automatico
```bash
git deploy
```
O alias executa `scripts/deploy.sh` que automaticamente:
1. Push para GitHub e Gitea
2. Backup dos dados no VPS
3. Sincronizacao via rsync
4. Correcao de permissoes
5. Rebuild do container Docker
6. Verificacao de integridade
7. Limpeza de backups antigos (mantem 3)
```bash
# Apenas deploy sem push
bash scripts/deploy.sh --skip-push
```
### Variaveis de Ambiente
| Variavel | Descricao | Padrao |
|----------|-----------|--------|
| `PORT` | Porta do servidor | `3000` |
| `HOST` | Endereco de bind | `0.0.0.0` |
| `AUTH_TOKEN` | Bearer token para auth da API | _(desabilitado)_ |
| `ALLOWED_ORIGIN` | Origin para CORS | `http://localhost:3000` |
| `WEBHOOK_SECRET` | Segredo HMAC para webhooks | _(desabilitado)_ |
| `GITEA_URL` | URL interna do Gitea | `http://gitea:3000` |
| `GITEA_USER` | Usuario do Gitea | `fred` |
| `GITEA_PASS` | Senha do Gitea | _(obrigatorio para Git)_ |
| `DOMAIN` | Dominio base para subdominios | `nitro-cloud.duckdns.org` |
| `CLAUDE_BIN` | Caminho do CLI Claude | _(auto-detectado)_ |
| `REDIS_URL` | Redis para cache L2 | _(somente memoria)_ |
---
## Seguranca
- HTTPS via Caddy com certificado wildcard Let's Encrypt
- Autenticacao Bearer token com timing-safe comparison
- Rate limiting: 100 req/min (API), 30 req/min (webhooks)
- CORS restrito a origin configurada
- Correlation IDs em todas as requisicoes
- Escrita atomica em disco (temp + rename)
- Sanitizacao de prompts (NUL, controle, limite 50K chars)
- HMAC-SHA256 para webhooks recebidos
- Protecao contra path traversal no file explorer
---
## Eventos WebSocket ## Eventos WebSocket
O servidor envia eventos tipados via WebSocket que o frontend renderiza no terminal: | Evento | Descricao |
| Evento | Descrição |
|--------|-----------| |--------|-----------|
| `execution_output` | Chunk de texto da saída do agente | | `execution_output` | Chunk de saida do agente |
| `execution_complete` | Execução finalizada com resultado | | `execution_complete` | Execucao finalizada |
| `execution_error` | Erro durante execução | | `execution_error` | Erro durante execucao |
| `pipeline_step_start` | Início de um passo do pipeline | | `execution_retry` | Tentativa de retry |
| `pipeline_step_complete` | Passo do pipeline concluído | | `pipeline_step_start` | Inicio de passo |
| `pipeline_step_complete` | Passo concluido |
| `pipeline_complete` | Pipeline finalizado | | `pipeline_complete` | Pipeline finalizado |
| `pipeline_error` | Erro em um passo do pipeline | | `pipeline_error` | Erro no pipeline |
| `pipeline_approval_required` | Passo aguardando aprovação humana | | `pipeline_approval_required` | Aguardando aprovacao humana |
| `report_generated` | Relatorio gerado |
## Segurança ---
- **HTTPS** via Caddy com certificado Let's Encrypt automático
- **Autenticação** via Bearer token (variável `AUTH_TOKEN`)
- **Rate limiting** — 100 requisições por minuto por IP
- **CORS** restrito à origin configurada
- **Timing-safe comparison** para tokens de autenticação e webhooks
- **Correlation IDs** em todas as requisições para rastreabilidade
- **Escrita atômica** em disco (temp + rename) para integridade de dados
- **Sanitização** de prompts (NUL, caracteres de controle, limite de 50.000 chars)
- **Assinatura HMAC-SHA256** para webhooks recebidos
## Stack ## Stack
- **Backend**: Node.js, Express, WebSocket (ws), node-cron, uuid, express-rate-limit | Camada | Tecnologias |
- **Frontend**: HTML, CSS, JavaScript vanilla (sem framework, sem bundler) |--------|-------------|
- **Gráficos**: Chart.js 4.x | **Backend** | Node.js 22, Express, WebSocket (ws), node-cron, uuid |
- **Ícones**: Lucide | **Frontend** | HTML, CSS, JavaScript vanilla — sem framework, sem bundler |
- **Fontes**: Inter (UI), JetBrains Mono (código/terminal) | **Graficos** | Chart.js 4.x |
- **Persistência**: Arquivos JSON em disco com escrita atômica | **Icones** | Lucide |
- **Cache**: In-memory com suporte opcional a Redis (ioredis) | **Fontes** | Inter (UI), JetBrains Mono (terminal) |
- **Infraestrutura**: Docker + Caddy + DuckDNS + Let's Encrypt | **Persistencia** | JSON em disco com escrita atomica |
| **Cache** | In-memory + Redis opcional (ioredis) |
| **Infra** | Docker, Caddy, DuckDNS, Let's Encrypt |
| **Git** | Gitea (self-hosted) |
## Licença ---
## Licenca
MIT MIT
---
<p align="center">
<sub>Desenvolvido por <a href="https://nitro-cloud.duckdns.org">Nitro Cloud</a></sub>
</p>

View File

@@ -72,6 +72,12 @@
<span>Histórico</span> <span>Histórico</span>
</a> </a>
</li> </li>
<li class="sidebar-nav-item">
<a href="#" class="sidebar-nav-link" data-section="files">
<i data-lucide="folder-open"></i>
<span>Projetos</span>
</a>
</li>
<li class="sidebar-nav-item"> <li class="sidebar-nav-item">
<a href="#" class="sidebar-nav-link" data-section="settings"> <a href="#" class="sidebar-nav-link" data-section="settings">
<i data-lucide="settings"></i> <i data-lucide="settings"></i>
@@ -578,6 +584,10 @@
<div id="history-pagination"></div> <div id="history-pagination"></div>
</section> </section>
<section id="files" class="section" aria-label="Projetos" hidden>
<div id="files-container"></div>
</section>
<section id="settings" class="section" aria-label="Configurações" hidden> <section id="settings" class="section" aria-label="Configurações" hidden>
<div class="settings-grid"> <div class="settings-grid">
<div class="card"> <div class="card">
@@ -798,7 +808,7 @@
class="input" class="input"
id="agent-workdir" id="agent-workdir"
name="workdir" name="workdir"
placeholder="/home/fred/projetos" value="/home/projetos/"
autocomplete="off" autocomplete="off"
/> />
</div> </div>
@@ -842,6 +852,16 @@
</div> </div>
</div> </div>
<div class="form-row">
<div class="form-group">
<label class="form-label" for="agent-delegate-to">Delegar para (auto)</label>
<select class="select" id="agent-delegate-to" name="delegateTo">
<option value="">Nenhum</option>
</select>
<p class="form-hint">Ao concluir, delega automaticamente o resultado para este agente.</p>
</div>
</div>
<div class="form-row"> <div class="form-row">
<div class="form-group"> <div class="form-group">
<label class="form-label">Retry em caso de falha</label> <label class="form-label">Retry em caso de falha</label>
@@ -978,6 +998,30 @@
></textarea> ></textarea>
</div> </div>
<div class="form-group">
<label class="form-label" for="execute-repo">Repositório Git</label>
<div class="repo-selector">
<select class="select" id="execute-repo">
<option value="">Nenhum (usar diretório manual)</option>
</select>
<select class="select" id="execute-repo-branch" style="display:none">
<option value="">Branch padrão</option>
</select>
</div>
<p class="form-hint">Se selecionado, o agente trabalha no repositório e faz commit/push automático ao finalizar.</p>
</div>
<div class="form-group" id="execute-workdir-group">
<label class="form-label" for="execute-workdir">Diretório de Trabalho</label>
<input
type="text"
class="input"
id="execute-workdir"
value="/home/projetos/"
autocomplete="off"
/>
</div>
<div class="form-group"> <div class="form-group">
<label class="form-label">Arquivos de Contexto</label> <label class="form-label">Arquivos de Contexto</label>
<div class="dropzone" id="execute-dropzone"> <div class="dropzone" id="execute-dropzone">
@@ -1201,6 +1245,19 @@
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label" for="pipeline-execute-repo">Repositório Git</label>
<div class="repo-selector">
<select class="select" id="pipeline-execute-repo">
<option value="">Nenhum (usar diretório manual)</option>
</select>
<select class="select" id="pipeline-execute-repo-branch" style="display:none">
<option value="">Branch padrão</option>
</select>
</div>
<p class="form-hint">Se selecionado, todos os agentes trabalham no repositório e o commit/push é automático ao final.</p>
</div>
<div class="form-group" id="pipeline-execute-workdir-group">
<label class="form-label" for="pipeline-execute-workdir">Diretório de Trabalho (opcional)</label> <label class="form-label" for="pipeline-execute-workdir">Diretório de Trabalho (opcional)</label>
<input <input
type="text" type="text"
@@ -1377,6 +1434,7 @@
<script src="js/components/history.js"></script> <script src="js/components/history.js"></script>
<script src="js/components/webhooks.js"></script> <script src="js/components/webhooks.js"></script>
<script src="js/components/notifications.js"></script> <script src="js/components/notifications.js"></script>
<script src="js/components/files.js"></script>
<script src="js/app.js"></script> <script src="js/app.js"></script>
<script> <script>
Utils.refreshIcons(); Utils.refreshIcons();

View File

@@ -966,15 +966,22 @@ textarea {
transform: scale(1) translateY(0); transform: scale(1) translateY(0);
} }
.modal-sm { .modal-sm,
.modal--sm {
max-width: 400px; max-width: 400px;
} }
.modal-lg { .modal--md {
max-width: 700px;
}
.modal-lg,
.modal--lg {
max-width: 800px; max-width: 800px;
} }
.modal-xl { .modal-xl,
.modal--xl {
max-width: 1000px; max-width: 1000px;
} }
@@ -1088,29 +1095,32 @@ textarea {
flex: 1; flex: 1;
padding: 16px; padding: 16px;
overflow-y: auto; overflow-y: auto;
overflow-x: hidden;
font-family: 'JetBrains Mono', monospace; font-family: 'JetBrains Mono', monospace;
font-size: 13px; font-size: 13px;
line-height: 1.7; line-height: 1.7;
min-width: 0;
} }
.terminal-line { .terminal-line {
padding: 2px 0; padding: 2px 0;
display: flex; display: grid;
align-items: flex-start; grid-template-columns: auto 1fr;
gap: 12px;
} }
.terminal-line .timestamp { .terminal-line .timestamp {
color: var(--text-muted); color: var(--text-muted);
margin-right: 12px;
flex-shrink: 0;
font-size: 12px; font-size: 12px;
padding-top: 1px; padding-top: 1px;
white-space: nowrap;
} }
.terminal-line .content { .terminal-line .content {
color: #c8c8d8; color: #c8c8d8;
word-break: break-all; word-break: break-word;
flex: 1; overflow-wrap: anywhere;
white-space: pre-wrap;
} }
.terminal-line.error .content { .terminal-line.error .content {
@@ -3370,9 +3380,14 @@ tbody tr:hover td {
font-size: 13px; font-size: 13px;
color: var(--text-secondary); color: var(--text-secondary);
line-height: 1.5; line-height: 1.5;
display: -webkit-box;
-webkit-line-clamp: 4;
-webkit-box-orient: vertical;
overflow: hidden;
} }
.task-card-footer { .task-card-footer {
margin-top: 12px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
@@ -5161,3 +5176,185 @@ body, .sidebar, .header, .card, .modal-content, .input, .select, textarea, .metr
padding-top: 16px; padding-top: 16px;
border-top: 1px solid var(--border-primary); border-top: 1px solid var(--border-primary);
} }
/* File Explorer */
#files-container {
display: flex;
flex-direction: column;
gap: 16px;
}
.files-breadcrumb {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
padding: 12px 16px;
background: var(--bg-card);
border: 1px solid var(--border-primary);
border-radius: 8px;
font-size: 13px;
}
.files-breadcrumb-link {
display: inline-flex;
align-items: center;
gap: 4px;
color: var(--accent);
text-decoration: none;
font-weight: 500;
transition: color 0.2s;
}
.files-breadcrumb-link:hover {
color: var(--accent-hover);
}
.files-breadcrumb-link:last-child {
color: var(--text-primary);
pointer-events: none;
}
.files-breadcrumb-sep {
color: var(--text-muted);
user-select: none;
}
.files-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
}
.files-count {
font-size: 13px;
color: var(--text-muted);
}
.files-table-wrapper {
background: var(--bg-card);
border: 1px solid var(--border-primary);
border-radius: 8px;
overflow: hidden;
}
.files-table {
width: 100%;
border-collapse: collapse;
}
.files-table thead {
background: var(--bg-tertiary);
}
.files-table th {
text-align: left;
padding: 10px 16px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-muted);
border-bottom: 1px solid var(--border-primary);
}
.files-table td {
padding: 10px 16px;
font-size: 13px;
color: var(--text-secondary);
border-bottom: 1px solid var(--border-primary);
}
.files-row:last-child td {
border-bottom: none;
}
.files-row:hover {
background: var(--bg-card-hover);
}
.files-td-name {
color: var(--text-primary);
}
.files-th-size,
.files-td-size {
width: 100px;
text-align: right;
}
.files-th-date,
.files-td-date {
width: 160px;
}
.files-th-actions,
.files-td-actions {
width: 120px;
text-align: right;
}
.files-td-actions {
display: flex;
gap: 4px;
justify-content: flex-end;
align-items: center;
}
.repo-selector { display: flex; gap: 8px; }
.repo-selector .select { flex: 1; }
.btn-publish { color: var(--success); }
.btn-publish:hover { background: rgba(16, 185, 129, 0.1); }
.publish-result { display: flex; flex-direction: column; gap: 12px; padding: 8px 0; }
.publish-result-item { font-size: 14px; }
.publish-result-item a { color: var(--accent); text-decoration: underline; }
.files-entry-link {
display: inline-flex;
align-items: center;
gap: 8px;
text-decoration: none;
color: inherit;
}
.files-entry-dir {
color: var(--text-primary);
font-weight: 500;
cursor: pointer;
transition: color 0.2s;
}
.files-entry-dir:hover {
color: var(--accent);
}
.files-entry-file {
color: var(--text-secondary);
}
.files-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
padding: 80px 20px;
background: var(--bg-card);
border: 1px solid var(--border-primary);
border-radius: 8px;
color: var(--text-muted);
font-size: 14px;
}
@media (max-width: 768px) {
.files-th-date,
.files-td-date {
display: none;
}
.files-th-size,
.files-td-size {
width: 70px;
}
}

View File

@@ -38,8 +38,10 @@ const API = {
create(data) { return API.request('POST', '/agents', data); }, create(data) { return API.request('POST', '/agents', data); },
update(id, data) { return API.request('PUT', `/agents/${id}`, data); }, update(id, data) { return API.request('PUT', `/agents/${id}`, data); },
delete(id) { return API.request('DELETE', `/agents/${id}`); }, delete(id) { return API.request('DELETE', `/agents/${id}`); },
execute(id, task, instructions, contextFiles) { execute(id, task, instructions, contextFiles, workingDirectory, repoName, repoBranch) {
const body = { task, instructions }; 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; if (contextFiles && contextFiles.length > 0) body.contextFiles = contextFiles;
return API.request('POST', `/agents/${id}/execute`, body); return API.request('POST', `/agents/${id}/execute`, body);
}, },
@@ -82,9 +84,10 @@ const API = {
create(data) { return API.request('POST', '/pipelines', data); }, create(data) { return API.request('POST', '/pipelines', data); },
update(id, data) { return API.request('PUT', `/pipelines/${id}`, data); }, update(id, data) { return API.request('PUT', `/pipelines/${id}`, data); },
delete(id) { return API.request('DELETE', `/pipelines/${id}`); }, delete(id) { return API.request('DELETE', `/pipelines/${id}`); },
execute(id, input, workingDirectory, contextFiles) { execute(id, input, workingDirectory, contextFiles, repoName, repoBranch) {
const body = { input }; const body = { input };
if (workingDirectory) body.workingDirectory = workingDirectory; if (repoName) { body.repoName = repoName; if (repoBranch) body.repoBranch = repoBranch; }
else if (workingDirectory) body.workingDirectory = workingDirectory;
if (contextFiles && contextFiles.length > 0) body.contextFiles = contextFiles; if (contextFiles && contextFiles.length > 0) body.contextFiles = contextFiles;
return API.request('POST', `/pipelines/${id}/execute`, body); return API.request('POST', `/pipelines/${id}/execute`, body);
}, },
@@ -141,6 +144,17 @@ const API = {
}, },
}, },
repos: {
list() { return API.request('GET', '/repos'); },
branches(name) { return API.request('GET', `/repos/${encodeURIComponent(name)}/branches`); },
},
files: {
list(path) { return API.request('GET', `/files${path ? '?path=' + encodeURIComponent(path) : ''}`); },
delete(path) { return API.request('DELETE', `/files?path=${encodeURIComponent(path)}`); },
publish(path) { return API.request('POST', '/files/publish', { path }); },
},
reports: { reports: {
list() { return API.request('GET', '/reports'); }, list() { return API.request('GET', '/reports'); },
get(filename) { return API.request('GET', `/reports/${encodeURIComponent(filename)}`); }, get(filename) { return API.request('GET', `/reports/${encodeURIComponent(filename)}`); },

View File

@@ -17,10 +17,11 @@ const App = {
webhooks: 'Webhooks', webhooks: 'Webhooks',
terminal: 'Terminal', terminal: 'Terminal',
history: 'Histórico', history: 'Histórico',
files: 'Projetos',
settings: 'Configurações', settings: 'Configurações',
}, },
sections: ['dashboard', 'agents', 'tasks', 'schedules', 'pipelines', 'webhooks', 'terminal', 'history', 'settings'], sections: ['dashboard', 'agents', 'tasks', 'schedules', 'pipelines', 'webhooks', 'terminal', 'history', 'files', 'settings'],
init() { init() {
if (App._initialized) return; if (App._initialized) return;
@@ -36,6 +37,7 @@ const App = {
App._executeDropzone = Utils.initDropzone('execute-dropzone', 'execute-files', 'execute-file-list'); 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._pipelineDropzone = Utils.initDropzone('pipeline-execute-dropzone', 'pipeline-execute-files', 'pipeline-execute-file-list');
App._initRepoSelectors();
const initialSection = location.hash.replace('#', '') || 'dashboard'; const initialSection = location.hash.replace('#', '') || 'dashboard';
App.navigateTo(App.sections.includes(initialSection) ? initialSection : 'dashboard'); App.navigateTo(App.sections.includes(initialSection) ? initialSection : 'dashboard');
@@ -113,6 +115,7 @@ const App = {
case 'pipelines': await PipelinesUI.load(); break; case 'pipelines': await PipelinesUI.load(); break;
case 'webhooks': await WebhooksUI.load(); break; case 'webhooks': await WebhooksUI.load(); break;
case 'history': await HistoryUI.load(); break; case 'history': await HistoryUI.load(); break;
case 'files': await FilesUI.load(); break;
case 'settings': await SettingsUI.load(); break; case 'settings': await SettingsUI.load(); break;
} }
} catch (err) { } catch (err) {
@@ -763,6 +766,20 @@ const App = {
} }
}); });
document.getElementById('files-container')?.addEventListener('click', (e) => {
const el = e.target.closest('[data-action]');
if (!el) return;
e.preventDefault();
const { action, path } = el.dataset;
switch (action) {
case 'navigate-files': FilesUI.navigate(path || ''); break;
case 'download-file': FilesUI.downloadFile(path); break;
case 'download-folder': FilesUI.downloadFolder(path); break;
case 'publish-project': FilesUI.publishProject(path); break;
case 'delete-entry': FilesUI.deleteEntry(path, el.dataset.entryType); break;
}
});
document.getElementById('pipeline-steps-container')?.addEventListener('click', (e) => { document.getElementById('pipeline-steps-container')?.addEventListener('click', (e) => {
const btn = e.target.closest('[data-step-action]'); const btn = e.target.closest('[data-step-action]');
if (!btn) return; if (!btn) return;
@@ -844,6 +861,61 @@ const App = {
}); });
}, },
_reposCache: null,
async _loadRepos(selectId) {
const select = document.getElementById(selectId);
if (!select) return;
try {
if (!App._reposCache) App._reposCache = await API.repos.list();
const current = select.value;
select.innerHTML = '<option value="">Nenhum (usar diretório manual)</option>';
App._reposCache.forEach(r => {
select.insertAdjacentHTML('beforeend',
`<option value="${Utils.escapeHtml(r.name)}">${Utils.escapeHtml(r.name)}${r.description ? ' — ' + Utils.escapeHtml(r.description.slice(0, 40)) : ''}</option>`
);
});
if (current) select.value = current;
} catch { }
},
_initRepoSelectors() {
const pairs = [
['execute-repo', 'execute-repo-branch', 'execute-workdir-group'],
['pipeline-execute-repo', 'pipeline-execute-repo-branch', 'pipeline-execute-workdir-group'],
];
pairs.forEach(([repoId, branchId, workdirGroupId]) => {
const repoSelect = document.getElementById(repoId);
const branchSelect = document.getElementById(branchId);
const workdirGroup = document.getElementById(workdirGroupId);
if (!repoSelect) return;
repoSelect.addEventListener('change', async () => {
const repoName = repoSelect.value;
if (repoName) {
if (workdirGroup) workdirGroup.style.display = 'none';
if (branchSelect) {
branchSelect.style.display = '';
branchSelect.innerHTML = '<option value="">Branch padrão</option>';
try {
const branches = await API.repos.branches(repoName);
branches.forEach(b => {
branchSelect.insertAdjacentHTML('beforeend', `<option value="${Utils.escapeHtml(b)}">${Utils.escapeHtml(b)}</option>`);
});
} catch { }
}
} else {
if (workdirGroup) workdirGroup.style.display = '';
if (branchSelect) branchSelect.style.display = 'none';
}
});
repoSelect.addEventListener('focus', () => {
if (repoSelect.options.length <= 1) App._loadRepos(repoId);
});
});
},
async _handleExecute() { async _handleExecute() {
const agentId = document.getElementById('execute-agent-select')?.value const agentId = document.getElementById('execute-agent-select')?.value
|| document.getElementById('execute-agent-id')?.value; || document.getElementById('execute-agent-id')?.value;
@@ -860,6 +932,9 @@ const App = {
} }
const instructions = document.getElementById('execute-instructions')?.value.trim() || ''; const instructions = document.getElementById('execute-instructions')?.value.trim() || '';
const workingDirectory = document.getElementById('execute-workdir')?.value.trim() || '';
const repoName = document.getElementById('execute-repo')?.value || '';
const repoBranch = document.getElementById('execute-repo-branch')?.value || '';
try { try {
const selectEl = document.getElementById('execute-agent-select'); const selectEl = document.getElementById('execute-agent-select');
@@ -876,7 +951,7 @@ const App = {
Terminal.disableChat(); Terminal.disableChat();
App._lastAgentName = agentName; App._lastAgentName = agentName;
await API.agents.execute(agentId, task, instructions, contextFiles); await API.agents.execute(agentId, task, instructions, contextFiles, workingDirectory, repoName, repoBranch);
if (dropzone) dropzone.reset(); if (dropzone) dropzone.reset();
Modal.close('execute-modal-overlay'); Modal.close('execute-modal-overlay');

View File

@@ -180,6 +180,9 @@ const AgentsUI = {
const maxTurns = document.getElementById('agent-max-turns'); const maxTurns = document.getElementById('agent-max-turns');
if (maxTurns) maxTurns.value = '0'; if (maxTurns) maxTurns.value = '0';
const workdir = document.getElementById('agent-workdir');
if (workdir) workdir.value = '/home/projetos/';
const permissionMode = document.getElementById('agent-permission-mode'); const permissionMode = document.getElementById('agent-permission-mode');
if (permissionMode) permissionMode.value = ''; if (permissionMode) permissionMode.value = '';
@@ -192,6 +195,8 @@ const AgentsUI = {
const retryMax = document.getElementById('agent-retry-max'); const retryMax = document.getElementById('agent-retry-max');
if (retryMax) retryMax.value = '3'; if (retryMax) retryMax.value = '3';
AgentsUI._populateDelegateSelect('');
const secretsSection = document.getElementById('agent-secrets-section'); const secretsSection = document.getElementById('agent-secrets-section');
if (secretsSection) secretsSection.hidden = true; if (secretsSection) secretsSection.hidden = true;
@@ -250,6 +255,8 @@ const AgentsUI = {
const retryMax = document.getElementById('agent-retry-max'); const retryMax = document.getElementById('agent-retry-max');
if (retryMax) retryMax.value = (agent.config && agent.config.maxRetries) || '3'; if (retryMax) retryMax.value = (agent.config && agent.config.maxRetries) || '3';
AgentsUI._populateDelegateSelect(agent.config?.delegateTo || '', agent.id);
const secretsSection = document.getElementById('agent-secrets-section'); const secretsSection = document.getElementById('agent-secrets-section');
if (secretsSection) secretsSection.hidden = false; if (secretsSection) secretsSection.hidden = false;
@@ -296,6 +303,7 @@ const AgentsUI = {
permissionMode: document.getElementById('agent-permission-mode')?.value || '', permissionMode: document.getElementById('agent-permission-mode')?.value || '',
retryOnFailure: !!document.getElementById('agent-retry-toggle')?.checked, retryOnFailure: !!document.getElementById('agent-retry-toggle')?.checked,
maxRetries: parseInt(document.getElementById('agent-retry-max')?.value) || 3, maxRetries: parseInt(document.getElementById('agent-retry-max')?.value) || 3,
delegateTo: document.getElementById('agent-delegate-to')?.value || '',
}, },
}; };
@@ -358,8 +366,19 @@ const AgentsUI = {
if (App._executeDropzone) App._executeDropzone.reset(); if (App._executeDropzone) App._executeDropzone.reset();
const selectedAgent = allAgents.find(a => a.id === agentId);
const workdirEl = document.getElementById('execute-workdir');
if (workdirEl) {
workdirEl.value = (selectedAgent?.config?.workingDirectory) || '/home/projetos/';
}
AgentsUI._loadSavedTasks(); AgentsUI._loadSavedTasks();
const repoSelect = document.getElementById('execute-repo');
if (repoSelect) { repoSelect.value = ''; repoSelect.dispatchEvent(new Event('change')); }
App._reposCache = null;
App._loadRepos('execute-repo');
Modal.open('execute-modal-overlay'); Modal.open('execute-modal-overlay');
} catch (err) { } catch (err) {
Toast.error(`Erro ao abrir modal de execução: ${err.message}`); Toast.error(`Erro ao abrir modal de execução: ${err.message}`);
@@ -468,6 +487,14 @@ const AgentsUI = {
}); });
}, },
_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() { _setupModalListeners() {
const retryToggle = document.getElementById('agent-retry-toggle'); const retryToggle = document.getElementById('agent-retry-toggle');
const retryMaxGroup = document.getElementById('agent-retry-max-group'); const retryMaxGroup = document.getElementById('agent-retry-max-group');

View File

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

View File

@@ -476,6 +476,11 @@ const PipelinesUI = {
if (App._pipelineDropzone) App._pipelineDropzone.reset(); if (App._pipelineDropzone) App._pipelineDropzone.reset();
const repoSelect = document.getElementById('pipeline-execute-repo');
if (repoSelect) { repoSelect.value = ''; repoSelect.dispatchEvent(new Event('change')); }
App._reposCache = null;
App._loadRepos('pipeline-execute-repo');
Modal.open('pipeline-execute-modal-overlay'); Modal.open('pipeline-execute-modal-overlay');
}, },
@@ -503,7 +508,9 @@ const PipelinesUI = {
contextFiles = uploadResult.files; contextFiles = uploadResult.files;
} }
await API.pipelines.execute(pipelineId, input, workingDirectory, contextFiles); 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(); if (dropzone) dropzone.reset();
Modal.close('pipeline-execute-modal-overlay'); Modal.close('pipeline-execute-modal-overlay');
App.navigateTo('terminal'); App.navigateTo('terminal');

View File

@@ -68,7 +68,7 @@ const TasksUI = {
<h4 class="task-card-name">${Utils.escapeHtml(task.name)}</h4> <h4 class="task-card-name">${Utils.escapeHtml(task.name)}</h4>
<span class="badge ${categoryClass}">${Utils.escapeHtml(categoryLabel)}</span> <span class="badge ${categoryClass}">${Utils.escapeHtml(categoryLabel)}</span>
</div> </div>
${task.description ? `<p class="task-card-description">${Utils.escapeHtml(task.description)}</p>` : ''} ${task.description ? `<p class="task-card-description" title="${Utils.escapeHtml(task.description)}">${Utils.escapeHtml(task.description.length > 240 ? task.description.slice(0, 240) + '…' : task.description)}</p>` : ''}
<div class="task-card-footer"> <div class="task-card-footer">
<span class="task-card-date"> <span class="task-card-date">
<i data-lucide="calendar"></i> <i data-lucide="calendar"></i>

79
scripts/deploy.sh Executable file
View File

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

View File

@@ -12,6 +12,7 @@ import apiRouter, { setWsBroadcast, setWsBroadcastTo, hookRouter } from './src/r
import * as manager from './src/agents/manager.js'; import * as manager from './src/agents/manager.js';
import { setGlobalBroadcast } from './src/agents/manager.js'; import { setGlobalBroadcast } from './src/agents/manager.js';
import { cancelAllExecutions } from './src/agents/executor.js'; import { cancelAllExecutions } from './src/agents/executor.js';
import { stopAll as stopAllSchedules } from './src/agents/scheduler.js';
import { flushAllStores } from './src/store/db.js'; import { flushAllStores } from './src/store/db.js';
const __dirname = dirname(fileURLToPath(import.meta.url)); const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -114,13 +115,9 @@ app.use('/hook', hookLimiter, verifyWebhookSignature, hookRouter);
app.use(express.static(join(__dirname, 'public'), { app.use(express.static(join(__dirname, 'public'), {
etag: true, etag: true,
setHeaders(res, filePath) { setHeaders(res, filePath) {
if (filePath.endsWith('.html')) { res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); res.setHeader('Pragma', 'no-cache');
res.setHeader('Pragma', 'no-cache'); res.setHeader('Expires', '0');
res.setHeader('Expires', '0');
} else {
res.setHeader('Cache-Control', 'public, max-age=3600');
}
}, },
})); }));
app.use('/api', apiRouter); app.use('/api', apiRouter);
@@ -177,6 +174,9 @@ setGlobalBroadcast(broadcast);
function gracefulShutdown(signal) { function gracefulShutdown(signal) {
console.log(`\nSinal ${signal} recebido. Encerrando servidor...`); console.log(`\nSinal ${signal} recebido. Encerrando servidor...`);
stopAllSchedules();
console.log('Agendamentos parados.');
cancelAllExecutions(); cancelAllExecutions();
console.log('Execuções ativas canceladas.'); console.log('Execuções ativas canceladas.');
@@ -185,15 +185,23 @@ function gracefulShutdown(signal) {
clearInterval(wsHeartbeat); clearInterval(wsHeartbeat);
httpServer.close(() => { for (const client of wss.clients) {
console.log('Servidor HTTP encerrado.'); client.close(1001, 'Servidor encerrando');
process.exit(0); }
connectedClients.clear();
wss.close(() => {
console.log('WebSocket server encerrado.');
httpServer.close(() => {
console.log('Servidor HTTP encerrado.');
process.exit(0);
});
}); });
setTimeout(() => { setTimeout(() => {
console.error('Forçando encerramento após timeout.'); console.error('Forçando encerramento após timeout.');
process.exit(1); process.exit(1);
}, 10000); }, 10000).unref();
} }
process.on('SIGTERM', () => gracefulShutdown('SIGTERM')); process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));

View File

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

View File

@@ -4,6 +4,7 @@ import { agentsStore, schedulesStore, executionsStore, notificationsStore, secre
import * as executor from './executor.js'; import * as executor from './executor.js';
import * as scheduler from './scheduler.js'; import * as scheduler from './scheduler.js';
import { generateAgentReport } from '../reports/generator.js'; import { generateAgentReport } from '../reports/generator.js';
import * as gitIntegration from './git-integration.js';
const DEFAULT_CONFIG = { const DEFAULT_CONFIG = {
model: 'claude-sonnet-4-6', model: 'claude-sonnet-4-6',
@@ -172,9 +173,26 @@ export function executeTask(agentId, task, instructions, wsCallback, metadata =
const agentSecrets = loadAgentSecrets(agentId); const agentSecrets = loadAgentSecrets(agentId);
let effectiveInstructions = instructions || '';
const tags = agent.tags || [];
if (tags.includes('coordinator')) {
const allAgents = agentsStore.getAll().filter(a => a.id !== agentId && a.status === 'active');
const agentList = allAgents.map(a => `- **${a.agent_name}**: ${a.description || 'Sem descrição'}`).join('\n');
effectiveInstructions += `\n\n<agentes_disponiveis>\n${agentList}\n</agentes_disponiveis>`;
}
if (metadata.repoName) {
effectiveInstructions += `\n\n<git_repository>\nVocê está trabalhando no repositório "${metadata.repoName}". NÃO faça git init, git commit, git push ou qualquer operação git. O sistema fará commit e push automaticamente ao final da execução. Foque apenas no código.\n</git_repository>`;
}
const effectiveConfig = { ...agent.config };
if (metadata.workingDirectoryOverride) {
effectiveConfig.workingDirectory = metadata.workingDirectoryOverride;
}
const executionId = executor.execute( const executionId = executor.execute(
agent.config, effectiveConfig,
{ description: task, instructions }, { description: task, instructions: effectiveInstructions },
{ {
onData: (parsed, execId) => { onData: (parsed, execId) => {
if (cb) cb({ type: 'execution_output', executionId: execId, agentId, data: parsed }); if (cb) cb({ type: 'execution_output', executionId: execId, agentId, data: parsed });
@@ -233,7 +251,50 @@ export function executeTask(agentId, task, instructions, wsCallback, metadata =
if (cb) cb({ type: 'report_generated', executionId: execId, agentId, reportFile: report.filename }); if (cb) cb({ type: 'report_generated', executionId: execId, agentId, reportFile: report.filename });
} }
} catch (e) { console.error('[manager] Erro ao gerar relatório:', e.message); } } catch (e) { console.error('[manager] Erro ao gerar relatório:', e.message); }
if (metadata.repoName && result.result) {
const repoDir = gitIntegration.getProjectDir(metadata.repoName);
gitIntegration.commitAndPush(repoDir, agent.agent_name, taskText.slice(0, 100))
.then(gitResult => {
if (gitResult.changed) {
console.log(`[manager] Auto-commit: ${gitResult.commitHash} em ${metadata.repoName}`);
if (cb) cb({
type: 'execution_output', executionId: execId, agentId,
data: { type: 'success', content: `Git: commit ${gitResult.commitHash} pushed para ${metadata.repoName} (${gitResult.filesChanged} arquivos) → ${gitResult.commitUrl}` },
});
} else if (gitResult.error) {
console.error(`[manager] Erro no auto-commit:`, gitResult.error);
}
})
.catch(err => console.error(`[manager] Erro no auto-commit:`, err.message));
}
if (cb) cb({ type: 'execution_complete', executionId: execId, agentId, data: result }); if (cb) cb({ type: 'execution_complete', executionId: execId, agentId, data: result });
const isPipelineStep = !!metadata.pipelineExecutionId;
const delegateTo = agent.config?.delegateTo;
if (!isPipelineStep && delegateTo && result.result) {
const delegateAgent = agentsStore.getById(delegateTo);
if (delegateAgent && delegateAgent.status === 'active') {
console.log(`[manager] Auto-delegando de "${agent.agent_name}" para "${delegateAgent.agent_name}"`);
if (cb) cb({
type: 'execution_output',
executionId: execId,
agentId,
data: { type: 'system', content: `Delegando para ${delegateAgent.agent_name}...` },
});
setTimeout(() => {
try {
executeTask(delegateTo, result.result, null, wsCallback, {
delegatedFrom: agent.agent_name,
originalTask: taskText,
});
} catch (delegateErr) {
console.error(`[manager] Erro ao delegar para "${delegateAgent.agent_name}":`, delegateErr.message);
if (cb) cb({ type: 'execution_error', executionId: execId, agentId: delegateTo, data: { error: delegateErr.message } });
}
}, 3000);
}
}
}, },
}, },
agentSecrets agentSecrets

View File

@@ -1,6 +1,7 @@
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { pipelinesStore, agentsStore, executionsStore } from '../store/db.js'; import { pipelinesStore, agentsStore, executionsStore } from '../store/db.js';
import * as executor from './executor.js'; import * as executor from './executor.js';
import * as gitIntegration from './git-integration.js';
import { mem } from '../cache/index.js'; import { mem } from '../cache/index.js';
import { generatePipelineReport } from '../reports/generator.js'; import { generatePipelineReport } from '../reports/generator.js';
@@ -293,6 +294,19 @@ export async function executePipeline(pipelineId, initialInput, wsCallback, opti
}); });
if (!pipelineState.canceled) { 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); }
}
try { try {
const updated = executionsStore.getById(historyRecord.id); const updated = executionsStore.getById(historyRecord.id);
if (updated) { if (updated) {

View File

@@ -160,6 +160,13 @@ export function restoreSchedules(executeFn) {
if (restored > 0) console.log(`[scheduler] ${restored} agendamento(s) restaurado(s)`); if (restored > 0) console.log(`[scheduler] ${restored} agendamento(s) restaurado(s)`);
} }
export function stopAll() {
for (const [, entry] of schedules) {
entry.task.stop();
}
schedules.clear();
}
export function on(event, listener) { export function on(event, listener) {
emitter.on(event, listener); emitter.on(event, listener);
} }

View File

@@ -1,5 +1,5 @@
import { Router } from 'express'; import { Router } from 'express';
import { execFile } from 'child_process'; import { execFile, spawn as spawnProcess } from 'child_process';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import crypto from 'crypto'; import crypto from 'crypto';
import os from 'os'; import os from 'os';
@@ -8,11 +8,14 @@ import * as manager from '../agents/manager.js';
import { tasksStore, settingsStore, executionsStore, webhooksStore, notificationsStore, secretsStore, agentVersionsStore } from '../store/db.js'; import { tasksStore, settingsStore, executionsStore, webhooksStore, notificationsStore, secretsStore, agentVersionsStore } from '../store/db.js';
import * as scheduler from '../agents/scheduler.js'; import * as scheduler from '../agents/scheduler.js';
import * as pipeline from '../agents/pipeline.js'; import * as pipeline from '../agents/pipeline.js';
import * as gitIntegration from '../agents/git-integration.js';
import { getBinPath, updateMaxConcurrent, cancelAllExecutions, getActiveExecutions } from '../agents/executor.js'; import { getBinPath, updateMaxConcurrent, cancelAllExecutions, getActiveExecutions } from '../agents/executor.js';
import { invalidateAgentMapCache } from '../agents/pipeline.js'; import { invalidateAgentMapCache } from '../agents/pipeline.js';
import { cached } from '../cache/index.js'; import { cached } from '../cache/index.js';
import { readdirSync, readFileSync, unlinkSync, existsSync, mkdirSync } from 'fs'; import { readdirSync, readFileSync, writeFileSync, unlinkSync, existsSync, mkdirSync, statSync, createReadStream, rmSync } from 'fs';
import { join, dirname, resolve as pathResolve, extname } from 'path'; import { join, dirname, resolve as pathResolve, extname, basename, relative } from 'path';
import { createGzip } from 'zlib';
import { Readable } from 'stream';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
const __apiDirname = dirname(fileURLToPath(import.meta.url)); const __apiDirname = dirname(fileURLToPath(import.meta.url));
@@ -163,14 +166,25 @@ function buildContextFilesPrompt(contextFiles) {
return `\n\nArquivos de contexto anexados (leia cada um deles antes de iniciar):\n${lines.join('\n')}`; return `\n\nArquivos de contexto anexados (leia cada um deles antes de iniciar):\n${lines.join('\n')}`;
} }
router.post('/agents/:id/execute', (req, res) => { router.post('/agents/:id/execute', async (req, res) => {
try { try {
const { task, instructions, contextFiles } = req.body; const { task, instructions, contextFiles, workingDirectory, repoName, repoBranch } = req.body;
if (!task) return res.status(400).json({ error: 'task é obrigatório' }); if (!task) return res.status(400).json({ error: 'task é obrigatório' });
const clientId = req.headers['x-client-id'] || null; const clientId = req.headers['x-client-id'] || null;
const filesPrompt = buildContextFilesPrompt(contextFiles); const filesPrompt = buildContextFilesPrompt(contextFiles);
const fullTask = task + filesPrompt; const fullTask = task + filesPrompt;
const executionId = manager.executeTask(req.params.id, fullTask, instructions, (msg) => wsCallback(msg, clientId)); const metadata = {};
if (repoName) {
const syncResult = await gitIntegration.cloneOrPull(repoName, repoBranch || null);
metadata.workingDirectoryOverride = syncResult.dir;
metadata.repoName = repoName;
metadata.repoBranch = repoBranch || null;
} else if (workingDirectory) {
metadata.workingDirectoryOverride = workingDirectory;
}
const executionId = manager.executeTask(req.params.id, fullTask, instructions, (msg) => wsCallback(msg, clientId), metadata);
res.status(202).json({ executionId, status: 'started' }); res.status(202).json({ executionId, status: 'started' });
} catch (err) { } catch (err) {
const status = err.message.includes('não encontrado') ? 404 : 400; const status = err.message.includes('não encontrado') ? 404 : 400;
@@ -460,11 +474,20 @@ router.delete('/pipelines/:id', (req, res) => {
router.post('/pipelines/:id/execute', async (req, res) => { router.post('/pipelines/:id/execute', async (req, res) => {
try { try {
const { input, workingDirectory, contextFiles } = req.body; const { input, workingDirectory, contextFiles, repoName, repoBranch } = req.body;
if (!input) return res.status(400).json({ error: 'input é obrigatório' }); if (!input) return res.status(400).json({ error: 'input é obrigatório' });
const clientId = req.headers['x-client-id'] || null; const clientId = req.headers['x-client-id'] || null;
const options = {}; const options = {};
if (workingDirectory) options.workingDirectory = workingDirectory;
if (repoName) {
const syncResult = await gitIntegration.cloneOrPull(repoName, repoBranch || null);
options.workingDirectory = syncResult.dir;
options.repoName = repoName;
options.repoBranch = repoBranch || null;
} else if (workingDirectory) {
options.workingDirectory = workingDirectory;
}
const filesPrompt = buildContextFilesPrompt(contextFiles); const filesPrompt = buildContextFilesPrompt(contextFiles);
const fullInput = input + filesPrompt; const fullInput = input + filesPrompt;
const result = pipeline.executePipeline(req.params.id, fullInput, (msg) => wsCallback(msg, clientId), options); const result = pipeline.executePipeline(req.params.id, fullInput, (msg) => wsCallback(msg, clientId), options);
@@ -1037,4 +1060,282 @@ router.delete('/reports/:filename', (req, res) => {
} }
}); });
const PROJECTS_DIR = '/home/projetos';
function resolveProjectPath(requestedPath) {
const decoded = decodeURIComponent(requestedPath || '');
const resolved = pathResolve(PROJECTS_DIR, decoded);
if (!resolved.startsWith(PROJECTS_DIR)) return null;
return resolved;
}
router.get('/files', (req, res) => {
try {
const targetPath = resolveProjectPath(req.query.path || '');
if (!targetPath) return res.status(400).json({ error: 'Caminho inválido' });
if (!existsSync(targetPath)) return res.status(404).json({ error: 'Diretório não encontrado' });
const stat = statSync(targetPath);
if (!stat.isDirectory()) return res.status(400).json({ error: 'Caminho não é um diretório' });
const entries = readdirSync(targetPath, { withFileTypes: true })
.filter(e => !e.name.startsWith('.'))
.map(entry => {
const fullPath = join(targetPath, entry.name);
try {
const s = statSync(fullPath);
return {
name: entry.name,
type: entry.isDirectory() ? 'directory' : 'file',
size: entry.isDirectory() ? null : s.size,
modified: s.mtime.toISOString(),
extension: entry.isDirectory() ? null : extname(entry.name).slice(1).toLowerCase(),
};
} catch {
return null;
}
})
.filter(Boolean)
.sort((a, b) => {
if (a.type !== b.type) return a.type === 'directory' ? -1 : 1;
return a.name.localeCompare(b.name);
});
const relativePath = relative(PROJECTS_DIR, targetPath) || '';
res.json({
path: relativePath,
parent: relativePath ? dirname(relativePath) : null,
entries,
});
} catch (err) {
res.status(500).json({ error: err.message });
}
});
router.get('/files/download', (req, res) => {
try {
const targetPath = resolveProjectPath(req.query.path || '');
if (!targetPath) return res.status(400).json({ error: 'Caminho inválido' });
if (!existsSync(targetPath)) return res.status(404).json({ error: 'Arquivo não encontrado' });
const stat = statSync(targetPath);
if (!stat.isFile()) return res.status(400).json({ error: 'Caminho não é um arquivo' });
const filename = basename(targetPath);
res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(filename)}"`);
res.setHeader('Content-Length', stat.size);
createReadStream(targetPath).pipe(res);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
router.get('/files/download-folder', (req, res) => {
try {
const targetPath = resolveProjectPath(req.query.path || '');
if (!targetPath) return res.status(400).json({ error: 'Caminho inválido' });
if (!existsSync(targetPath)) return res.status(404).json({ error: 'Pasta não encontrada' });
const stat = statSync(targetPath);
if (!stat.isDirectory()) return res.status(400).json({ error: 'Caminho não é uma pasta' });
const folderName = basename(targetPath) || 'projetos';
res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(folderName)}.tar.gz"`);
res.setHeader('Content-Type', 'application/gzip');
const parentDir = dirname(targetPath);
const dirName = basename(targetPath);
const tar = spawnProcess('tar', ['-czf', '-', '-C', parentDir, dirName]);
tar.stdout.pipe(res);
tar.stderr.on('data', () => {});
tar.on('error', (err) => {
if (!res.headersSent) res.status(500).json({ error: err.message });
});
req.on('close', () => { try { tar.kill(); } catch {} });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
router.delete('/files', (req, res) => {
try {
const targetPath = resolveProjectPath(req.query.path || '');
if (!targetPath) return res.status(400).json({ error: 'Caminho inválido' });
if (targetPath === PROJECTS_DIR) return res.status(400).json({ error: 'Não é permitido excluir o diretório raiz' });
if (!existsSync(targetPath)) return res.status(404).json({ error: 'Arquivo ou pasta não encontrado' });
const stat = statSync(targetPath);
if (stat.isDirectory()) {
rmSync(targetPath, { recursive: true, force: true });
} else {
unlinkSync(targetPath);
}
res.json({ success: true });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
router.get('/repos', async (req, res) => {
try {
const repos = await gitIntegration.listRepos();
res.json(repos);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
router.get('/repos/:name/branches', async (req, res) => {
try {
const branches = await gitIntegration.listBranches(req.params.name);
res.json(branches);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
router.post('/files/publish', async (req, res) => {
const { path: projectPath } = req.body;
if (!projectPath) return res.status(400).json({ error: 'path é obrigatório' });
const targetPath = resolveProjectPath(projectPath);
if (!targetPath) return res.status(400).json({ error: 'Caminho inválido' });
if (!existsSync(targetPath)) return res.status(404).json({ error: 'Projeto não encontrado' });
if (!statSync(targetPath).isDirectory()) return res.status(400).json({ error: 'Caminho não é uma pasta' });
const projectName = basename(targetPath).toLowerCase().replace(/[^a-z0-9-]/g, '-');
const GITEA_URL = process.env.GITEA_URL || 'http://gitea:3000';
const GITEA_USER = process.env.GITEA_USER || 'fred';
const GITEA_PASS = process.env.GITEA_PASS || '';
const DOMAIN = process.env.DOMAIN || 'nitro-cloud.duckdns.org';
const VPS_COMPOSE_DIR = process.env.VPS_COMPOSE_DIR || '/vps';
if (!GITEA_PASS) return res.status(500).json({ error: 'GITEA_PASS não configurado no servidor' });
const exec = (cmd, opts = {}) => new Promise((resolve, reject) => {
const proc = spawnProcess('sh', ['-c', cmd], { cwd: opts.cwd || targetPath, env: { ...process.env, HOME: '/tmp', GIT_TERMINAL_PROMPT: '0' } });
let stdout = '', stderr = '';
proc.stdout.on('data', d => stdout += d);
proc.stderr.on('data', d => stderr += d);
proc.on('close', code => code === 0 ? resolve(stdout.trim()) : reject(new Error(stderr.trim() || `exit ${code}`)));
});
const steps = [];
try {
const authUrl = `${GITEA_URL.replace('://', `://${GITEA_USER}:${GITEA_PASS}@`)}`;
const repoApiUrl = `${GITEA_URL}/api/v1/repos/${GITEA_USER}/${projectName}`;
const createUrl = `${GITEA_URL}/api/v1/user/repos`;
const authHeader = 'Basic ' + Buffer.from(`${GITEA_USER}:${GITEA_PASS}`).toString('base64');
let repoExists = false;
try {
const check = await fetch(repoApiUrl, { headers: { Authorization: authHeader } });
repoExists = check.ok;
} catch {}
if (!repoExists) {
const createRes = await fetch(createUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: authHeader },
body: JSON.stringify({ name: projectName, auto_init: false, private: false }),
});
if (!createRes.ok) {
const err = await createRes.json().catch(() => ({}));
throw new Error(`Erro ao criar repositório: ${err.message || createRes.statusText}`);
}
steps.push('Repositório criado no Gitea');
} else {
steps.push('Repositório já existe no Gitea');
}
const repoUrl = `${authUrl}/${GITEA_USER}/${projectName}.git`;
const gitDir = `${targetPath}/.git`;
if (!existsSync(gitDir)) {
await exec('git init');
await exec(`git remote add origin "${repoUrl}"`);
steps.push('Git inicializado');
} else {
try {
await exec('git remote get-url origin');
await exec(`git remote set-url origin "${repoUrl}"`);
} catch {
await exec(`git remote add origin "${repoUrl}"`);
}
steps.push('Remote atualizado');
}
await exec('git add -A');
try {
await exec('git -c user.name="Agents Orchestrator" -c user.email="agents@nitro-cloud" commit -m "Publicação automática"');
steps.push('Commit criado');
} catch {
steps.push('Sem alterações para commit');
}
await exec('git push -u origin HEAD:main --force');
steps.push('Push realizado');
const caddyFile = `${VPS_COMPOSE_DIR}/caddy/Caddyfile`;
if (existsSync(caddyFile)) {
const caddyContent = readFileSync(caddyFile, 'utf-8');
const marker = `@${projectName} host ${projectName}.${DOMAIN}`;
if (!caddyContent.includes(marker)) {
const block = `\n @${projectName} host ${projectName}.${DOMAIN}\n handle @${projectName} {\n root * /srv/${projectName}\n file_server\n try_files {path} /index.html\n }\n`;
const updated = caddyContent.replace(
/(\n? {4}handle \{[\s\S]*?respond.*?200[\s\S]*?\})/,
block + '$1'
);
writeFileSync(caddyFile, updated);
steps.push('Caddyfile atualizado');
} else {
steps.push('Caddyfile já configurado');
}
}
const composePath = `${VPS_COMPOSE_DIR}/docker-compose.yml`;
if (existsSync(composePath)) {
const composeContent = readFileSync(composePath, 'utf-8');
const volumeLine = `/home/projetos/${basename(targetPath)}:/srv/${projectName}:ro`;
if (!composeContent.includes(volumeLine)) {
const updated = composeContent.replace(
/(- .\/caddy\/config:\/config)/,
`$1\n - ${volumeLine}`
);
writeFileSync(composePath, updated);
steps.push('Volume adicionado ao docker-compose');
} else {
steps.push('Volume já configurado');
}
}
try {
await exec(`docker compose -f ${VPS_COMPOSE_DIR}/docker-compose.yml up -d --force-recreate --no-deps caddy`, { cwd: VPS_COMPOSE_DIR });
steps.push('Caddy reiniciado');
} catch (e) {
steps.push(`Caddy: reinício manual necessário (${e.message})`);
}
const siteUrl = `https://${projectName}.${DOMAIN}`;
const repoWebUrl = `https://git.${DOMAIN}/${GITEA_USER}/${projectName}`;
res.json({
status: 'Publicado',
siteUrl,
repoUrl: repoWebUrl,
projectName,
steps,
message: `Acesse ${siteUrl} em alguns segundos`,
});
} catch (err) {
res.status(500).json({ error: err.message, steps });
}
});
export default router; export default router;