15 Commits
Author SHA1 Message Date
bonito-systemandClaude Opus 5.5 d37cd5ca3b fix(kit): a atualização instala a release publicada, nunca a maior tag
update.sh (alvo) e agent.sh (o que a tela anuncia) escolhiam
git tag -l 'v*' --sort=-v:refname | head -1. Em 2026-09-13 a v1.20.0 existia
como tag manual sem release, com /releases/latest devolvendo v1.19.0: todo
clone teria instalado código que ninguém lançou.

ultima_release_estavel (_common.sh) pergunta /releases/latest do repositório
da origin. API sem resposta = "não sei" (update.sh recusa; agent.sh marca
COMPARE_FAILED), nunca a maior tag. Origin fora do GitHub (espelho, caminho
local) não tem API de release e segue com a maior tag, como antes; o endereço
é trocável por DESKCOMM_RELEASES_LATEST_URL (forks e testes, via file://).

Prova: tests/shell/release-publicada-nao-maior-tag.test.sh, 7/7; sabotado
(função devolvendo a maior tag), 3 vermelhos, os previstos.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 01:47:34 +00:00
melgarafaelandClaude Opus 5 befedad6db docs(triagem): a afirmacao de estado sobre o gate do changelog acompanha o conserto
DoD 16. Duas afirmacoes ficaram falsas quando a medicao do acervo saiu do
`pull_request`:

- `triagem/TRIAGEM.md` dizia que `changelog-cabe-na-tela-da-vps.test.ts` reprova
  quando o acervo passa de 30.000 bytes. Ele nao reprova mais por isso, e a
  consequencia e operacional: o PR de LOTE deixa de ficar vermelho por acervo
  cheio, e o vermelho so chega depois, no push da main. Quem integra passa a
  rodar `pnpm release:acervo-cabe` ANTES de mesclar o lote — o comando entrou no
  bloco de conferencia que ja estava la. O numero saiu do texto: o teto e lido
  do `agent.sh`, e frase com numero envelhece;
- `hostgator-setup-kit/agent.sh` mandava manter a linha do corte inteira porque
  o TESTE a lia por regex. Quem a le agora e `lib/release/cabe-na-tela.ts`, e os
  leitores dela sao dois, com atores diferentes. O comentario passa a nomear o
  leitor real — quem quebrar a linha precisa saber o que explode.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FHzAhMNW4yRWfgR5Ack89m
2026-09-20 17:51:15 -03:00
melgarafaelandClaude Opus 5 edc5f8c638 merge: trazer a main para dentro de triagem/803-frente-a-atualizacao-segura
A base desta branch estava atrasada em relacao a origin/main, e por isso as
medicoes de CI do PR #1198 falavam de um mundo que nao existe mais (e, enquanto
o PR fica CONFLICTING, o GitHub nem chega a criar o merge ref, entao nenhum run
de pull_request nasce). Este merge so traz a main para dentro.

Um conflito, TRIVIAL: `package.json`, script `test:shell` — as duas pontas
ACRESCENTARAM testes de shell distintos ao mesmo encadeamento, e nenhuma removeu
nada (medido contra a merge-base: base 12 itens, a branch somou
`atualizacao-para-quem-fala-com-o-banco.test.sh`, a main somou
`senha-do-cron-vazada.test.sh` e `hooks-nao-acusam-a-main.test.sh`). A resolucao
e a uniao dos tres, 15 itens, cada um na posicao em que seu autor o pos.

Nenhum outro conserto embutido.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 00:53:04 -03:00
PessoaandClaude Opus 5 6f79c0fe54 fix(kit): a senha das rotinas que ficou no log do sistema é trocada sozinha
O #1054 (@rafaeskytrabalho) parou de escrever INTERNAL_CRON_SECRET na linha
do crontab, mas a senha que o syslog/journal já guardou seguia valendo. Decisão
do dono (18/09, opção a): o kit troca sozinho, uma vez, sem edição de .env.

- trocar_segredo_do_cron_vazado (_common.sh): gera → .env → dc up -d →
  arquivo do cron → marca. Troca só o segredo que ia na linha (CRON, ou
  INTERNAL_SECRET quando o CRON está vazio). Falhou o up: volta a senha velha,
  sem marca, tenta de novo depois. Cadeado no .update.lock do agent.sh.
- Chamada de dentro de setup_event_log_drain_cron só no update.sh do terminal:
  é a função que o corpo de todo update.sh publicado já chama depois de reler
  o _common.sh, então a troca chega na mesma atualização que a traz.
- Pelo botão da tela NÃO troca no update.sh: o agent.sh que o dirige guarda a
  senha velha e o run_result levaria 401. O agent.sh seguinte (≤5 min, lido do
  disco a cada vez) faz a troca e já anuncia com a senha nova.
- install.sh nasce marcado, salvo se o crontab ainda tem a linha antiga.

Teste: tests/shell/senha-do-cron-vazada.test.sh (no test:shell), com o
_common.sh e o agent.sh reais e dublês de docker/crontab/curl.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DeeqfKfpL79Sw79XG9X8g1
2026-09-18 11:57:12 -03:00
Paulo Lima JrandClaude Opus 5 90572a9fd6 feat(kit): a atualização para o CRM enquanto mexe no banco, e avisa quem estiver na tela
Frente A do PR #803, de @paulolimajr77 — atualização segura da VPS. A frente de
agenda do mesmo PR não entra aqui: ela é independente e segue pelo caminho normal.

O que muda para quem opera uma VPS: durante a parte do banco o CRM sai do ar por
alguns segundos, de propósito, e quem estiver com a tela aberta vê uma página
"Estamos atualizando o sistema" em vez do erro do navegador. A página volta
sozinha. Nenhum passo manual foi acrescentado — o botão é o mesmo.

Os cinco consertos, todos medidos por ele numa instalação real:

- O CRM, o worker, o agendador e três peças do Supabase param antes do DDL.
  Medido no mesmo dia e com o mesmo arquivo: 113 travamentos com tudo de pé, 60
  com o CRM parado, 0 com as três peças também paradas. Travamento aqui não é
  lentidão: quando o `create policy` trava, o `drop` que veio antes já valeu — a
  regra some e o banco passa a negar leitura em silêncio.
- As regras de isolamento são conferidas uma a uma no fim, contra o pg_policy. Se
  faltar alguma, as que faltam são recriadas a partir do PRÓPRIO baseline (não
  reaplicando o arquivo inteiro, que foi medido e NÃO converge); se ainda faltar,
  a atualização PARA e o CRM não volta ao ar. Um CRM fora do ar é um problema
  visível; um CRM no ar sem isolamento mostra tela vazia e parece normal.
- A volta das peças deixa de ser muda: confere uma a uma, tenta de novo, e nomeia
  em vermelho quem não voltou. Numa instalação real elas não voltaram e a
  atualização disse "concluída com sucesso".
- A página não responde pela API: `/api/` recebe 503 + JSON curto, e só o
  navegador recebe HTML. Com a página de pé, o agente da atualização recebia 18 KB
  de HTML no próprio registro de erro e ficava cego na janela que precisa narrar.
- O aviso de versão nova só acende quando a imagem existe no registro. A etiqueta
  sai uns seis minutos antes do pacote, e clicar nessa janela parava a atualização
  no meio. Registro fora do ar NÃO apaga o botão — a sonda de controle é a versão
  instalada.

Portado sobre a `main` de hoje, 1291 commits à frente do PR. O que mudou ao
reconciliar, e por quê:

