mirror of
https://github.com/melgarafael/DeskcommCRM.git
synced 2026-10-02 01:28:34 +08:00
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
This commit is contained in:
co-authored by
Claude Opus 5
parent
2886696231
commit
eed657ddcf
@@ -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.
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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<Response> {
|
||||
}
|
||||
|
||||
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<Response> {
|
||||
// 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,
|
||||
|
||||
@@ -286,23 +286,57 @@ export function UpdatePanel() {
|
||||
</p>
|
||||
)}
|
||||
|
||||
{data.notes?.requires_attention && (
|
||||
{/* Os avisos de TODAS as versões da faixa, reunidos e sempre à vista.
|
||||
Nenhum deles entra em `<details>`: 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 ? (
|
||||
<div className="mb-4 rounded-md border border-warning bg-warning-bg p-3 text-sm text-warning-fg">
|
||||
<p className="mb-1 font-medium">⚠️ Requer atenção</p>
|
||||
<p className="whitespace-pre-line">
|
||||
{markdownParaTextoSimples(data.notes.requires_attention)}
|
||||
</p>
|
||||
{data.notes.requires_attention.map((aviso) => (
|
||||
<div key={aviso.version} className="mt-2">
|
||||
<p className="font-medium">Da versão {aviso.version}:</p>
|
||||
<p className="whitespace-pre-line">{markdownParaTextoSimples(aviso.texto)}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{data.notes?.complete === false && data.notes.sections.length > 0 && (
|
||||
<p className="mb-4 text-sm text-muted-foreground">
|
||||
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.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{data.notes?.body && (
|
||||
{data.notes?.sections.length ? (
|
||||
<div className="mb-6">
|
||||
<p className="mb-2 text-sm font-medium">O que muda</p>
|
||||
<pre className="whitespace-pre-wrap font-sans text-sm text-muted-foreground">
|
||||
{markdownParaTextoSimples(data.notes.body)}
|
||||
</pre>
|
||||
{data.notes.sections.map((secao, i) => (
|
||||
<div key={secao.version} className="mb-3">
|
||||
{/* 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 ? (
|
||||
<pre className="whitespace-pre-wrap font-sans text-sm text-muted-foreground">
|
||||
{markdownParaTextoSimples(secao.body)}
|
||||
</pre>
|
||||
) : (
|
||||
<details>
|
||||
<summary className="cursor-pointer text-sm font-medium">
|
||||
Versão {secao.version}
|
||||
</summary>
|
||||
<pre className="mt-2 whitespace-pre-wrap font-sans text-sm text-muted-foreground">
|
||||
{markdownParaTextoSimples(secao.body)}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
<BotaoAtualizar mutate={() => atualizar.mutate()} isPending={atualizar.isPending} erro={erro} />
|
||||
</Layout>
|
||||
|
||||
@@ -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 <tag>", "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 <tag>",
|
||||
"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": [
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
+88
-1
@@ -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 };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 `<details>` 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";
|
||||
|
||||
Reference in New Issue
Block a user