From eed657ddcff91f964055560598d324623fb09f77 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Rafael=20Melga=C3=A7o?=
Date: Thu, 27 Aug 2026 10:07:39 -0300
Subject: [PATCH] fix(atualizacao): a tela mostra TODAS as versoes entre a sua
e a nova, com os avisos de cada uma
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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 ``.** 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 `` 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)
Claude-Session: https://claude.ai/code/session_01DAtZo5GWZn8ckdmsoUVKts
---
.changes/tela-mostra-a-faixa-inteira.md | 11 +
app/api/v1/system/version/route.test.ts | 85 +++++++-
app/api/v1/system/version/route.ts | 30 ++-
.../atualizacao/_components/UpdatePanel.tsx | 52 ++++-
...atualizacao-self-service.architecture.json | 200 +++++++++++++++---
hooks/system/useSystemVersion.ts | 9 +-
hostgator-setup-kit/agent.sh | 12 +-
lib/system/changelog.test.ts | 84 +++++++-
lib/system/changelog.ts | 89 +++++++-
tests/e2e/system-update.spec.ts | 65 ++++++
10 files changed, 588 insertions(+), 49 deletions(-)
create mode 100644 .changes/tela-mostra-a-faixa-inteira.md
diff --git a/.changes/tela-mostra-a-faixa-inteira.md b/.changes/tela-mostra-a-faixa-inteira.md
new file mode 100644
index 000000000..56e741725
--- /dev/null
+++ b/.changes/tela-mostra-a-faixa-inteira.md
@@ -0,0 +1,11 @@
+---
+impacto: capacidade_nova
+secao: corrigido
+titulo: A tela de atualização mostra tudo o que mudou desde a sua versão
+---
+
+Antes ela mostrava só o texto da versão mais nova. Quem pulava versões — por
+exemplo, quem estava na 1.4.0 e atualizava direto para a 1.6.0 — nunca via o que
+tinha mudado no meio do caminho, e isso incluía os avisos de coisas que exigiam
+a sua ação. Agora a tela lista todas as versões entre a sua e a nova, com os
+avisos reunidos no topo e cada um dizendo de que versão veio.
diff --git a/app/api/v1/system/version/route.test.ts b/app/api/v1/system/version/route.test.ts
index de4851bcf..921322464 100644
--- a/app/api/v1/system/version/route.test.ts
+++ b/app/api/v1/system/version/route.test.ts
@@ -54,7 +54,11 @@ beforeEach(() => {
current_version: "1.0.0",
latest_version: "1.1.0",
off_release: false,
- changelog_raw: "## [1.1.0] — 2026-08-02\n\n**⚠️ Requer atenção**\n\nreconecte o número.\n\n### Adicionado\n\n- botão.\n",
+ // A seção da versão INSTALADA precisa estar aqui: sem ela, todo caso
+ // exercitaria o caminho "faixa incompleta" e o caminho feliz nasceria sem
+ // cobertura nenhuma.
+ changelog_raw:
+ "## [1.1.0] — 2026-08-02\n\n**⚠️ Requer atenção**\n\nreconecte o número.\n\n### Adicionado\n\n- botão.\n\n## [1.0.0] — 2026-08-01\n\n- primeira versão.\n",
agent_last_seen_at: new Date().toISOString(),
compare_failed: false,
update_requested_at: null,
@@ -158,13 +162,86 @@ describe("GET /api/v1/system/version", () => {
expect(body.data.notes).toBeUndefined();
});
- it("entrega o estado completo e a seção do CHANGELOG para o dono", async () => {
+ it("entrega o estado completo e a faixa do CHANGELOG para o dono", async () => {
vi.mocked(loadAuthUser).mockResolvedValue(OWNER as never);
const { GET } = await import("../version/route");
const body = await (await GET(get())).json();
expect(body.data.update_available).toBe(true);
- expect(body.data.notes.body).toContain("botão");
- expect(body.data.notes.requires_attention).toContain("reconecte o número");
+ expect(body.data.notes.sections.map((s: { version: string }) => s.version)).toEqual(["1.1.0"]);
+ expect(body.data.notes.sections[0].body).toContain("botão");
+ expect(body.data.notes.requires_attention).toEqual([
+ { version: "1.1.0", texto: expect.stringContaining("reconecte o número") },
+ ]);
+ expect(body.data.notes.complete).toBe(true);
+ });
+
+ it("entrega TODAS as seções entre a instalada e a alvo, com o aviso do meio nomeado", async () => {
+ // O defeito que esta faixa conserta: quem pula versões via só a seção-alvo,
+ // e o aviso de ação manual da versão do meio desaparecia (commit ac9472c5).
+ versionRow.current_version = "1.0.0";
+ versionRow.latest_version = "1.2.0";
+ versionRow.changelog_raw = [
+ "## [1.2.0] — 2026-08-03",
+ "",
+ "### Adicionado",
+ "",
+ "- coisa nova.",
+ "",
+ "## [1.1.0] — 2026-08-02",
+ "",
+ "**⚠️ Requer atenção**",
+ "",
+ "reconecte o número.",
+ "",
+ "## [1.0.0] — 2026-08-01",
+ "",
+ "- primeira versão.",
+ "",
+ ].join("\n");
+ vi.mocked(loadAuthUser).mockResolvedValue(OWNER as never);
+ const { GET } = await import("../version/route");
+ const body = await (await GET(get())).json();
+ expect(body.data.notes.sections.map((s: { version: string }) => s.version)).toEqual([
+ "1.2.0",
+ "1.1.0",
+ ]);
+ expect(body.data.notes.requires_attention.map((a: { version: string }) => a.version)).toEqual([
+ "1.1.0",
+ ]);
+ expect(body.data.notes.complete).toBe(true);
+ });
+
+ it("declara faixa INCOMPLETA quando o texto não alcança a versão que está no ar", async () => {
+ // O agente manda o CHANGELOG cortado em bytes. Um corpo truncado no meio da
+ // frase é indistinguível de um corpo inteiro — sem este sinal, a tela
+ // afirmaria completude que não tem.
+ versionRow.changelog_raw = "## [1.1.0] — 2026-08-02\n\n### Adicionado\n\n- botão.\n";
+ vi.mocked(loadAuthUser).mockResolvedValue(OWNER as never);
+ const { GET } = await import("../version/route");
+ const body = await (await GET(get())).json();
+ expect(body.data.notes.complete).toBe(false);
+ });
+
+ it("depois de um rollback a faixa parte da versão que VOLTOU AO AR, não da que quebrou", async () => {
+ // `current_version` nomeia a versão que quebrou (o `git checkout` deu
+ // certo; quem não subiu foi o container). Usar esse campo deixaria a faixa
+ // vazia justamente para quem mais precisa lê-la.
+ versionRow.current_version = "1.1.0";
+ versionRow.latest_version = "1.1.0";
+ runRow = {
+ id: "run-1",
+ status: "failed_rolled_back",
+ from_version: "1.0.0",
+ to_version: "1.1.0",
+ last_step: null,
+ log_tail: "",
+ dispatched_at: new Date().toISOString(),
+ } as never;
+ vi.mocked(loadAuthUser).mockResolvedValue(OWNER as never);
+ const { GET } = await import("../version/route");
+ const body = await (await GET(get())).json();
+ expect(body.data.current_version).toBe("1.0.0");
+ expect(body.data.notes.sections.map((s: { version: string }) => s.version)).toEqual(["1.1.0"]);
});
it("entrega compare_failed para a tela poder dizer 'não sei' em vez de 'está em dia'", async () => {
diff --git a/app/api/v1/system/version/route.ts b/app/api/v1/system/version/route.ts
index e7ad2769f..650073edb 100644
--- a/app/api/v1/system/version/route.ts
+++ b/app/api/v1/system/version/route.ts
@@ -11,7 +11,7 @@ import { fail, ok } from "@/lib/api/wrappers";
import { loadAuthUser } from "@/lib/auth/server";
import { logger } from "@/lib/logger";
import { createAdminClient } from "@/lib/supabase/admin";
-import { extractChangelogSection } from "@/lib/system/changelog";
+import { extractChangelogRange } from "@/lib/system/changelog";
import { isRunStale, type RunStatus, type RunStep } from "@/lib/system/update-run";
export const dynamic = "force-dynamic";
@@ -80,7 +80,15 @@ export async function GET(_req: NextRequest): Promise {
}
const latest = version?.latest_version ?? "";
- const section = latest ? extractChangelogSection(version?.changelog_raw ?? "", latest) : null;
+ // A faixa INTEIRA entre o que está no ar e o que vai entrar, não só a seção
+ // da versão-alvo. Mostrar só a alvo perdia aviso: quem pulava da 1.4.0 para a
+ // 1.6.0 nunca lia a 1.4.1 nem a 1.5.0 — e a 1.4.1 existia para corrigir uma
+ // instrução invertida que mandava apagar a conexão que estava funcionando.
+ //
+ // O limite inferior é `running`, NUNCA `current`: depois de um rollback,
+ // `current` nomeia a versão que quebrou, e a faixa sairia vazia justamente
+ // para quem mais precisa lê-la.
+ const faixa = latest ? extractChangelogRange(version?.changelog_raw ?? "", latest, running) : null;
return ok({
current_version: running,
@@ -99,7 +107,23 @@ export async function GET(_req: NextRequest): Promise {
// não tocada por nenhum heartbeat, coluna com o default da migration).
has_known_release: version?.has_known_release ?? true,
agent_online: !Number.isNaN(lastSeen) && now.getTime() - lastSeen < AGENT_OFFLINE_AFTER_MS,
- notes: section ? { body: section.body, requires_attention: section.requiresAttention } : null,
+ notes:
+ faixa && faixa.secoes.length > 0
+ ? {
+ // Consolidados no topo, cada um dizendo de que versão veio: numa
+ // faixa de várias versões, "reconecte o número" sem dizer de qual
+ // release não informa o operador — assusta.
+ requires_attention: faixa.secoes
+ .filter((s) => s.requiresAttention)
+ .map((s) => ({ version: s.version, texto: s.requiresAttention ?? "" })),
+ sections: faixa.secoes.map((s) => ({ version: s.version, body: s.body })),
+ // `false` = o texto recebido não alcança a versão que está no ar, e
+ // a última seção da lista pode estar cortada no meio da frase. A
+ // tela precisa DIZER isso — corpo truncado é indistinguível de
+ // corpo inteiro para quem lê.
+ complete: faixa.completa,
+ }
+ : null,
run: run
? {
id: run.id,
diff --git a/app/app/settings/atualizacao/_components/UpdatePanel.tsx b/app/app/settings/atualizacao/_components/UpdatePanel.tsx
index d3ec3cee7..8ae767394 100644
--- a/app/app/settings/atualizacao/_components/UpdatePanel.tsx
+++ b/app/app/settings/atualizacao/_components/UpdatePanel.tsx
@@ -286,23 +286,57 @@ export function UpdatePanel() {
)}
- {data.notes?.requires_attention && (
+ {/* Os avisos de TODAS as versões da faixa, reunidos e sempre à vista.
+ Nenhum deles entra em ``: esconder o que exige ação manual é
+ exatamente o defeito que esta tela passou a consertar. Cada um diz de
+ que versão veio — num salto de várias, "reconecte o número" sem dizer
+ de qual release não informa o operador, assusta. */}
+ {data.notes?.requires_attention.length ? (
⚠️ Requer atenção
-
- {markdownParaTextoSimples(data.notes.requires_attention)}
-
+ {data.notes.requires_attention.map((aviso) => (
+
+
Da versão {aviso.version}:
+
{markdownParaTextoSimples(aviso.texto)}
+
+ ))}
+ ) : null}
+
+ {data.notes?.complete === false && data.notes.sections.length > 0 && (
+
+ Este histórico começa na versão {data.notes.sections.at(-1)?.version} e pode não alcançar
+ a que você tem instalada ({versao}) — a última parte pode estar cortada. O texto completo
+ está no arquivo CHANGELOG.md do projeto.
+
)}
- {data.notes?.body && (
+ {data.notes?.sections.length ? (
O que muda
-
- {markdownParaTextoSimples(data.notes.body)}
-
+ {data.notes.sections.map((secao, i) => (
+
+ {/* A versão-alvo fica aberta; as do meio ficam recolhidas, para a
+ lista não virar um muro de texto num salto de várias versões.
+ Só o CORPO é recolhido — o aviso delas já está lá em cima. */}
+ {i === 0 ? (
+
+ {markdownParaTextoSimples(secao.body)}
+
+ ) : (
+
+
+ Versão {secao.version}
+
+
+ {markdownParaTextoSimples(secao.body)}
+
+
+ )}
+
+ ))}
- )}
+ ) : null}
atualizar.mutate()} isPending={atualizar.isPending} erro={erro} />
diff --git a/docs/architecture/atualizacao-self-service.architecture.json b/docs/architecture/atualizacao-self-service.architecture.json
index 120d9e522..f5c333c1f 100644
--- a/docs/architecture/atualizacao-self-service.architecture.json
+++ b/docs/architecture/atualizacao-self-service.architecture.json
@@ -8,39 +8,181 @@
"quality_profile": "standard"
},
"lanes": [
- { "id": "host", "label": "VPS (fora do app)" },
- { "id": "app", "label": "App — rota do agente e da UI" },
- { "id": "banco", "label": "Banco (instância, sem organization_id)" },
- { "id": "humano", "label": "O que o dono vê" }
+ {
+ "id": "host",
+ "label": "VPS (fora do app)"
+ },
+ {
+ "id": "app",
+ "label": "App — rota do agente e da UI"
+ },
+ {
+ "id": "banco",
+ "label": "Banco (instância, sem organization_id)"
+ },
+ {
+ "id": "humano",
+ "label": "O que o dono vê"
+ }
+ ],
+ "mainPath": [
+ "agentsh",
+ "agentroute",
+ "systemversion",
+ "footer",
+ "painel",
+ "updateroute",
+ "runs"
],
- "mainPath": ["agentsh", "agentroute", "systemversion", "footer", "painel", "updateroute", "runs"],
"nodes": [
- { "id": "agentsh", "lane": "host", "col": 0, "type": "service", "label": "agent.sh", "sublabel": "cron 5min · flock · esc() byte-safe (LC_ALL=C)" },
- { "id": "updatesh", "lane": "host", "col": 1, "type": "service", "label": "update.sh --to ", "sublabel": "backup → checkout da tag → baseline → imagem → healthcheck" },
-
- { "id": "agentroute", "lane": "app", "col": 0, "type": "api", "label": "POST /api/v1/system/agent", "sublabel": "bearer INTERNAL_CRON_SECRET · heartbeat/run_progress/run_result" },
- { "id": "versionroute", "lane": "app", "col": 2, "type": "api", "label": "GET /api/v1/system/version", "sublabel": "só is_platform_admin vê o operacional" },
- { "id": "updateroute", "lane": "app", "col": 3, "type": "api", "label": "POST /api/v1/system/update", "sublabel": "cria o run — nunca executa nada" },
-
- { "id": "systemversion", "lane": "banco", "col": 1, "type": "database", "label": "system_version", "sublabel": "singleton id=1 · current/latest/changelog_raw" },
- { "id": "runs", "lane": "banco", "col": 3, "type": "database", "label": "system_update_runs", "sublabel": "índice único: no máx. 1 'dispatched'" },
-
- { "id": "footer", "lane": "humano", "col": 2, "type": "frontend", "label": "Rodapé da sidebar", "sublabel": "aviso só se is_owner E update_available" },
- { "id": "painel", "lane": "humano", "col": 3, "type": "frontend", "label": "/app/settings/atualizacao", "sublabel": "4 estados · atenção ANTES do botão" }
+ {
+ "id": "agentsh",
+ "lane": "host",
+ "col": 0,
+ "type": "service",
+ "label": "agent.sh",
+ "sublabel": "cron 5min · flock · esc() byte-safe (LC_ALL=C)"
+ },
+ {
+ "id": "updatesh",
+ "lane": "host",
+ "col": 1,
+ "type": "service",
+ "label": "update.sh --to ",
+ "sublabel": "backup → checkout da tag → baseline → imagem → healthcheck"
+ },
+ {
+ "id": "agentroute",
+ "lane": "app",
+ "col": 0,
+ "type": "api",
+ "label": "POST /api/v1/system/agent",
+ "sublabel": "bearer INTERNAL_CRON_SECRET · heartbeat/run_progress/run_result"
+ },
+ {
+ "id": "versionroute",
+ "lane": "app",
+ "col": 2,
+ "type": "api",
+ "label": "GET /api/v1/system/version",
+ "sublabel": "só is_platform_admin vê o operacional · entrega a faixa instalada→alvo"
+ },
+ {
+ "id": "updateroute",
+ "lane": "app",
+ "col": 3,
+ "type": "api",
+ "label": "POST /api/v1/system/update",
+ "sublabel": "cria o run — nunca executa nada"
+ },
+ {
+ "id": "systemversion",
+ "lane": "banco",
+ "col": 1,
+ "type": "database",
+ "label": "system_version",
+ "sublabel": "singleton id=1 · current/latest/changelog_raw"
+ },
+ {
+ "id": "runs",
+ "lane": "banco",
+ "col": 3,
+ "type": "database",
+ "label": "system_update_runs",
+ "sublabel": "índice único: no máx. 1 'dispatched'"
+ },
+ {
+ "id": "footer",
+ "lane": "humano",
+ "col": 2,
+ "type": "frontend",
+ "label": "Rodapé da sidebar",
+ "sublabel": "aviso só se is_owner E update_available"
+ },
+ {
+ "id": "painel",
+ "lane": "humano",
+ "col": 3,
+ "type": "frontend",
+ "label": "/app/settings/atualizacao",
+ "sublabel": "4 estados · avisos de TODAS as versões da faixa, antes do botão"
+ }
],
"edges": [
- { "id": "e1", "from": "agentsh", "to": "agentroute", "label": "heartbeat: versão instalada + changelog cru" },
- { "id": "e2", "from": "agentroute", "to": "systemversion", "label": "grava current/latest/changelog_raw" },
- { "id": "e3", "from": "systemversion", "to": "versionroute", "label": "leitura para a tela" },
- { "id": "e4", "from": "versionroute", "to": "footer", "label": "update_available + is_owner" },
- { "id": "e5", "from": "footer", "to": "painel", "label": "clique no aviso 'Nova versão'" },
- { "id": "e6", "from": "painel", "to": "updateroute", "label": "clique em 'Atualizar agora'" },
- { "id": "e7", "from": "updateroute", "to": "runs", "label": "insert status='dispatched'" },
- { "id": "e8", "from": "agentroute", "to": "runs", "label": "heartbeat seguinte: update_requested=true" },
- { "id": "e9", "from": "agentsh", "to": "updatesh", "label": "dispara o script local — nunca um comando vindo do app", "route": "highlight" },
- { "id": "e10", "from": "updatesh", "to": "agentroute", "label": "report() em 3 pontos: backup/código/banco", "route": "highlight" },
- { "id": "e11", "from": "agentroute", "to": "runs", "label": "run_result fecha success/failed/failed_rolled_back" },
- { "id": "e12", "from": "runs", "to": "painel", "label": "polling 5s — progresso e desfecho" }
+ {
+ "id": "e1",
+ "from": "agentsh",
+ "to": "agentroute",
+ "label": "heartbeat: versão instalada + changelog cru"
+ },
+ {
+ "id": "e2",
+ "from": "agentroute",
+ "to": "systemversion",
+ "label": "grava current/latest/changelog_raw"
+ },
+ {
+ "id": "e3",
+ "from": "systemversion",
+ "to": "versionroute",
+ "label": "faixa de seções entre a instalada e a alvo"
+ },
+ {
+ "id": "e4",
+ "from": "versionroute",
+ "to": "footer",
+ "label": "update_available + is_owner"
+ },
+ {
+ "id": "e5",
+ "from": "footer",
+ "to": "painel",
+ "label": "clique no aviso 'Nova versão'"
+ },
+ {
+ "id": "e6",
+ "from": "painel",
+ "to": "updateroute",
+ "label": "clique em 'Atualizar agora'"
+ },
+ {
+ "id": "e7",
+ "from": "updateroute",
+ "to": "runs",
+ "label": "insert status='dispatched'"
+ },
+ {
+ "id": "e8",
+ "from": "agentroute",
+ "to": "runs",
+ "label": "heartbeat seguinte: update_requested=true"
+ },
+ {
+ "id": "e9",
+ "from": "agentsh",
+ "to": "updatesh",
+ "label": "dispara o script local — nunca um comando vindo do app",
+ "route": "highlight"
+ },
+ {
+ "id": "e10",
+ "from": "updatesh",
+ "to": "agentroute",
+ "label": "report() em 3 pontos: backup/código/banco",
+ "route": "highlight"
+ },
+ {
+ "id": "e11",
+ "from": "agentroute",
+ "to": "runs",
+ "label": "run_result fecha success/failed/failed_rolled_back"
+ },
+ {
+ "id": "e12",
+ "from": "runs",
+ "to": "painel",
+ "label": "polling 5s — progresso e desfecho"
+ }
],
"cards": [
{
diff --git a/hooks/system/useSystemVersion.ts b/hooks/system/useSystemVersion.ts
index 221e85fd2..afe56e337 100644
--- a/hooks/system/useSystemVersion.ts
+++ b/hooks/system/useSystemVersion.ts
@@ -14,7 +14,14 @@ export interface SystemVersion {
/** O host já viu ao menos uma tag `v*` publicada neste repositório. */
has_known_release?: boolean;
agent_online?: boolean;
- notes?: { body: string; requires_attention: string | null } | null;
+ notes?: {
+ /** Um por versão da faixa que tem aviso, do mais novo ao mais antigo. */
+ requires_attention: Array<{ version: string; texto: string }>;
+ /** Todas as seções entre a versão no ar e a alvo, da mais nova à mais antiga. */
+ sections: Array<{ version: string; body: string }>;
+ /** `false`: o texto recebido pode não alcançar a versão instalada. */
+ complete: boolean;
+ } | null;
run?: {
id: string;
status: string;
diff --git a/hostgator-setup-kit/agent.sh b/hostgator-setup-kit/agent.sh
index 3022571df..459f44a11 100755
--- a/hostgator-setup-kit/agent.sh
+++ b/hostgator-setup-kit/agent.sh
@@ -157,7 +157,17 @@ if [ -n "$LATEST_TAG" ] && [ "$LATEST_TAG" != "$CURRENT" ]; then
# HEARTBEAT INTEIRO morreria com 422 — sem short-circuit, isso morre calado.
# 30000 cru garante ≤60000 escapado mesmo no pior caso (100% do texto
# escapando 2x), com folga sobre o teto de 64000.
- CHANGELOG="$(git show "${LATEST_TAG}:CHANGELOG.md" 2>/dev/null | head -c 30000 || true)"
+ # O corte deixou de ser cego. O `awk` para de imprimir AO IMPRIMIR o cabeçalho
+ # da versão instalada — e o cabeçalho entra de propósito: é ele que prova ao
+ # app que a faixa está completa. Isso encolhe o payload no caso comum (uma ou
+ # duas versões de salto) em vez de subir o teto, que mataria o heartbeat
+ # inteiro com 422, calado. O `head -c 30000` continua depois, como teto para o
+ # salto grande. `index()` e não regex: o rótulo tem `[` e `]`, e escapar isso
+ # em awk é onde se erra. Instalação fora de release (CURRENT é um SHA) nunca
+ # casa, cai no arquivo inteiro cortado, e o app declara que não alcançou.
+ # MANTENHA numa linha física só: tests/unit/changelog-cabe-na-tela-da-vps.test.ts
+ # lê o teto daqui por regex de linha única e EXPLODE se ela for quebrada.
+ CHANGELOG="$(git show "${LATEST_TAG}:CHANGELOG.md" 2>/dev/null | awk -v cur="## [${CURRENT#v}]" 'index($0, cur) == 1 { print; exit } { print }' | head -c 30000 || true)"
# `head -c` corta em byte fixo, e o CHANGELOG tem emoji/acento multi-byte
# (UTF-8) — um corte no meio de um caractere quebraria o JSON de um jeito
# difícil de rastrear. `iconv -c` descarta o byte incompleto do final sem
diff --git a/lib/system/changelog.test.ts b/lib/system/changelog.test.ts
index 068e556e9..4f23263ca 100644
--- a/lib/system/changelog.test.ts
+++ b/lib/system/changelog.test.ts
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
import { readFileSync } from "fs";
import { join } from "path";
-import { extractChangelogSection, markdownParaTextoSimples } from "./changelog";
+import { extractChangelogRange, extractChangelogSection, markdownParaTextoSimples } from "./changelog";
const CHANGELOG = `# Changelog
@@ -193,3 +193,85 @@ describe("markdownParaTextoSimples", () => {
);
});
});
+
+describe("extractChangelogRange — todas as seções entre a instalada e a alvo", () => {
+ const TRES = [
+ "# Changelog",
+ "",
+ "## [Não lançado]",
+ "",
+ "## [1.2.0] — 2026-08-03",
+ "",
+ "### Adicionado",
+ "",
+ "- coisa nova",
+ "",
+ "## [1.1.0] — 2026-08-02",
+ "",
+ "### ⚠️ Requer atenção",
+ "",
+ "- reconecte o número depois de atualizar",
+ "",
+ "### Corrigido",
+ "",
+ "- conserto do meio",
+ "",
+ "## [1.0.0] — 2026-08-01",
+ "",
+ "- primeira",
+ "",
+ ].join("\n");
+
+ it("devolve da mais nova para a mais antiga, sem incluir a instalada", () => {
+ const f = extractChangelogRange(TRES, "1.2.0", "1.0.0");
+ expect(f.secoes.map((s) => s.version)).toEqual(["1.2.0", "1.1.0"]);
+ expect(f.completa).toBe(true);
+ });
+
+ it("o aviso da versão do MEIO sobrevive — é o defeito que esta função existe para corrigir", () => {
+ // Quem pulava versões via só a seção-alvo, e o aviso de ação manual da
+ // versão intermediária desaparecia (commit ac9472c5).
+ const f = extractChangelogRange(TRES, "1.2.0", "1.0.0");
+ const comAviso = f.secoes.filter((s) => s.requiresAttention);
+ expect(comAviso.map((s) => s.version)).toEqual(["1.1.0"]);
+ expect(comAviso[0]?.requiresAttention).toContain("reconecte o número");
+ });
+
+ it("não afirma completude quando a instalada é um SHA (instalação fora de release)", () => {
+ const f = extractChangelogRange(TRES, "1.2.0", "abc1234");
+ expect(f.completa).toBe(false);
+ expect(f.secoes.length).toBeGreaterThan(0);
+ });
+
+ it("não afirma completude quando o texto chegou cortado antes da instalada", () => {
+ const cortado = TRES.slice(0, TRES.indexOf("## [1.0.0]"));
+ expect(extractChangelogRange(cortado, "1.2.0", "1.0.0").completa).toBe(false);
+ });
+
+ it("em dia: nada entre as duas, e isso NÃO é incompletude", () => {
+ expect(extractChangelogRange(TRES, "1.2.0", "1.2.0")).toEqual({ secoes: [], completa: true });
+ });
+
+ it("instalada mais nova que a alvo devolve a seção-alvo, nunca uma lista vazia dizendo-se completa", () => {
+ // Um `slice(iAlvo, iInst)` ingênuo devolveria [] com completa=true aqui: a
+ // tela ficaria sem corpo nenhum afirmando estar inteira.
+ const f = extractChangelogRange(TRES, "1.0.0", "1.2.0");
+ expect(f.secoes.map((s) => s.version)).toEqual(["1.0.0"]);
+ expect(f.secoes[0]?.body).toContain("primeira");
+ });
+
+ it("alvo que não existe no texto devolve vazio e incompleto", () => {
+ expect(extractChangelogRange(TRES, "9.9.9", "1.0.0")).toEqual({ secoes: [], completa: false });
+ });
+
+ it("faixa de uma versão só é igual à seção única — as duas leituras não podem divergir", () => {
+ const pelaFaixa = extractChangelogRange(TRES, "1.2.0", "1.1.0").secoes[0];
+ const pelaSecao = extractChangelogSection(TRES, "1.2.0");
+ expect(pelaFaixa).toEqual(pelaSecao);
+ });
+
+ it("`[Não lançado]` nunca entra na faixa", () => {
+ const f = extractChangelogRange(TRES, "1.2.0", "1.0.0");
+ expect(f.secoes.map((s) => s.version)).not.toContain("Não lançado");
+ });
+});
diff --git a/lib/system/changelog.ts b/lib/system/changelog.ts
index db7096090..f91b89cf3 100644
--- a/lib/system/changelog.ts
+++ b/lib/system/changelog.ts
@@ -45,6 +45,16 @@ export function extractChangelogSection(raw: string, version: string): Changelog
if (start === -1) return null;
+ return montarSecao(lines, start, end, wanted);
+}
+
+/** O miolo de `extractChangelogSection`, reusado pela faixa. */
+function montarSecao(
+ lines: readonly string[],
+ start: number,
+ end: number,
+ versao: string,
+): ChangelogSection {
const bodyLines = lines.slice(start, end);
const attention = findAttentionRange(bodyLines);
@@ -62,7 +72,84 @@ export function extractChangelogSection(raw: string, version: string): Changelog
? cleanBody(bodyLines.slice(attention.start + 1, attention.end).join("\n").trim()) || null
: null;
- return { version: wanted, body, requiresAttention };
+ return { version: versao, body, requiresAttention };
+}
+
+export interface ChangelogRange {
+ /** Da mais NOVA para a mais antiga, como o arquivo. */
+ secoes: ChangelogSection[];
+ /**
+ * Achei o cabeçalho da versão instalada no texto recebido?
+ *
+ * `false` significa "este histórico pode não alcançar a sua versão" — e a tela
+ * precisa DIZER isso. O agente da VPS manda o CHANGELOG cortado em bytes, e um
+ * corpo truncado no meio da frase é indistinguível de um corpo inteiro: sem
+ * este sinal, a tela afirmaria completude que não tem.
+ */
+ completa: boolean;
+}
+
+/**
+ * Todas as seções entre a versão-alvo e a instalada.
+ *
+ * Existe porque mostrar só a seção-alvo perde aviso: quem pula da 1.4.0 para a
+ * 1.6.0 nunca lia a 1.4.1 nem a 1.5.0 — e a 1.4.1 existia justamente para
+ * corrigir uma instrução invertida que mandava o operador apagar a conexão que
+ * estava funcionando. O contorno da época foi carregar o aviso órfão para a
+ * versão seguinte, à mão (commit ac9472c5); isto o aposenta.
+ *
+ * A seleção é POSICIONAL, não semver: o arquivo já vem do mais novo para o mais
+ * antigo, e comparar número exigiria um comparador que não existe no projeto —
+ * que tropeçaria em `v1.1.1-jmpo.1`, tag de fork que este repo carrega.
+ */
+export function extractChangelogRange(
+ raw: string,
+ alvo: string,
+ instalada: string,
+): ChangelogRange {
+ const vazio: ChangelogRange = { secoes: [], completa: false };
+ if (!raw || !alvo) return vazio;
+
+ const lines = raw.split("\n");
+ const cabs: Array<{ rotulo: string; i: number }> = [];
+ lines.forEach((linha, i) => {
+ const m = VERSION_HEADING.exec(linha);
+ if (m) cabs.push({ rotulo: normalize(m[1] ?? ""), i });
+ });
+
+ const iAlvo = cabs.findIndex((c) => c.rotulo === normalize(alvo));
+ if (iAlvo === -1) return vazio;
+
+ const iInst = cabs.findIndex((c) => c.rotulo === normalize(instalada));
+
+ // Os quatro casos são escritos um a um de propósito. Um `slice(iAlvo, iInst)`
+ // ingênuo devolve lista VAZIA quando `iInst < iAlvo`, e ainda assim diria
+ // `completa: true` — a tela ficaria sem corpo nenhum afirmando estar inteira.
+ let fim: number;
+ let completa: boolean;
+ if (iInst === -1) {
+ // Instalação fora de release (a versão é um SHA) ou texto cortado antes de
+ // alcançá-la. Mostra o que veio e admite que pode não alcançar.
+ fim = cabs.length;
+ completa = false;
+ } else if (iInst === iAlvo) {
+ return { secoes: [], completa: true }; // está em dia: nada entre as duas
+ } else if (iInst < iAlvo) {
+ // A instalada é MAIS NOVA que a alvo (rollback pendente, canal trocado).
+ // Só a seção-alvo faz sentido aqui.
+ fim = iAlvo + 1;
+ completa = true;
+ } else {
+ fim = iInst;
+ completa = true;
+ }
+
+ const secoes = cabs.slice(iAlvo, fim).map((c, k, arr) => {
+ const proximo = arr[k + 1]?.i ?? cabs[iAlvo + arr.length]?.i ?? lines.length;
+ return montarSecao(lines, c.i + 1, proximo, c.rotulo);
+ });
+
+ return { secoes, completa };
}
/**
diff --git a/tests/e2e/system-update.spec.ts b/tests/e2e/system-update.spec.ts
index 372708629..2e1d63d84 100644
--- a/tests/e2e/system-update.spec.ts
+++ b/tests/e2e/system-update.spec.ts
@@ -178,6 +178,71 @@ function resetEstado(): void {
execFileSync("npx", ["tsx", "scripts/seed-e2e-system-update.ts"], { stdio: "inherit" });
}
+test("quem pula versões vê os avisos de TODAS elas, não só o da mais nova", async ({
+ page,
+ request,
+}) => {
+ // O defeito que este caso guarda: a tela mostrava só a seção da versão-alvo.
+ // Quem estava na 1.0.0 e ia para a 1.2.0 nunca lia a 1.1.0 — e no caso real
+ // (commit ac9472c5) a versão do meio trazia uma instrução para o operador
+ // apagar a conexão que estava funcionando.
+ const changelog = [
+ "## [1.2.0] — 2026-08-03",
+ "",
+ "**⚠️ Requer atenção**",
+ "",
+ "Rode o comando de migração antes.",
+ "",
+ "### Adicionado",
+ "",
+ "- Coisa da versão nova.",
+ "",
+ "## [1.1.0] — 2026-08-02",
+ "",
+ "**⚠️ Requer atenção**",
+ "",
+ "Reconecte o número depois.",
+ "",
+ "### Corrigido",
+ "",
+ "- Conserto da versão do meio.",
+ "",
+ "## [1.0.0] — 2026-08-01",
+ "",
+ "- Primeira versão.",
+ "",
+ ].join("\n");
+
+ await loginWithTotp(page, creds.users.dono!.email, creds.dono_totp!.secret);
+ await heartbeat(request, { latest_version: "1.2.0", current_version: "1.0.0", changelog });
+ await page.goto("/app/settings/atualizacao");
+
+ // Os DOIS avisos visíveis, o da alvo e o da versão do meio, cada um nomeando
+ // de onde veio.
+ await expect(page.getByText(/Rode o comando de migração antes/)).toBeVisible();
+ await expect(page.getByText(/Reconecte o número depois/)).toBeVisible();
+ await expect(page.getByText(/Da versão 1\.1\.0/)).toBeVisible();
+
+ // E os dois ANTES do botão — medido por ferramenta, nunca a olho: aviso que
+ // aparece depois do clique não é aviso.
+ const y = async (rx: RegExp) => (await page.getByText(rx).boundingBox())!.y;
+ const botao = (await page.getByRole("button", { name: /atualizar agora/i }).boundingBox())!.y;
+ expect(await y(/Rode o comando de migração antes/)).toBeLessThan(botao);
+ expect(await y(/Reconecte o número depois/)).toBeLessThan(botao);
+
+ // O corpo da versão-alvo fica aberto; o da intermediária, recolhido. Texto
+ // dentro de um `` FECHADO não é visível para o Playwright, então o
+ // caso abre e prova pelo estado real do elemento — e nunca põe um AVISO ali
+ // dentro, que é o defeito que esta tela existe para consertar.
+ await expect(page.getByText(/Coisa da versão nova/)).toBeVisible();
+ const recolhido = page.locator("details", { hasText: "Versão 1.1.0" });
+ await recolhido.locator("summary").click();
+ await expect(recolhido).toHaveJSProperty("open", true);
+ await expect(page.getByText(/Conserto da versão do meio/)).toBeVisible();
+
+ await page.screenshot({ path: ".superpowers/evidence/faixa-de-versoes.png" });
+});
+
test("o dono vê a versão nova na sidebar e atualiza pela tela", async ({ page, request }) => {
const changelog =
"## [1.1.0] — 2026-08-02\n\n**⚠️ Requer atenção**\n\nReconecte o número depois.\n\n### Adicionado\n\n- Botão de atualizar pela tela.\n";