- O quinto conserto dele ("a atualização roda pela versão que instala, não pela
  velha") JÁ ESTÁ na `main` desde 42720a4bf, por outro caminho: ela relê o
  `_common.sh` depois do checkout em vez de fazer `exec`. O re-exec e o caso 12 de
  update-guard que o provava ficaram de fora — entregar o mesmo conserto duas
  vezes, em dois desenhos que brigam, é pior que não entregar.
- O log do banco sai pelo segundo argumento de `reaplicar_baseline`, que a `main`
  já tinha e que guarda TODAS as passadas, em vez da captura crua da última.
- Duas sondas foram reancoradas porque o alvo se moveu na `main`, não porque
  afrouxaram: `dc up -d` hoje vive sob a guarda que reconstrói em VPS de outra
  arquitetura, e o `psql -f /b.sql` mudou-se para dentro de `reaplicar_baseline`.
  As duas estavam medindo -1 contra um update.sh correto.

Prova: `tests/shell/atualizacao-para-quem-fala-com-o-banco.test.sh` (46 casos,
com dublê de docker — nada sobe nem para de verdade), ligado ao `pnpm test:shell`,
e `tests/unit/atualizacao-confere-regras-de-isolamento.test.ts` (19 casos, que
executam o AWK extraído do próprio update.sh, nunca uma cópia).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 11:25:27 -03:00
webtecnica ad7da221f1 fix(#1040): a rodada do banco chega na rota — e não afirma fechamento que não houve
Os três consertos do review, todos com prova:

- O fio. `ler_rodada_do_banco` passa a imprimir as três chaves PLANAS e com os
  nomes que a rota lê (`disputa_de_banco`, `retentativas_do_banco`,
  `passada_do_banco`), e o `agent.sh` as manda no corpo do `run_result`. Antes
  ele aninhava um objeto em `rodada_do_banco` com `disputa`/`retentativas`/
  `passada`: o `z.object` descarta chave desconhecida em SILÊNCIO, os três campos
  opcionais chegavam `undefined` e as colunas eram gravadas NULAS em toda rodada
  — a tela ficava calada, que é exatamente o silêncio que este PR veio tirar.
  Trava: caso em `route.test.ts` com o corpo literal do kit, e caso de unidade
  que amarra os mesmos nomes nos três arquivos (kit, agent.sh e rota).

- O desfecho. Rodada que NÃO fechou o banco não registra mais nada: o esgotamento
  gravava os MESMOS números do sucesso, e o erro fatal gravava um `0 0 1` que
  ninguém mediu. As frases da tela são todas escritas como "…até a atualização do
  banco fechar", então silêncio é o degrau certo. Trava: seção 10 da prova de
  shell — o arquivo fica vazio, com controle positivo de que a rodada aconteceu.

- O ramo morto. `disputa: false` com `retentativas >= 1` é impossível por
  construção (os dois saem do mesmo contador de passadas: `disputa = passadas > 1`
  e `retentativas = passadas - 1`), então virou `null` em vez de uma frase para um
  estado que ninguém produz — e o teste que o cobria saiu junto.

A coluna da rodada também deixa de poder derrubar o desfecho: ela sai numa escrita
própria, antes da que fecha o run, e best-effort. Na escrita única de antes, um
42703 (banco anterior à migration da rodada) ou a CHECK recusando o número virava
500, o `post` do agente voltava vazio e o run ficava preso em `dispatched` para
sempre — o desfecho que ninguém detecta.
2026-09-18 01:12:09 -03:00
webtecnica 1bf7c8762d fix(system): rodada de atualização conta a disputa do banco, as retentativas e a passada
O PR #997 mostrou que o baseline reaplicado sobrevive a uma disputa de lock com
o sistema no ar: o kit tenta de novo e fecha. Até aqui essa parte da história
morria no log do servidor — quem clicou via "terminou" sem saber que a base
estava ocupada, nem quanto custou.

Agora a rodada carrega isso e a tela conta ao terminar, em português de gente:

- migration 0275 + apêndice idempotente no baseline.sql + linha no MANIFEST:
  três colunas em system_update_runs (disputa_de_banco, retentativas_do_banco,
  passada_do_banco), com CHECK de coerência e comentários de coluna;
- o kit registra a rodada em cada saída do reaplicar_baseline (_common.sh) e o
  agent.sh manda o campo no run_result;
- o relatório da versão lê as colunas e expõe rodada_do_banco, e o painel de
  atualização mostra o texto no fim da rodada (deu certo e falhou com volta
  atrás);
- rodada que não passou pelo banco não afirma nada: sem medição o campo chega
  ausente e a tela fica calada, em vez de dizer "zero disputas".
2026-09-17 21:17:49 -03:00
Rafael MelgaçoandClaude Opus 5 eed657ddcf fix(atualizacao): a tela mostra TODAS as versoes entre a sua e a nova, com os avisos de cada uma
O dano nao e hipotetico e esta escrito no commit ac9472c5, por quem o descobriu
por acaso: a 1.4.1 existia para consertar dois avisos errados da 1.4.0 — um deles
mandava o operador APAGAR A CONEXAO que estava funcionando —, e como a tela
mostrava so a secao da versao-alvo, quem pulasse da 1.4.0 para a 1.5.0 nunca os
leria. O contorno da epoca foi carregar os avisos orfaos para a versao seguinte,
a mao. Isto aposenta esse contorno.

Medido no CHANGELOG real, quem esta na 1.4.0 indo para a 1.6.0:

    hoje  -> secoes ["1.6.0"]                  avisos []
    agora -> secoes ["1.6.0","1.5.0","1.4.1"]  avisos ["1.5.0","1.4.1"]

Dois avisos de acao manual que hoje simplesmente somem.

**A selecao e POSICIONAL, nao semver.** O arquivo ja vem do mais novo para o mais
antigo, e comparar numero exigiria um comparador que nao existe no projeto — e que
tropecaria em `v1.1.1-jmpo.1`, tag de fork que este repo carrega.

**Os quatro casos sao escritos um a um de proposito.** Um `slice(iAlvo, iInst)`
ingenuo devolve lista VAZIA quando a instalada e mais nova que a alvo, e ainda
assim diria `completa: true`: a tela ficaria sem corpo nenhum afirmando estar
inteira. Ha caso de teste para cada ramo.

**O limite inferior e `running`, nunca `current_version`.** Depois de um rollback,
`current_version` nomeia a versao que QUEBROU (o `git checkout` deu certo; quem
nao subiu foi o container) — a faixa sairia vazia justamente para quem mais
precisa le-la. Coberto por caso proprio na rota.

**O sinal de honestidade e estrutural, e e um so:** achei o cabecalho da versao
instalada no texto recebido? O agente manda o CHANGELOG cortado em bytes, e um
corpo truncado no meio da frase e indistinguivel de um corpo inteiro para quem le.
Sem `complete`, a tela afirmaria completude que nao tem.

**O agente passa a mandar MENOS e melhor, em vez de mais.** Subir o `head -c`
mataria o heartbeat inteiro: o teto do Zod e 64000 sobre a string JA ESCAPADA, e o
422 morre calado, virando "agente offline" 24h depois. Entao o `awk` para de
imprimir AO IMPRIMIR o cabecalho da versao instalada — e o cabecalho entra de
proposito, e ele que prova a completude. Medido: quem esta na 1.4.1 indo para a
1.6.0 passa de 30.000 para 18.032 bytes, terminando exatamente em
`## [1.4.1] — 2026-08-25`.

E o app funciona com o agente VELHO, o que nao e detalhe: o `agent.sh` novo so
chega na VPS no update seguinte. Medido sobre o corte cego de 30.000 bytes:
instalada 1.4.1 -> 2 secoes completa=true; instalada 1.4.0 -> 3 secoes
completa=true.

**Nenhum aviso entra em `<details>`.** Esconder o que exige acao e o defeito que
esta tela existe para consertar; so o CORPO das versoes intermediarias e recolhido.
O e2e prova isso pela tela, com boundingBox (os dois avisos ANTES do botao) e
abrindo o `<details>` com clique — texto dentro de um fechado nao e visivel para o
Playwright, entao afirmar `toBeVisible()` sem abrir seria um teste que passa por
motivo errado.

Este commit traz o proprio fragmento em `.changes/`, e o gate pegou a primeira
versao dele: um `**negrito**` que atravessava a quebra de linha, que chegaria a
tela com os asteriscos a mostra. `pnpm release:conferir` -> 1.6.0 + minor = 1.7.0.

Evidencia: `pnpm typecheck` zerado; `pnpm lint` 0 errors; `pnpm vitest run
--exclude 'lib/ai/dispatcher/rate-limit.test.ts'` -> 498 arquivos, 5626 testes,
verde. A exclusao e defeito de AMBIENTE pre-existente (medido: na main pura da 5
failed COM `.env.local` no disco e 5 passed SEM ele), nao desta branch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DAtZo5GWZn8ckdmsoUVKts
2026-08-27 10:07:39 -03:00
Rafael MelgaçoandClaude Opus 5 c861cbdcd8 fix(kit): duas cópias do repo disputavam os mesmos contêineres e trocavam as credenciais
`/root/DeskcommCRM` e `/root/apagar6/DeskcommCRM` têm o mesmo basename, logo o
mesmo nome de projeto compose. O cron rodava o `agent.sh` das duas a cada 5
minutos, e cada `up -d` recriava o parque com o `.env` da sua árvore.

Medido na VPS de produção: 21/08 13:30 o clone de teste recriou o contêiner do
WAHA com a chave dele; 14:47 o app foi recriado da árvore de produção, com
outra. Por três dias toda chamada ao WhatsApp respondeu 401 — nenhum número
conectava — e as sessões caíam a cada recriação (STOPPED em 20/08 e 21/08).

O `flock` do agent.sh não pega isso: tranca por DIRETÓRIO, e as duas árvores
pegam locks diferentes enquanto disputam os mesmos contêineres. A trava tem de
ser pelo que elas compartilham — o projeto Docker —, e o sinal já existe: o
label `com.docker.compose.project.working_dir` de cada contêiner.

`recusar_projeto_de_outra_arvore` falha fechada na ação e aberta na informação:
nomeia as duas árvores e ensina a saída. `DESKCOMM_ASSUMIR_PROJETO=1` cobre o
caso legítimo de instalação que mudou de pasta.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4p9H7rGReKzkLuwtZ1ABB
2026-08-24 14:11:36 -03:00
Rafael MelgaçoandClaude Opus 5 81b3bd5d83 feat(kit): o agente completa o pin sozinho — só a lacuna, nunca a decisão
O estado que a primeira atualização deixa (worker seguindo canal móvel) durava
até alguém rodar o update de novo — e ninguém roda, porque a tela diz
"Atualização concluída". Agora dura no máximo um ciclo de cron.

A regra que torna isso seguro é a que o Rafael aprovou: **preenche lacuna, nunca
sobrescreve valor explícito.** Chave ausente é omissão do script antigo; chave
presente é decisão de quem opera — inclusive a de seguir um canal de propósito.
Um cron que corrigisse escolha alheia seria pior que o defeito.

E grava a versão que o contêiner JÁ ESTÁ RODANDO (label image.version da imagem
em uso), não a do app. A diferença importa: se o worker estiver numa versão
diferente, gravar a do app MUDARIA o que roda no próximo `up -d` — possivelmente
um downgrade. Assim é congelamento puro.

Ensaiado na VPS com CRON DE VERDADE, não com a função chamada à mão: instalei a
linha, esperei disparar, e o .env ganhou as quatro chaves em 1.3.0 com a
customização do operador intacta. Cron removido e VPS varrida depois.

Duas sabotagens, ambas derrubando o veredito: remover a regra do "só lacuna"
reprova 4 provas (incluindo a que protege o `:stable` explícito); inventar a
versão em vez de ler da imagem reprova 2.

**Um campo decorativo que eu mesma tinha acabado de criar, removido.** No commit
anterior pus `pin_incompleto` no heartbeat. Fui verificar: o schema da rota é
`z.object` sem `.strict()`, então o Zod DESCARTA chave desconhecida em silêncio —
o campo viajaria e não chegaria a lugar nenhum. É o mesmo anti-pattern que esta
sessão já corrigiu duas vezes (controle que existe e não controla). Fica só o log
do host, que é real, até o app aprender o campo.

E um comentário meu que mentia: escrevi que `[ -w ]` protege contra escrita sem
permissão. Não protege — o cron roda como ROOT, e root ignora chmod. Medi com
`chattr +i`, que barra até root: a escrita falha, a função sai 0 e o .env chega
intacto do outro lado. Quem protege é a atomicidade do `set_env_var` (tmp + mv),
não a guarda.

Living System Checklist: entrada = .env + imagens em execução; saída = .env
corrigido + log do host; registro = log_err com a frase inteira; anti-morte = o
cron reexecuta a cada 5 min, e o update.sh manual cobre se o cron estiver parado;
laço de retorno = se gravar errado, o `diagnostico.sh` e o `pin_incompleto` da
execução seguinte acusam. Dívida declarada: não aparece na tela — o estado é
transitório (≤5 min) e autocorrigido, e o que precisa aparecer (a versão) já
aparece.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RKm2XcTcfgi1vdMcDYSRWa
2026-08-14 08:54:10 -03:00
Rafael MelgaçoandClaude Opus 5 9f8fdc314b fix(kit): o pin pela metade deixa de ser silencioso — e o aviso vai onde ele é visto
A primeira atualização de uma instalação legada termina com "Atualização
concluída — app no ar e saudável" e deixa o worker seguindo um canal móvel. Todo
mundo do parque para aí, porque a tela disse que acabou. É a mesma classe do bug
original: estado errado que nada denuncia.

**O lugar óbvio produziria código morto, e medi antes de escrever.** O pedido era
"o update.sh detecta ao final e diz o que falta" — mas `gravar_imagens` roda na
linha 175, antes do `dc pull` (182), do `up -d` (203) e do "Atualização concluída"
(234). Ao chegar no fim, o estado JÁ está pinado: um aviso ali nunca dispararia.

Então a detecção vai em dois lugares, e cada um cobre o que o outro não alcança:

- **update.sh**: lê o estado ANTES de `gravar_imagens` e conta no fim o que
  corrigiu ("a versão de worker scheduler estava solta e foi fixada agora").
  Dispara na 2ª execução, que é quando o estado ruim existe.
- **agent.sh**: é o único que roda DEPOIS da 1ª execução já com o kit novo em
  disco (cron de 5 min, lido a cada vez). Publica `pin_incompleto` no heartbeat e
  escreve a frase inteira no log do host — porque o campo do heartbeat só aparece
  na tela quando o app aprender a exibi-lo, e quem parou na 1ª precisa ser
  alcançado agora.

Ele AVISA, não corrige. Gravar no `.env` de uma instalação alheia é mudança de
comportamento e é decisão de quem opera, não de um cron — vai como proposta no
relatório, não como fato consumado.

Três defeitos meus no caminho, os três achados por medição e não por revisão:

1. `valor_do_env` sem `|| true`: sob o `set -euo pipefail` do _common.sh, um
   `grep` que não casa mata a função — justamente no caso da chave AUSENTE, que é
   o que interessa. Dois casos davam verde de mentira.
2. O bloco de teste foi anexado DEPOIS do `exit` do script: código inalcançável.
3. Depois de movido, ele rodava sem sourcear o _common.sh — e três dos cinco
   casos passavam por VACUIDADE ("comando não encontrado" devolve string vazia,
   que casa com o esperado vazio). A guarda `command -v pin_incompleto` existe por
   causa disso.

E o veredito do arquivo era impresso ANTES do bloco novo: se ele falhasse, a
linha "OK — todas as provas passaram" sairia mesmo assim. Movido para o fim, e
sabotado para confirmar — quebrei a detecção e o veredito virou "FALHOU — 1".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RKm2XcTcfgi1vdMcDYSRWa
2026-08-13 22:11:30 -03:00
Rafael MelgaçoandClaude Opus 5 6eb0b2d2ee fix(packaging): os 11 defeitos de gravidade alta que a revisão adversarial achou
Rodei 6 lentes independentes sobre o próprio diff, cada achado passando por um
cético que tinha de REPRODUZIR o cenário. 27 confirmados de 38, e 11 altos. Os
que quebravam de verdade:

**A transição pareava o app de uma release com o worker do topo da main.** Numa
VPS que atualiza pela primeira vez, quem executa é o update.sh VELHO — o do
disco. Ele faz `git checkout` (que já troca o compose pelo novo) e só sabe
gravar APP_IMAGE. Worker e scheduler caíam no default do compose, que era
`:latest` = topo da main. Ou seja: a entrega que existe para consertar "o worker
nunca era atualizado" faria o parque rodar código NÃO LANÇADO no runtime do
agente de IA, sobre o banco da release. O default virou `:stable` (a última
release) — conserto de uma linha, sem tocar em script, para uma corrida que
nenhum script novo pode vencer.

**`dc pull` sem guarda abortava a instalação NOVA.** Dar `image:` a um serviço
que era build-only muda o `pull` de "Skipped" para FALHA quando a referência não
resolve — e há três motivos reais para não resolver logo após um release: pacote
novo no GHCR nasce PRIVADO, a tag git existe minutos antes das imagens, e o
registro pode cair. O install morria no passo 9, com banco provisionado e .env
escrito. Ganhou a mesma guarda que o update já tinha.

**O pin do WAHA não chegava a ninguém.** `install.sh:1338` gravava
`WAHA_IMAGE=devlikeapro/waha` — sem tag — por cima do default pinado do compose.
O pin existia e era inalcançável.

**Toda tag `v*` também movia `latest`.** `enable={{is_default_branch}}` é
verdadeiro num push de tag, então o canal oscilava entre "topo da main" e
"última release" — apagando a distinção de que a doutrina inteira depende.
`latest` agora exige `ref_type == 'branch'`, e `stable` exige tag `v*`.

**O rollback podia matar todos os crons.** Numa instalação legada, `dc images -q
scheduler` devolve o ID do `alpine:3.20` antigo, cujo crontab vinha de um
`command:` que este compose não tem mais. Gravar esse ID deixaria o contêiner
rodando o CMD do alpine puro: sai na hora, `restart: unless-stopped` recoloca,
crashloop silencioso. Agora só entram no rollback os serviços cujo .env já tem a
chave — isto é, instalações que já passaram pelo update novo.

**A doutrina afirmava o que não era.** Dizia que `build-and-push` já era check
obrigatório (a branch protection diz `verify, build-and-size, invariants, e2e`),
citava dois arquivos de teste que não existem, e creditava ao update-guard uma
prova que é do test-validators. Cometi, dentro da doutrina, exatamente o defeito
que ela existe para impedir. Agora a pendência está escrita como pendência, com
a medição e o motivo de a ativação não poder vir antes do merge.

**O único artefato executável novo não tinha gate** — e escondia uma injeção: o
heredoc interpolava o INTERNAL_SECRET dentro de aspas duplas, então uma crase no
segredo virava substituição de comando a cada minuto. Medido, com o valor real
que saía: `segrafaelmelgacoredo/Users/rafaelmelgaco…`, com `whoami` EXECUTADO.
O valor passou para aspas simples com escape, e tests/shell/scheduler-entrypoint.test.sh
monta o header com um `sh` de verdade e compara byte a byte — sabotar de volta
para aspas duplas reprova.

De quebra: `SCHEDULER_APP_ORIGIN` era controle decorativo (o entrypoint lia, o
compose não repassava, nenhum template documentava) e saiu.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RKm2XcTcfgi1vdMcDYSRWa
2026-08-13 12:12:24 -03:00
Rafael MelgaçoandClaude Opus 5 1c9ead8586 feat(kit): a instalação nasce numa VERSÃO, não no topo da main
`install.sh` gravava `APP_IMAGE=…:latest`, e aqui `latest` não quer dizer "a
última release": a regra do CI é `enable={{is_default_branch}}`, então ela segue
o topo da `main` — código ainda não lançado. Quem instalava no dia 6 e quem
instalava no dia 20 rodavam software diferente, ambos dizendo "estou no latest".
A issue #184 chegou descrevendo o ambiente como "latest do dia 06/08/2026", que
é a admissão de que a versão não era nomeável.

Agora o install resolve a última tag publicada no REMOTO (`git ls-remote
--tags --sort=-v:refname`) — precisa ser no remoto porque o clone é `--depth 1`
e não traz tag nenhuma, então um `git tag -l` local devolveria vazio e a
instalação nasceria em `latest` de novo, sem ninguém perceber.

Falha ABERTA: sem rede, sem git ou sem tag no remoto, cai em `latest` como
antes e avisa. Travar a instalação de alguém por não resolver um número seria
trocar previsibilidade por disponibilidade.

O `update.sh` passa a gravar as TRÊS imagens juntas, na mesma versão — app numa
versão e worker em `latest` é uma matriz de compatibilidade que ninguém testou.
E o `dc pull` deixou de ser fatal: se alguma imagem ainda não existir no
registro (instalação subindo para a primeira versão publicada depois desta
mudança), o compose constrói pelo `build:` que ficou ao lado. Mais lento, mesmo
resultado — melhor que não atualizar.

O rollback do agent.sh voltava SÓ o app. Com worker e scheduler agora pinados,
isso deixaria app na versão antiga e os outros dois na nova — a mistura de
versões que a doutrina existe para impedir. Os três voltam juntos, e só entram
no rollback os que o `images -q` conhecia.

Uma prova do update-guard mudou de lado, e não por conveniência: ela exigia
`APP_PULL_POLICY=always`, com um motivo real (um rollback deixava `missing` com
ID local e ninguém desfazia). O que mudou foi a régua — medi que `always` com o
registro sem responder faz o `up -d` FALHAR e o contêiner não subir, mesmo com
a imagem no disco. Com tag imutável, `always` não protege de nada e amarra a
subida do CRM à disponibilidade do GHCR. O medo original segue coberto: o
`dc pull` do update é explícito e independe do pull_policy. A justificativa
ficou escrita ao lado da prova, não na mensagem de commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RKm2XcTcfgi1vdMcDYSRWa
2026-08-13 11:03:14 -03:00
jmpo 107a978c9f feat(kit): suporte a VPS que já tem proxy reverso (Traefik)
Várias hospedagens entregam a VPS com um Traefik próprio ocupando as
portas 80 e 443 — Hostinger, Coolify, Dokploy, CapRover. É esse Traefik
que dá HTTPS automático a tudo que o painel delas instala.

O kit sobe um Caddy que quer as MESMAS portas. Dois processos não podem
ouvir a mesma porta, então nessas VPS o `up -d` falha no bind e a
instalação morre no meio, com um erro de Docker que não diz nada a quem
não é técnico. A saída óbvia — desligar o Traefik da hospedagem — quebra
as automações do painel dela, presentes e futuras.

O que entra:

- docker-compose.traefik.yml (novo): override que põe o `caddy` num
  profile inativo e publica o `app` por labels do Traefik, reproduzindo
  uma a uma as regras do Caddyfile — rota do domínio, TLS, gzip e o
  bloqueio 403 do webhook global do WAHA. Esse bloqueio importa: quem
  soubesse o endereço injetaria mensagem falsa no CRM. O Traefik não tem
  "responda 403", então o equivalente é um ipallowlist com faixa
  192.0.2.1/32 (TEST-NET-1, RFC 5737) — nenhum cliente real casa.

- REVERSE_PROXY=caddy|traefik no .env, DETECTADO sozinho pelo install.sh
  (procura um contêiner Traefik rodando). Default 'caddy': toda
  instalação existente continua idêntica, sem passar a saber que este
  arquivo existe.

- dc() / dc_files() no _common.sh e no install.sh: todo `docker compose`
  do kit passa a montar a lista de -f conforme o modo. Inclui as
  mensagens que ensinam comandos ao dono — antes elas mandariam um
  comando sem o override, e o próprio dono subiria o Caddy por cima do
  Traefik seguindo a instrução do kit.

- update.sh: pula o `up -d --force-recreate --no-deps caddy` no modo
  traefik. O profile inativo NÃO bastava: nomear o serviço explicitamente
  ATIVA o profile dele no Compose e o contêiner sobe assim mesmo.
  Comprovado com `docker compose --dry-run` (v5.3.0), que reporta
  "Container crm-caddy-1 Starting/Started". Sem isso, toda atualização
  numa VPS com proxy externo terminava num "⚠ não consegui recriar o
  proxy" — alarme falso.

Verificação: numa VPS Hostinger real, com o CRM instalado e no ar, a
config resolvida por

  docker compose -f docker-compose.prod.yml -f docker-compose.traefik.yml config

é IDÊNTICA à do compose adaptado à mão que já roda lá. No modo padrão a
lista de serviços segue com o caddy, e os validadores do kit
(test-validators.sh) passam.
2026-08-01 17:03:03 +00:00
Rafael MelgaçoandClaude Opus 5 2df62aa2cb feat(update): atualizar o CRM pela propria tela, sem SSH (#61)
* docs(update): desenho do botao de atualizar pela propria tela

O app roda em container sem acesso ao host, entao o botao nao executa nada:
publica uma intencao que um agente no host (mesmo cron que ja drena o
event_log) le e converte em `update.sh` da tag assinada. O que atravessa a
fronteira e um booleano, nao um comando.

Alvo passa a ser a ultima tag SemVer, nao o topo da main — assim a tela mostra
a secao curada do CHANGELOG em vez de mensagem de commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ng41aCYobTn37KrN2V7BZf

* docs(update): a tela prometia progresso que o agente nao reportava

A secao da tela mostrava backup/codigo/banco passo a passo, mas o agente so
falava no fim — barra de progresso decorativa. Passa a reportar cada passo
concluido enquanto o app ainda esta de pe (os passos lentos sao justamente os
que acontecem antes do reinicio).

Tambem: `unknown` e derivado na leitura, nao reportado (agente morto nao
anuncia a propria morte); e a instalacao fora de tag e levada a ultima release.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ng41aCYobTn37KrN2V7BZf

* docs(update): plano de implementacao em 9 tasks

Auto-revisao contra o codigo real corrigiu quatro divergencias: fail() tem o
status POSICIONAL (nao dentro de opts), o tipo do GET tinha dois nomes
diferentes entre as tasks 5 e 6, `system.update_finished` estava declarado e
nunca emitido, e o E2E mandava "siga o helper vizinho" em vez de apontar o
arquivo e as linhas.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ng41aCYobTn37KrN2V7BZf

* feat(update): tabelas de instancia da atualizacao self-service

system_version (singleton) e system_update_runs descrevem o servidor, nao o
inquilino — sem organization_id. RLS habilitada com ZERO policy: negar e o
default estrutural, nao depende de a rota lembrar de checar.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ng41aCYobTn37KrN2V7BZf

* feat(update): extrator da secao do CHANGELOG

O agente do host manda o arquivo cru; quem interpreta e o app, em TypeScript,
porque aqui vira funcao pura testavel em vez de awk dentro do bash.

O bloco "Requer atencao" sai separado do corpo: a tela o mostra ACIMA do botao,
senao quem precisa agir a mao descobre rolando a pagina depois de ja ter clicado.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ng41aCYobTn37KrN2V7BZf

* fix(update): corrigir extrator contra CHANGELOG real

Correcoes empiricas encontradas pelo revisor rodando contra CHANGELOG.md real:

1. CRITICAL 1: regex ATTENTION_HEADING agora casa tanto com heading
   (### ⚠️ Requer atenção) quanto com negrito (**⚠️ Requer atenção**).
   O arquivo real usa heading de nível 3.

2. CRITICAL 2: teste "para no proximo heading" era placebo — testava versao
   ultima sem proxima secao seguinte. Reescrito para testar "Nao lancado"
   que tem "1.1.0" como proxima, garantindo que end=i é testado de verdade.
   Sabotagem prova que 2 testes quebram quando end=lines.length.

3. IMPORTANT 3: delimitador do bloco de atencao agora termina apenas em heading
   de verdade (^#{2,4}). Remover /^\*\*/ evita cortar em negrito aleatório
   no meio de um aviso multi-parágrafo.

4. IMPORTANT 4: funcao cleanBody remove referências de link do final
   (formato [algo]: http://...), evitando que rodapé do arquivo apareça
   na tela antes do botão "Atualizar".

5. Teste contra CHANGELOG.md real: valida que versao 1.0.0 é encontrada
   com requiresAttention não nulo, protegendo contra divergência entre
   fixture e arquivo real.

Cobertura agora: 12 testes, incluindo sabotagem validada.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ng41aCYobTn37KrN2V7BZf

* fix(update): corrigir testes placebo e vazamento em requiresAttention

Correcoes criticas encontradas na rodada 2 de revisao:

1. CRITICAL: Teste que le CHANGELOG.md real era placebo com try/catch vazio.
   As asserções estavam dentro do try, logo erros eram engolidos e o teste
   passava mesmo com a implementacao quebrada (regex antigo não casa com heading).

   Fixes: remove try/catch, usa path relativo ao __dirname do teste (não cwd),
   restaura o regex quebrado e roda para provar que 3 testes falham pelo nome.

   Testes que quebram quando regex = antigo:
   - "extrai bloco de atencao em heading (### ⚠️ Requer atencao)" linha 58
   - "le CHANGELOG.md real e encontra 1.0.0 com requiresAttention" linha 120
   - "remove referencias de link do bloco de atencao..." linha 131

2. IMPORTANT: Rodape de links vazava em requiresAttention (não só em body).
   extractAttention usava linhas cruas. Contra arquivo real pedindo 1.0.0
   (ultima versao com bloco de atencao), requiresAttention incluia:
   "[1.0.0]: https://github.com/..."

   Fixes: aplica cleanBody ao resultado de extractAttention. Novo teste
   cobre especificamente versao que eh ultima E tem bloco de atencao.

Cobertura agora: 13 testes, sabotagem validada em 3 casos distintos.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ng41aCYobTn37KrN2V7BZf

* feat(update): vocabulario e transicoes do run de atualizacao

Run que ja terminou e imutavel: o agente tenta reportar de novo apos o reinicio
do app, e a segunda tentativa e recusada em vez de reescrever a historia.

`unknown` nao existe como estado gravado — e derivado na leitura, porque um
agente morto nao consegue anunciar a propria morte.

Edita tests/invariants/vocabulario-banco-x-typescript.test.ts (congelado) para
ACRESCENTAR dois pares novos ao array PARES (system_update_runs.status/last_step
-> RunStatus/RunStep) — manutencao prevista pelo proprio cabecalho do arquivo,
nao correcao de invariante existente. Sabotagem confirmou o morde: removendo
"failed_rolled_back" de RunStatus e "banco" de RunStep, cada par reprovou
isoladamente (1 teste vermelho por vez, os outros 376 verdes) e foi restaurado
em seguida.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ng41aCYobTn37KrN2V7BZf

* feat(update): endpoint unico do agente do host

heartbeat / run_progress / run_result numa uniao discriminada. A resposta do
heartbeat carrega um booleano — nunca um comando: mesmo com o app comprometido,
o atacante nao escolhe O QUE roda no host, so quando o update.sh assinado roda.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ng41aCYobTn37KrN2V7BZf

* fix(update): fecha os 3 buracos da rota do agente do host (review 1/5)

Corrige 3 achados do revisor na Task 4: (1) o lookup do run pendente no
heartbeat tinha perdido order/limit para acomodar um mock pobre — restaurado
e o erro agora é checado (postgrest-js nao lanca com >1 linha "dispatched",
so devolve PGRST116; ler isso como "ninguem pediu" apagava o pedido em
silencio). (2) o invariante "no maximo 1 run dispatched" era so expectativa
de aplicacao — virou indice unico parcial de banco (migration 0090:
uniq_system_update_runs_dispatched), com dedup defensivo antes da
constraint e invariante novo em tests/invariants/system-self-update.test.ts
provando que a segunda insercao concorrente lanca. (3) os dois update() do
run_result nao checavam erro e finalizavam na ordem errada (limpar o pedido
ANTES de fechar o run) — se a segunda escrita falhasse sobrava um run
"dispatched" orfao invisivel. Invertido: fecha o run primeiro (erro vira 500
sem auditoria), so depois limpa o pedido (erro so loga — o pior caso vira
pedido pendente com run ja concluido, detectavel no proximo heartbeat). O
run_progress tambem passou a checar erro. O mock do teste foi reescrito pra
compor eq->order->limit->maybeSingle de verdade e simular falha de update
por tabela, em vez de aceitar so o shape que o codigo antigo produzia.

DESKCOMM_GOV_INVARIANTS_EDIT=1: acrescenta 1 caso a
tests/invariants/system-self-update.test.ts (arquivo pre-existente da
Task 1) provando o indice unico parcial novo da migration 0090 — nao
remove nem enfraquece nenhum invariante existente, so soma cobertura para
uma constraint de banco nova desta correcao.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ng41aCYobTn37KrN2V7BZf

* feat(update): rotas de estado e de pedido de atualizacao

Quem nao e dono do servidor recebe 200 com a versao instalada e nada mais —
aviso sem acao disponivel e so ansiedade, e estado operacional nao e assunto
de inquilino.

O POST nao executa nada: cria o run em `dispatched` e marca o pedido. Essa e a
UNICA transicao que o app escreve, porque e o unico instante em que ele sabe
com certeza que a ordem saiu.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ng41aCYobTn37KrN2V7BZf

* fix(update): unauthenticated no lugar de unauthorized + 500 em vez de 409 numa falha de leitura

`unauthorized` no catalogo e reservado ao segredo interno das rotas host<->app;
sessao ausente e `unauthenticated`, como as outras 38 rotas do projeto ja usam.
Sem essa distincao um frontend que decide por error.code nao reage certo, e o
log confunde "credencial de agente invalida" com "sessao expirada".

Selects de system_version/system_update_runs que nao checavam o erro deixavam
uma falha real de leitura (outage, rede) virar current/latest vazios — e no
POST isso caia no 409 otimista "voce ja esta em dia", a pior mensagem possivel
na hora errada. Agora viram 500 com logger.error, no mesmo padrao ja usado no
INSERT.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ng41aCYobTn37KrN2V7BZf

* fix(update): heartbeat do agente do host bloqueado pelo proxy de sessao

/api/v1/system/agent so aceita bearer token (INTERNAL_SECRET), sem cookie de
sessao -- mas o proxy global exigia sessao antes mesmo da rota rodar seu
proprio check, devolvendo 401 unauthenticated pra qualquer heartbeat real do
agent.sh. Achado provando a Task 6 pela tela (curl do heartbeat batia 401
antes de chegar na rota). Registrado em PUBLIC_PATHS ao lado de /cron/, que
tem a mesma forma de auth.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ng41aCYobTn37KrN2V7BZf

* feat(update): versao instalada no rodape da sidebar

Vira aviso clicavel so para quem e dono do servidor E tem versao nova. Quem nao
pode atualizar ve apenas o numero em cinza — alertar quem nao pode agir e so
ansiedade.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ng41aCYobTn37KrN2V7BZf

* test(update): prova que PUBLIC_PATHS morde

isPublicPath decide quem atravessa o proxy sem sessao em toda a aplicacao e
nao tinha nenhum teste. Cobre o heartbeat do agente (bearer, sem cookie),
a ancora `$` que impede um sub-path de passar de carona, e confirma que
/system/update e /system/version continuam exigindo sessao. Sabotado (removi
o `$`) para confirmar vermelho pelo nome antes de restaurar.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ng41aCYobTn37KrN2V7BZf

* feat(update): tela de atualizacao com os quatro estados

Pagina em vez de modal: um progresso de dois minutos com o servidor
reiniciando no meio nao cabe num modal.

Requisicao falhando enquanto ha run em andamento nao e erro — e o app
reiniciando; a tela mostra "reiniciando" e reconecta sozinha.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ng41aCYobTn37KrN2V7BZf

* fix(update): bloco de atencao nao duplica mais e ganha limpeza de markdown real

O body do changelog incluia o proprio bloco de atencao, entao o mesmo
aviso aparecia duas vezes na tela (caixa destacada + "O que muda"), e
requires_attention nunca passava por limpeza de Markdown. Corrigido na
causa em lib/system/changelog.ts: findAttentionRange() devolve o
intervalo, nao so o texto, e quem chama exclui esse trecho do body.

A limpeza de Markdown (agora markdownParaTextoSimples, testada contra
o CHANGELOG.md real) cobre heading, negrito, crase e lista - nao so
heading como antes. Achado rodando contra o arquivo real: italico de
underscore sem guarda de fronteira de palavra comia os "_" de
identificadores citados no changelog (fn_user_org_ids virava
fnuserorg_ids), corrigido com a mesma regra do CommonMark.

Estados "falhou"/"desfecho desconhecido" agora oferecem o botao de
tentar de novo quando ha versao nova disponivel, em vez de deixar a
pessoa presa numa tela sem saida.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ng41aCYobTn37KrN2V7BZf

* feat(update): agente do host e update.sh por tag publicada

O agente puxa a ordem em vez de o app empurrar comando: nenhuma porta nova,
nenhum socket do Docker, nenhum volume no compose.

update.sh passa a instalar a TAG publicada (nao o topo da main) e a sair com
codigo != 0 quando o app nao volta saudavel — e esse codigo que dispara a volta
para a imagem anterior, guardada antes do pull.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ng41aCYobTn37KrN2V7BZf

* fix(update): agente para de morrer calado sob pipefail e run travado expira

set -e -o pipefail (herdado de _common.sh) matava o agent.sh em silencio em
qualquer substituicao de comando que falhasse antes do relatorio (rede caindo
no heartbeat, docker soluçando ao guardar a imagem anterior) — o pior caso:
o app ja marcou o run como dispatched e nunca mais ouve falar do agente. Toda
substituicao de risco agora tem "|| true" e trata vazio explicitamente; PREV_IMAGE
vazio vira log, nao silencio.

Escape de JSON passa a cobrir toda a faixa de controle C0 (inclusive ESC, que
as proprias c_grn/c_ylw emitem em todo log_tail) e o corte do changelog agora
mede o pior caso escapado, nao o bruto.

POST /api/v1/system/update expira um run "dispatched" abandonado (>15min,
isRunStale ja existente) em vez de recusar pra sempre — e a rede de protecao
completa mesmo quando o agente morre por um motivo novo.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ng41aCYobTn37KrN2V7BZf

* fix(update): esc() vira byte-safe (LC_ALL=C), fecha o ultimo furo de morte silenciosa

Sob locale UTF-8 (o padrao em Debian/Ubuntu, inclusive via cron), tr/sed
validam a entrada como texto multibyte e abortam com "Illegal byte sequence"
ao encontrar um byte invalido — e byte invalido e exatamente o que head -c
produz cortando no meio de um caractere. Reproduzido de verdade: o agente
morria silenciosamente sob UTF-8 e sobrevivia sob LC_ALL=C — dependia do
locale do host, funcionava na maquina de quem testava e morria na do cliente.

LC_ALL=C em cada estagio de esc() faz tr/sed tratarem tudo como bytes crus,
nunca validando multibyte — a causa, nao o sintoma. BODY e TAIL (o ultimo
ainda sem guarda) ganham "|| true" de cinto e suspensorio, e o comentario do
topo do arquivo passa a descrever a garantia real em vez de prometer um
mecanismo que nem todo lugar usava.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ng41aCYobTn37KrN2V7BZf

* fix(update): rodape da sidebar ficava fora do viewport com muitos itens de menu

Achado provando o clique real no aviso "Nova versao" (task 9): o <nav> da
sidebar nao tinha scroll proprio, entao com todos os itens visiveis (caso do
dono da plataforma, que enxerga mais paineis) o rodape era empurrado para
fora da altura fixa do <aside>, inalcancavel por um clique de verdade - nao so
no Playwright, em qualquer viewport curto. Fix: overflow-y-auto no <nav>,
mantendo o rodape fixo.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ng41aCYobTn37KrN2V7BZf

* fix(update): comando manual fixava a marca e o clique usava clipboard cru

Achado rodando pnpm test:unit (invariantes de branding.test.ts e
clipboard.test.ts nunca tinham sido exercitados contra este arquivo nas
tasks 6-8, que so rodaram vitest em arquivos especificos):

1. COMANDO_MANUAL hardcodava "cd DeskcommCRM" - alem de violar o guard de
   white-label (nenhuma superficie pode fixar a marca, revendedor usa a dele),
   o nome nem bate com o default real do instalador (REPO_DIR="deskcommcrm",
   minusculo, e configuravel). Corrigido removendo o cd: quem tem acesso ao
   servidor ja entra na pasta do projeto antes de rodar o script.
2. O botao "Copiar" chamava navigator.clipboard.writeText direto - a regra do
   repo e todo client component passar por copyToClipboard() (lib/clipboard.ts),
   que tem fallback pra contexto nao-seguro (self-host servindo http://IP puro).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ng41aCYobTn37KrN2V7BZf

* test(update): prova pela tela e documentacao da atualizacao self-service

O E2E mede a ordem visual por ferramenta (boundingBox), nao a olho: o bloco
"Requer atencao" tem que vir ANTES do botao, senao quem precisa agir a mao
descobre depois de ja ter clicado.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ng41aCYobTn37KrN2V7BZf

* fix(update): recusa instalar versao anterior e grava a imagem no .env

A ultima tag publicada era mais velha que o codigo de quem segue a main:
`git describe --exact-match` vazio fazia a comparacao de tags passar batido, e o
update.sh rebobinava a instalacao inteira para a tag antiga — apagando o proprio
agente que faz o botao da tela existir. A guarda agora e de ancestralidade
(merge-base --is-ancestor), roda ANTES do backup e de qualquer toque no banco, e
--force e a saida explicita de quem quer mesmo voltar. O agent.sh aplica o MESMO
teste e deixa de anunciar tag ja contida no HEAD, para a tela nunca prometer o
que o host vai recusar.

APP_IMAGE deixou de ser so `export`: o compose le do .env, entao um `up -d`
rodado a mao semanas depois voltava para :latest e desfazia a atualizacao — o
modo de falha pelo qual o Watchtower foi descartado no desenho. Novo helper
set_env_var grava (sem duplicar chave, mantendo 600) na atualizacao e tambem no
rollback, onde a politica de pull vai junto porque a imagem anterior e um ID
local.

O cron do agente subiu para antes da decisao de versao: ele vivia depois de dois
`exit` possiveis, entao quem ja estava em dia — ou agora e recusado — nunca o
ganhava, e o bootstrap pelo terminal nao terminava. A linha de cron ganhou `cd`
no diretorio do projeto: o agent.sh resolve o projeto pelo CWD, e no cron o CWD e
o home (funcionava por acidente so na instalacao padrao).

Prova: tests/shell/update-guard.test.sh (pnpm test:shell, no CI) monta um repo
git descartavel com docker/crontab dublados — 13 verificacoes, incluindo que o
backup nao chega a rodar na recusa e que a chave do .env nao duplica em duas
execucoes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ng41aCYobTn37KrN2V7BZf

* fix(update): tela para de mentir na falha e passa a ter saida sempre

current_version e o `git describe` do HOST, e o checkout ja aconteceu quando o
app quebra: a tela dizia "Voltei para a versao anterior (1.1.0)" nomeando a
versao que FALHOU, e como latest passava a ser igual a current o botao de tentar
de novo sumia — beco sem saida no caso mais comum. A mesma copia ainda era
servida para `failed`, onde o rollback NAO aconteceu.

Agora a versao exibida e a do app que esta no ar (derivada de run.from_version
num rollback), lida antes da bifurcacao por papel para que o rodape da sidebar e
a tela nao divirjam. `failed` e `failed_rolled_back` viraram dois textos: um diz
que voltou, o outro diz que nao conseguiu voltar. O log_tail — que o agent.sh
gasta escape byte-safe e retry de 2 min para entregar e ninguem lia — aparece
recolhido nos tres estados ruins.

O botao de tentar de novo fica DESLIGADO nas falhas de proposito: o codigo do
servidor ja esta na tag nova, entao um novo pedido faria o agente rodar
`update.sh --to <mesma tag>`, que responde "ja esta na mais recente", sai com 0 e
seria reportado como sucesso — mentira pior que a anterior. Quem resolve ali e
--force, e o comando manual (sempre presente) leva a ele.

Instalacao fora de uma versao publicada sem tag para oferecer ganhou tela
propria: antes caia no bloco final e renderizava "Versao  disponivel" com numero
vazio e um botao que a API recusa com 409, mais um ponto pulsante permanente na
sidebar.

Prova pela tela em tests/e2e/system-update.spec.ts (2 casos novos, evidencia em
.superpowers/evidence/final-{1,2,3}-*.png).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ng41aCYobTn37KrN2V7BZf

* fix(update): o run e a unica fonte de "alguem pediu", e erro de leitura nao vira 404

O POST /update criava o run e marcava update_requested_at num segundo write, sem
transacao e sem checar erro: falhando o segundo, a rota respondia 200, a tela
mostrava a barra de passos por 15 minutos e o agente nunca via o pedido. Duas
fontes de verdade para a mesma pergunta, e o heartbeat porteava justamente na
frageil. Agora quem responde "ha pedido?" e o run em dispatched — quem pediu e
quando ja vivem no proprio run (requested_by, dispatched_at) e no audit log.

Nos ramos run_progress/run_result, o error do select().maybeSingle() passou a ser
checado: erro de banco virava 404 e o agente desistiria de reportar um desfecho
que aconteceu de verdade.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ng41aCYobTn37KrN2V7BZf

* docs(update): o bootstrap sao duas execucoes, nao uma

O bloco "Requer atencao" prometia uma execucao. A primeira e sempre a do script
antigo, que ainda esta no disco: ele baixa o novo (git pull) mas sourceou o
_common.sh velho, que nao conhece o cron do agente (conferido em
`git show v1.0.0:hostgator-setup-kit/_common.sh`). Quem liga o botao e a segunda.
Registrado com o porque em uma frase de leigo, no CHANGELOG, no CLAUDE.md do kit
e na propria tela do estado "sem agente".

O mapa vivo ganhou os invariantes que a forma nao mostra: a guarda de
retrocesso nas duas pontas, a imagem gravada no .env e a fonte unica do pedido.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ng41aCYobTn37KrN2V7BZf

* fix(update): clone raso derrubava a guarda de retrocesso, e recusa nao e falha no meio

O install.sh instala com `git clone --depth 1`, e num repositorio raso o
`merge-base --is-ancestor` responde "nao e ancestral" para tudo que esta fora do
unico commit baixado — inclusive para uma tag velha. Ou seja: a resposta que
LIBERA o retrocesso era exatamente a que o raso dava de graca, e `git fetch
--tags` nao desfaz o raso (medido: is-shallow-repository continua true depois do
fetch). A guarda da onda anterior passava batido justamente na unica topologia
que o produto entrega.

is_already_in_head() passa a ter tres respostas: 0 retrocesso, 1 pode seguir, 2
NAO SEI. Completa a historia (fetch --unshallow) antes de perguntar e, se nao
conseguir, devolve 2 — que o update.sh e o agent.sh tratam como recusa, cada um
do seu lado. O risco aqui nao e o botao nao funcionar, e o botao quebrar o CRM
de quem nao sabe consertar: na duvida, recusa e explica.

Recusa ganhou codigo de saida proprio (REFUSED_RC=3). O agent.sh tratava
qualquer RC != 0 como "quebrou no meio": reiniciava o container, reescrevia o
.env e reportava "failed_rolled_back" — estrago inventado para um run que nao
tocou em nada. Agora so ha rollback quando houve o que desfazer.

APP_PULL_POLICY volta a "always" numa atualizacao bem-sucedida: depois de um
rollback ela ficava "missing" para sempre e o `up -d` manual do dono nunca mais
puxava imagem.

Prova: tres casos novos no pnpm test:shell — clone raso de verdade (com o HEAD
e o .env conferidos antes e depois), clone raso sem conseguir completar a
historia, e o agent.sh de verdade contra dubles de curl/docker/flock provando
que a recusa nao dispara rollback.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ng41aCYobTn37KrN2V7BZf

* fix(update): a saida da falha volta para a versao que funcionava, e "a frente" nao e defeito

O estado `failed` prometia "para colocar o sistema de volta no ar" e entregava um
comando que reinstala exatamente a release que acabou de quebrar: se o problema
for a release, e caminho sem volta. A pagina ja tinha `run.from_version` na mao —
o comando agora e `--to v<anterior> --force`, e a frase nomeia o destino. Cai no
--force puro so quando a versao anterior nao e uma release publicada (nao existe
imagem publicada com o nome de uma marca de desenvolvimento).

`failed` sem nenhum passo concluido virou estado proprio: o servidor recusou
ANTES de tocar em qualquer coisa, entao a tela diz "a atualizacao nao chegou a
comecar / nada mudou" em vez de "o sistema pode estar rodando a versao X com
defeito" — assustar por nada e o mesmo defeito de afirmar o que nao aconteceu.

E "Instalacao fora de uma versao publicada" soava como defeito sendo que ninguem
do nosso publico escolheu estar no topo da main: virou "Voce esta a frente da
versao publicada — nao ha nada a atualizar", com o tom de quem nao sabe o que e
uma tag.

Prova pela tela: 4 casos no spec, evidencia em .superpowers/evidence/final-*.png.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ng41aCYobTn37KrN2V7BZf

* docs(update): registra o furo do clone raso e o codigo de recusa

O mapa vivo ganhou os dois invariantes que a forma nao mostra: por que a
pergunta de ancestralidade precisa completar a historia antes (e recusar quando
nao consegue), e o que o codigo 3 significa na fronteira host-app. O CLAUDE.md do
kit explica a recusa por clone raso para quem opera o servidor.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ng41aCYobTn37KrN2V7BZf

* fix(update): o fio host-app ganha "nao sei", e a tela para de adivinhar recusa

Duas regressoes que eu introduzi na onda anterior, com a mesma raiz: o host
aprendeu a dizer "recusei" e "nao sei", mas o fio host->app nao tinha campo para
nenhum dos dois — entao a tela reconstruia essas informacoes a partir de sinais
que tambem significam outra coisa.

1. `last_step` nulo NAO quer dizer "o host recusou". run_progress nao tem retry e
engole falha; run_result insiste 12x por ~2 min. Uma atualizacao que mexeu em
tudo e quebrou no fim chega ao banco com status='failed' e last_step nulo sempre
que os POSTs de progresso nao passaram — e a tela nova lia isso como "nada
mudou", sem oferecer saida nenhuma, justo no estado em que o dono mais precisa
do comando de volta. Ramo removido; o comentario no lugar dele registra o porque.
O caso do E2E que afirmava o comportamento errado virou o contrario.

2. A instalacao atrasada era informada de que estava em dia. Com o clone raso
sem conseguir completar a historia, o agente deixava de anunciar a tag e a tela
lia o silencio como boa noticia: "Voce esta na versao 0.9.0 — e a mais recente",
enquanto uma v1.1.0 esperava. Recusar agir e defensavel; afirmar o que nao se
conferiu nao e, e o custo e o cliente nunca receber a proxima correcao de
seguranca. Agora o "nao sei" viaja explicito: compare_failed no heartbeat (Zod
opcional com default — agente antigo nao pode passar a levar 422 e ficar mudo),
coluna nova em system_version (migration 0093 + apendice no baseline + MANIFEST)
e tela propria dizendo que nao da pra afirmar nem uma coisa nem outra.

3. Nit: no failed_rolled_back a frase prometia "tentar de novo" e o comando so
rebobina. A frase agora descreve o que o comando faz.

Prova: 2 casos novos no pnpm test:shell (o agente de verdade num clone raso
atrasado, e a saida que nao pode sumir sem passo reportado), 3 testes de rota, e
5 specs de tela.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ng41aCYobTn37KrN2V7BZf

* fix(atualizacao): distingue fork sem release de instalação à frente da publicada

Sem release nenhuma conhecida (fork novo), a tela dizia "à frente da versão
publicada" — afirmando a existência de algo que não existe. Novo campo
`has_known_release` (contrato agent.sh -> heartbeat -> tela) resolve a
ambiguidade; default `true` em toda camada (coluna, Zod, GET) preserva o
comportamento anterior para agente antigo que ainda não envia o campo.

Também isola as duas provas do heartbeat "não sei comparar" no shell test
(CONTIDA=2 do unshallow vs. fallback sem tag nenhuma), que antes viviam
misturadas no mesmo caso e não provavam cada linha isoladamente.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ng41aCYobTn37KrN2V7BZf

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 15:11:21 -03:00