O item "Arquivar" do menu da lista de agentes fica desabilitado quando o
agente é o padrão da organização, com um title explicando o motivo, e o
erro cannot_archive_default da action vira frase legível em vez do código
cru "Falha: cannot_archive_default".
Recorte do commit ae3979791 do PR #714: só o hunk de AgentRowMenu.tsx.
Refs: #714
A fila registration_requests, a action que aprova ou recusa e os códigos de
auditoria registration.* vêm do commit 48183ec4 de @betoarts. A empresa só
nasce quando o administrador da instalação aprova, pelo mesmo caminho do
cadastro aberto (ensureTenantForUser).
Adaptado ao que a decisão do dono (doc 24, Decisão 2 = d) e os bloqueadores
publicados no #714 permitem:
- sem pedido para ENTRAR em empresa existente: exigia listar as empresas da
instalação a visitante anônimo. Quem entra numa empresa existente entra
pelo convite;
- sem confirmar e-mail pelo servidor: a aprovação exige email_confirmed_at
gravado pelo provedor de auth e nunca o escreve;
- a CHECK de decisão não exige decided_by, que é "on delete set null":
apagar a conta de quem aprovou violaria a constraint.
Migration 0383 (alocada pelo coordenador da rodada), com o terceiro modo
com_aprovacao na CHECK de platform_settings.signup_mode; o default continua
aberto. Tabela da instalação, anterior à organização: RLS ligada sem policy,
só service_role, como platform_settings (0253). Na branch do autor era
20260912115014_solicitacoes_de_cadastro, sem número.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019G7fFaatqXpzHA77xP9onS
Recorte do PR #714 (commits 783a0a9dc, c3c99696b, dd48f650e de @betoarts):
o instalador de uma pergunta (so o dominio), o override de compose e o
Caddyfile do modo single-server, o override do Supabase que prende Postgres e
gateway em loopback, a porta de entrada ubuntu-production-installer.sh e os
testes estruturais dos dois.
No kit, a mesma logica do autor reaplicada sobre a main atual (que mudou
desde a base do PR): dc/dc_files reconhecem SINGLE_SERVER=1, todo psql/pg_dump
efemero passa por pg_container (entra na bridge privada quando
PSQL_DOCKER_NETWORK existe), o validador da URL do Supabase prova o gateway
local antes de o Caddy existir, e o 1o admin e criado pelo gateway interno.
Fora deste recorte, de proposito: ENABLE_EMAIL_AUTOCONFIRM true (dependia da
fila de aprovacao de cadastro, que nao entra) e o preflight que apontava para
o GHCR do fork.
Refs: #714
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RccZbdYe8URAiWCpyvZimQ
Quem quer rodar o DeskcommCRM na propria maquina nao tinha caminho: o kit
da VPS assume dominio e proxy, e a cadeia historica de migrations nao sobe
em banco novo. Esta fatia traz o ambiente local completo:
- ubuntu-local-installer.sh: prepara a VM, sobe o Supabase local, gera o
.env.local, sobe app/worker/WAHA/Redis e cria o dono;
- scripts/local-supabase.sh: sobe o Supabase local aplicando o BASELINE, e
nao a cadeia historica (que nao sobe em banco novo -- a mesma razao que o
CLAUDE.md registra), restaurando supabase/migrations por trap;
- scripts/local-env.sh: gera o .env.local com segredos aleatorios e faz
backup do ambiente anterior quando ele nao e local;
- scripts/local-stack.sh: up/down/status/logs/reset, mais os quatro atalhos
local:* no package.json;
- docker-compose.local.yml e docs/SETUP.md.
Recorte do PR #714, de @betoarts.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X2i1fgsteAjoAU6T8ewnq2
O ramo de falha gravava validated_at:null e validation_error e deixava
models_available como estava -- a lista da validacao ANTERIOR. A tela
desenha essa contagem (CredentialCard.tsx:165) ao lado da mensagem de
erro, entao uma credencial que deixou de ser confiavel aparece com
catalogo, como se estivesse pronta.
A decifragem que falha passa a usar o logger estruturado no lugar do
console.error. Isso e consistencia com a casa, nao conserto de violacao:
o anti-pattern 14 do CLAUDE.md nomeia console.log, e o eslint permite
error explicitamente (eslint.config.mjs:25).
Recorte do PR #714, de @betoarts.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X2i1fgsteAjoAU6T8ewnq2
A URL canonica da instalacao continua sendo o piso, mas quem desenvolve na
propria maquina abre o mesmo processo por localhost enquanto a configuracao
guardou o IP da rede. O OAuth compara o redirect byte a byte, entao o
consentimento voltava para outra origem e a conexao nao terminava.
So loopback e aceito (localhost, 127.0.0.1, ::1): host arbitrario nunca vence
a URL canonica, para que um cabecalho Host forjado nao troque o endereco a que
o Google devolve o codigo de autorizacao.
Recorte do PR #714, de @betoarts. A pagina da agenda mudou na main desde o
commit original; o conflito de import e de formatacao foi resolvido mantendo o
estilo da main e o efeito do autor.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lou3N4D8u3CV1nmeqTdkZ7
Recorte do #714, de @betoarts (Humberto Moura Neto). A ideia e o diagnostico
sao dele; o que muda em relacao ao patch original e o LUGAR onde a guarda
entra, porque o arquivo alvo andou muito desde que ele escreveu.
=== O DEFEITO
`classifyStage` e `classifyJailbreak` rodam ANTES de o agente responder e sao
os dois ADVISORIOS do turno: um sugere o estagio do funil (quem confirma e o
modelo do agente, via `update_lead_state`), o outro so FLAGRA a mensagem no
trace e nunca vetou um inbound sozinho.
A excecao de `runModelCall` subia dos dois assim mesmo. Como eles sao os dois
elementos do `Promise.all` que abre `executarTurnoDoAgente`, qualquer falha
deles matava o turno inteiro e o cliente ficava sem resposta. O caso dominante
nao e o provedor cair: e o ponto auxiliar, em Configuracoes > Provedores de IA,
apontar para um modelo que saiu do painel ou para uma credencial revogada — o
modelo do agente de pe, o atendimento parado.
Os dois classificadores ja degradavam saida ILEGIVEL sem bloquear
(`parseStageSuggestion` devolve null, `parseJailbreakClassification` devolve
`none`). A excecao era o unico caminho que fugia dessa regra, subindo.
=== ONDE A GUARDA ENTRA, E POR QUE NAO ONDE ELE POS
O patch do #714 envolvia os dois CALL SITES em `inbound-turn.ts`, com dois
`await` em serie — era a forma daquele arquivo na epoca. Hoje o turno roda os
dois em `Promise.all`, e `tests/unit/classificadores-auxiliares-em-paralelo.test.ts`
exige, por AST, que as duas chamadas sejam elementos DIRETOS do mesmo array.
Aplicar o patch como escrito reintroduziria a latencia em serie que aquele
teste existe para impedir, e reprovaria o gate.
A guarda entra dentro de `classifyStage` e `classifyJailbreak`, que e o lugar
certo por tres razoes: e onde a regra de degradacao dessas funcoes ja mora
(a de parse), cobre todo call site futuro sem lista a manter, e deixa o turno
intocado — nenhum dos dois testes de AST do `inbound-turn.ts` precisou mudar.
=== O QUE CONTINUA SUBINDO
`LlmBudgetExceededError`, exatamente como o @betoarts escreveu. Quem a espera e
`comHandoffSeOrcamentoAcabar`: os auxiliares sao os PRIMEIROS a estourar o teto
do mes, e e essa escolta que passa a conversa para uma pessoa em vez de deixar
o lead no vacuo. Engolir o erro de orcamento aqui trocaria o handoff por um
turno que segue gastando.
=== O DEGRADE NAO E SILENCIOSO
`runModelCall` grava a chamada falha em `llm_calls` (purposes
`stage_classifier` / `jailbreak_detect`) ANTES de relancar, e o `warn` carimba
o run. O laco de retorno e essa linha — "a camada auxiliar parou de rodar" tem
onde ser lido, em Uso de IA. Nos campos do log vai so a mensagem do
fornecedor, truncada em 200: a mensagem do lead nunca entra (regra dura 8),
e ha caso medindo isso.
=== PROVA
`tests/unit/classificador-auxiliar-nao-derruba-o-turno.test.ts`, 5 casos.
Controle negativo rodado: com os dois arquivos revertidos ao estado da main,
3 dos 5 reprovam (os tres do degrade) e 2 passam — os de orcamento, que medem
a propriedade que NAO podia ser quebrada pelo `catch` novo.
A METADE do #714 que NAO entrou: `app/api/v1/ai/agents/[id]/versions/[vid]/test/route.ts`,
onde ele trocava `status: "error"` por `"failed"`. A main ja resolveu, e melhor
— o caminho de erro daquela rota ja grava `failed`, `error_message` e um
`logger.error`, e o `catch` vazio que tornava a falha indiagnosticavel tambem
ja foi fechado. Trazer o pedaco dele ali nao mudaria byte nenhum.
Refs: #714
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FHzAhMNW4yRWfgR5Ack89m
O catálogo dos provedores diretos vem no baseline. O da OpenRouter não cabe
lá — são ~400 modelos que mudam sozinhos — e chega pelo cron
`api/v1/cron/sync-model-catalog`, que o scheduler bate às 04:15 UTC
(docker/scheduler/entrypoint.sh:104). Quem terminasse a instalação DEPOIS
dessa rodada abria a tela de criar o primeiro agente e encontrava o seletor
de modelos vazio, com a chave OpenRouter já cadastrada e válida — e só no dia
seguinte descobriria que não era defeito. É primeira impressão: a tela que a
pessoa abre para testar a IA que veio vender o produto.
Depois do healthcheck (`APP_SAUDAVEL=1`), o instalador pede a sincronização
uma vez, pelo mesmo caminho do cron.
Três decisões, as três medidas:
- **Falha aberta.** A origem é externa (openrouter.ai) e pode estar fora do
ar naquele minuto; uma instalação saudável não pode ser invalidada por
isso. O comando mora na CONDIÇÃO de um `if`, onde o `set -euo pipefail`
não aborta — provado nos dois sentidos com o bloco real extraído do
arquivo: com `dc` falhando, imprime o aviso e segue (exit=0); com `dc` OK,
imprime o ✓ e segue.
- **O segredo não passa pelo argv deste processo.** As aspas simples impedem
a expansão aqui; quem expande `$INTERNAL_SECRET` é o `sh` de dentro do
contêiner `scheduler`, que já o recebe pelo ambiente
(docker-compose.prod.yml).
- **Nenhuma variável de ambiente nova.** `INTERNAL_SECRET` e o endereço
interno `http://app:3000` já existiam; nada entra em `.env.example` nem em
`lib/env.ts`, e nenhum `.env` antigo precisa mudar.
Quando o app NÃO fica saudável, o passo nem roda — a tela de "Quase lá"
continua sendo a última coisa que a pessoa lê.
Trabalho de @betoarts, recortado do #714.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Tirar o CRM de uma VPS era trabalho manual, e o atalho conhecido
(`docker system prune -a`) é o errado: numa VPS que hospeda mais de uma
coisa, ele leva junto container, volume e imagem de quem ninguém mandou
apagar.
`desinstalar_docker.sh` descobre o projeto pelo label que o Docker Compose
grava (`com.docker.compose.project`) e remove SÓ os containers, volumes e
redes internas dele. O que fica: outras aplicações do mesmo daemon, imagens,
cache de build, a rede do proxy reverso (que é `external: true` no compose e
por isso não carrega o label do projeto), código, `.env`, backups e um
Supabase externo.
Duas guardas, as duas antes da primeira mutação: a confirmação exige digitar
`REMOVER-<projeto>` (`--force` pula, para descarte automatizado de ambiente),
e quando os containers do mesmo nome de projeto registram OUTRO diretório de
trabalho que ainda existe no disco, o script para e manda rodar a partir de
lá, em vez de assumir propriedade — duas cópias do repositório com o mesmo
basename produzem o mesmo nome Compose.
`tests/shell/desinstalar-docker.test.sh` prova a seleção com um `docker` de
mentira: que as 5 descobertas passam pelo label do projeto, que só o que foi
encontrado é parado/removido, e que `system prune`, `builder prune` e
`image rm` não aparecem no script. Ele entra no `test:shell`, que sobe de 14
para 15 gates — a lista da `main` inteira, mais este.
Trabalho de @betoarts, recortado do #714. O único ajuste de conteúdo foi o
nome: o original era `unistall_docker.sh` (typo de "uninstall"), e congelar
isso num script que o operador digita é dívida barata agora e cara depois.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Quem instala numa VPS era obrigado a abrir conta num serviço externo de envio
e verificar domínio lá antes de conseguir mandar o PRIMEIRO convite de equipe.
Com isto, quem já tem servidor de e-mail aponta para ele.
O que entra:
- `lib/email/smtp.ts` — transporte por nodemailer, com `formatFromAddress`
sanitizando `<`, `>`, `"` e quebra de linha (injeção de cabeçalho) e
devolvendo `null` sem remetente: sem remetente não existe e-mail, e nunca se
inventa um domínio do produto.
- `lib/email/config.ts` — resolução da configuração: banco primeiro
(`platform_smtp_settings`), `.env` como piso de rollback, senha cifrada pela
mesma GUC das outras credenciais server-side.
- `app/actions/settings/smtp.ts` — grava e testa, com `requirePlatformAdmin()`,
Zod e audit. O audit registra `senha_trocada` como booleano, nunca a senha.
- migration `0277` + apêndice idempotente no `baseline.sql` + linha no MANIFEST:
singleton de escopo de instalação, RLS ligada sem policies, `revoke all` de
`anon`/`authenticated`, grant só ao `service_role`.
- `lib/env.ts` e os dois `.env*.example` — as sete `SMTP_*`, todas com default,
para `.env` antigo não quebrar ao atualizar.
- a tela de configuração, em `/admin/email`.
- três casos de teste do remetente do lado SMTP.
Este commit é o recorte do PR #714, e três coisas foram adaptadas do original:
1. **A Resend não sai.** O PR reduzia `lib/email/resend.ts` a um shim de cinco
linhas, tirava o pacote do `package.json`, as chaves do Zod e dos
`.env*.example`, e derrubava a tabela de configuração dela numa migration
irmã. O dono do produto decidiu o contrário: os dois caminhos convivem e quem
já tem Resend não mexe em nada. Nada disso entra; nenhum teste da Resend é
removido.
2. **A tela mudou de lugar.** Estava em `/app/settings/resend`, que é a área de
uma EMPRESA, para configurar um servidor da INSTALAÇÃO — e o sintoma disso
estava no próprio PR, que precisou inventar um `platformOnly: true` no
catálogo de navegação do tenant para escondê-la da empresa. Em `/admin`, ao
lado de `/admin/meta` e `/admin/google`, a exceção deixa de ser necessária.
3. **A tela pede menos do que recebia.** Para desenhar o campo de senha, a tela
não precisa da senha atual — basta saber que existe uma gravada. É o que ela
recebe agora, na mesma disciplina de `temSegredoSalvo` em `/admin/google` e
`/admin/meta`.
Crédito: @betoarts (PR #714).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013edWAVC1j5BuzoBzqVzu5h