Compare commits
1 commit
main
...
pool-state
| Author | SHA1 | Date | |
|---|---|---|---|
| b890518cfd |
|
|
@ -1,90 +1,87 @@
|
|||
---
|
||||
description: PRs de agente — Ready (nunca draft), base main, auto-merge nativo do GitHub
|
||||
description: PRs de agente — Ready (nunca draft), base main, auto-merge imediato
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# Agent PR workflow (Teqo)
|
||||
|
||||
**This repo overrides any user-level or Cloud-tool default that says "create PRs as draft".** Agent deliveries merge to `main` via required checks + GitHub's native auto-merge (OPS71). The ISSUE TRACKER stays on the Forgejo — GitHub is only the Actions host.
|
||||
**This repo overrides any user-level or Cloud-tool default that says "create PRs as draft".** Agent deliveries merge to `main` via required checks + auto-merge.
|
||||
|
||||
## Create (Ready, base main)
|
||||
|
||||
```bash
|
||||
pnpm push -u origin HEAD # origin = GitHub (cutover OPS71)
|
||||
```
|
||||
|
||||
Depois, abra a PR no **GitHub** — **Ready**, base **`main`**, title `<id> — <title>`, body:
|
||||
|
||||
```
|
||||
pnpm push -u origin HEAD
|
||||
gh pr create --base main --title "<id> — <title>" --body "$(cat <<'EOF'
|
||||
## O que é
|
||||
<2–4 linhas>
|
||||
|
||||
Closes #<N>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
- **Never** `--draft`. PRs must be **Ready for review** on creation.
|
||||
- Base is always **`main`**.
|
||||
- **Plans-only PRs** (diff só em `docs/plans/`): **nunca** `Closes #N` — use `Related #N`; CI falha com keywords de fechamento (`plans-only-closes` em `ci-pr.yml`).
|
||||
- PR via CLI (não há `gh` — OPS50): `GITHUB_TOKEN=<PAT> node scripts/github-pr.mjs --head <branch> --title "<t>" --body-file <file>` — Ready por construção (não existe flag draft).
|
||||
|
||||
### Cursor Cloud / `ManagePullRequest`
|
||||
|
||||
When the environment says to use `ManagePullRequest` instead of `gh pr create`:
|
||||
|
||||
- **`draft: false`** (or omit only if the tool’s default is Ready — today Cloud defaults to draft, so **always pass `draft: false`**).
|
||||
- Não há auto-merge a armar por PR: o safety net (abaixo) arma o auto-merge nativo.
|
||||
- Never end a turn with an open agent PR left as **Draft** — inclui PRs do `engineering-audit` (artefatos e remediações).
|
||||
- Right after create (or on `update_pr` if you opened draft by mistake): mark Ready if still draft, then enable auto-merge with `gh pr merge --auto --rebase <url|number>` (write allowed for this step — same as `agent-pr-workflow`; do not leave the PR waiting on a human).
|
||||
- Never end a turn with an open agent PR left as **Draft** or without **auto-merge** armed — inclui PRs do `engineering-audit` (artefatos e remediações).
|
||||
|
||||
## Push (determinístico)
|
||||
|
||||
**Use `pnpm push`, never bare `git push`.** Não depende do Husky estar wired:
|
||||
|
||||
1. `ensure-repo-deps` — `pnpm install` se `node_modules` faltar/stale + `pnpm run prepare` (Husky) se hooks não estiverem apontando para `.husky`
|
||||
2. `pnpm gate:push` — espelho do `ci-pr.yml` sem e2e (OPS72: o e2e é passo **explícito da skill** — antes do push rode localmente os e2e que criou + os da mesma superfície, discricionário via `pnpm test:e2e:affected`; o CI de PR roda só o blast radius `selected`, e o full fica no `verify` do deploy manual), com os `docs-guards` locais (OPS63: changelog append-only, agregado sync e marcadores de conflito — o escape `changelog-rewrite:` fica só no CI, body do PR)
|
||||
2. `pnpm gate:push` — espelho do `ci-pr.yml` (inclui `format:check`)
|
||||
3. `git push --no-verify` — só porque o gate já rodou no passo 2 (não é escape hatch)
|
||||
|
||||
```bash
|
||||
pnpm push -u origin HEAD # canonical — origin é o GitHub (OPS71)
|
||||
pnpm push -u origin HEAD # canonical
|
||||
git push # só depois de `pnpm i` — hook repete ensure-deps + gate:push
|
||||
git push --no-verify # WIP / cutover escape — CI repete tudo; ver AGENT-OPS
|
||||
```
|
||||
|
||||
## Auto-merge immediately
|
||||
|
||||
The safety net (`agent-pr-ready-automerge.yml`, abaixo) arms GitHub's **native auto-merge** (rebase) — the server merges the PR when the required check passes. There is nothing to arm per PR. Do not wait for human approval on normal deliveries.
|
||||
Right after the PR exists and is **Ready**:
|
||||
|
||||
## Required checks
|
||||
```bash
|
||||
gh pr merge --auto --rebase
|
||||
```
|
||||
|
||||
Branch protection on `main` (GitHub, applied at the OPS71 cutover via `pnpm configure:branch-protection`) requires only:
|
||||
Do not wait for human approval on normal deliveries. Auto-merge fires when **required** checks pass.
|
||||
|
||||
- `CI (PR) / checks` — the ci-pr single-job check-run (OPS62: one sequential job posts one honest verdict; no matrix, no shards, no rollup to lie). **Literal de match na proteção: `checks`** (o GitHub casa pelo nome do check-run/job — a UI exibe `CI (PR) / checks`; pin ao vivo PR #742).
|
||||
## Required checks vs Vercel
|
||||
|
||||
OPS64 history: the Forgejo runner could report the LAST matrix shard's result as the parent's (runs 604/607/610) — that bug is gone by construction: ci-pr.yml is a SINGLE sequential job, and GitHub's native auto-merge only merges with every required check green (server-side guarantee). The safety-net CLI has no poll path to port.
|
||||
Branch protection on `main` requires only:
|
||||
|
||||
(`migration-lock` removed 2026-08-12 — the CI validates migration chains via migrate + int on every PR.)
|
||||
- `checks` (GitHub Actions rollup from `ci-pr.yml`)
|
||||
|
||||
**There is no deploy from PR CI.** Publishing is a **manual `workflow_dispatch`** (`.github/workflows/deploy.yml`): `verify` runs the full suite (hosted) and only then the `deploy` job (self-hosted runner on the homeserver) publishes `jorgesolla1313.com.br` — migrations applied before the rollout. Do not add a Vercel/Git-integration deploy gate.
|
||||
(`migration-lock` removed 2026-08-12 — the CI validates migration chains via migrate + int on every PR/main.)
|
||||
|
||||
When watching CI, use the PR page (or the API) — the safety net arms the server-side auto-merge; `gh` does not exist in this repo (OPS50).
|
||||
**Vercel preview is NOT a merge gate.** Production deploy is Actions-gated (`ci.yml` → `vercel deploy --prod` after full suite green). Do not block on Vercel Git status.
|
||||
|
||||
Use the required check `CI (PR) / checks` — without it, you are not covering the cascade.
|
||||
When watching CI:
|
||||
|
||||
```bash
|
||||
gh pr checks <PR> --watch --required
|
||||
```
|
||||
|
||||
Use `--required` — without it, `--watch` also waits for Vercel.
|
||||
|
||||
## Agent branches skip Vercel builds
|
||||
|
||||
All branches skip Vercel Git builds via `scripts/vercel-ignore-build.sh` (deploys only from the main verifier Action).
|
||||
|
||||
## Promote
|
||||
|
||||
There is no stage→main promote. Merging to `main` is the delivery; publishing is a deliberate manual dispatch with full verification (runbook: `docs/ops/teqo-1313-deploy.md`).
|
||||
There is no stage→main promote. Merging to `main` is the delivery; `ci.yml` verifies and deploys.
|
||||
|
||||
## Safety net (workflow)
|
||||
## Safety net (GitHub Action)
|
||||
|
||||
`agent-pr-ready-automerge.yml` — PRs same-repo → `main` (todas as branches).
|
||||
O workflow roda `scripts/github-pr-automerge.mjs` (plain Node, zero-dep,
|
||||
REST+GraphQL): se draft `cursor/*` → marca Ready (PATCH `draft: false`); arm
|
||||
`enablePullRequestAutoMerge` (GraphQL, mergeMethod REBASE) no `node_id` do PR
|
||||
— o **servidor** espera o required check `CI (PR) / checks` POSTADO e verde
|
||||
antes de mergear; o próprio check-run do safety net fica `pending` durante o
|
||||
run e não interfere. A regra de branch protection no servidor
|
||||
(`enforce_admins: true`) bloqueia qualquer caminho de merge — inclusive API
|
||||
manual — enquanto o required check não estiver satisfeito (foi um merge
|
||||
manual que colocou a PR #52 em main com o CI vermelho na era Forgejo).
|
||||
Cobre o caso em que o
|
||||
agente (ou o default draft do Cloud) esqueceu e PRs humanas/worktree com merge
|
||||
falho silencioso (incidente OPS52). **Draft de branch não-`cursor/*` nunca é
|
||||
tocado** — o draft é o veto do ator (skip do CLI). Não há opt-out por skill
|
||||
(audit incluído).
|
||||
`agent-pr-ready-automerge.yml` — PRs `cursor/*` → `main`: se Draft → `gh pr ready`; sempre arma `gh pr merge --auto --rebase`. Cobre o caso em que o agente (ou o default draft do Cloud) esqueceu. Não há opt-out por skill (audit incluído).
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ Established 2026-07-23 by the engineering-standards hardening (tracker: `docs/IM
|
|||
|
||||
1. **Dead code dies immediately.** After your edits, check what your change orphaned and delete it in the same delivery: run `pnpm exec knip` (files/dependencies are CI-blocking errors; unused exports are warnings being driven to zero). Never leave commented-out code, unused exports, superseded components, or one-shot scripts behind — git history is the archive.
|
||||
2. **Zero warnings.** `pnpm lint` runs with `--max-warnings=0`. Deliberate placeholders use a `_` prefix. Never silence a rule without an inline justification comment.
|
||||
3. **Gate em duas velocidades (desde 2026-07-30, paradigma de agentes paralelos — `docs/AGENT-OPS.md`).** **Fast gate local do agente** (iteração durante a sessão): `pnpm gate:fast` (lint + typecheck + test:unit). **Push canônico:** `pnpm push` — `ensure-repo-deps` (install + Husky em worktrees) + `git push`; o hook pre-push roda `gate:push` → `gate:ci` (espelho serial do `ci-pr.yml` **sem e2e** — OPS72: o e2e é passo **explícito da skill** (`work-issue`/`agent-work-issue`): antes do push rode localmente os e2e que criou + os da mesma superfície, **discricionário** via `pnpm test:e2e:affected`; o CI de PR roda só o blast radius `selected` — nunca `full` — e o full fica no `verify` do deploy manual; **com os `docs-guards` locais** — OPS63: changelog append-only, agregado sync e marcadores de conflito, mesmos scripts do CI; o escape `changelog-rewrite:` fica só no CI, body do PR). Não use `git push` nu. Fonte única: scripts `gate:*` e `scripts/git-push.mjs` em `package.json`; escape para WIP: `git push --no-verify`. Migrations NÃO são serializadas entre PRs: a cadeia é validada por `pnpm migrate` + `test:int` (incl. `campaignMigrationReconciliation`) em todo PR; rebase antes de `migrate:create` continua obrigatório (o antigo `migration-lock` foi removido em 2026-08-12 — deadlock de CI). Entrega = merge em `main`; publish gated pelo `verify` do `deploy.yml` (dispatch manual — OPS71; não há `ci.yml` verificador de main). **CI vermelho no teu PR é teu, mesmo pré-existente ou de infra** — "fora do escopo da feature" não é critério de parada (política "Dono do PR, dono do CI" em `docs/AGENT-OPS.md`).
|
||||
3. **Gate em duas velocidades (desde 2026-07-30, paradigma de agentes paralelos — `docs/AGENT-OPS.md`).** **Fast gate local do agente** (iteração durante a sessão): `pnpm gate:fast` (lint + typecheck + test:unit). **Push canônico:** `pnpm push` — `ensure-repo-deps` (install + Husky em worktrees) + `git push`; o hook pre-push roda `gate:push` → `gate:ci` (espelho serial do `ci-pr.yml`, inclui `format:check`). Não use `git push` nu. Fonte única: scripts `gate:*` e `scripts/git-push.mjs` em `package.json`; escape para WIP: `git push --no-verify`. Migrations NÃO são serializadas entre PRs: a cadeia é validada por `pnpm migrate` + `test:int` (incl. `campaignMigrationReconciliation`) em todo PR e na main; rebase antes de `migrate:create` continua obrigatório (o antigo `migration-lock` foi removido em 2026-08-12 — deadlock de CI). Entrega = merge em `main`; deploy gated pelo verificador `ci.yml`. **CI vermelho no teu PR é teu, mesmo pré-existente ou de infra** — "fora do escopo da feature" não é critério de parada (política "Dono do PR, dono do CI" em `docs/AGENT-OPS.md`).
|
||||
4. **Run gate commands bare, never piped** (`pnpm test | tail` swallows the exit code). Never run `knip --fix` blind — verify each removal with `git grep -w <symbol>` first (knip still cannot load `payload.config.ts`, so its graph is incomplete; ledgered as P3).
|
||||
|
||||
## Type honesty
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
name: agent-pool
|
||||
description: >-
|
||||
Opera e monitora o pool de agentes Cursor Cloud do Teqo (supervisor remoto
|
||||
determinístico em Forgejo Actions). Ao invocar: SEMPRE status + fila elegível
|
||||
determinístico em GitHub Actions). Ao invocar: SEMPRE status + fila elegível
|
||||
primeiro; NUNCA liga o pool sem confirmação explícita do usuário. Usar quando
|
||||
o usuário pedir /agent-pool, ligar/desligar/pausar o pool, ver status dos
|
||||
workers, "pool de agentes", orquestrador, triage de Issue que o pool mandou
|
||||
|
|
@ -11,11 +11,7 @@ description: >-
|
|||
|
||||
# Agent pool — operação do supervisor remoto
|
||||
|
||||
**DORMENTE (OPS65):** o workflow `agent-pool.yml` foi removido (o tick `*/10`
|
||||
custava 144 `pnpm install`/dia na workstation) e o dispatch via CLI falha 404 —
|
||||
scripts/skill ficam para histórico e leitura. O que foi:
|
||||
|
||||
O pool mantém até **5 Cursor Cloud Agents** (configurável, máx 12) rodando `agent-work-issue` sobre Issues `ready` elegíveis para autonomia. O supervisor é **determinístico** (não é um agente): workflow `.forgejo/workflows/agent-pool.yml` (removido) com tick stateless a cada 10 min + a cada merge em `main` + sob dispatch, rodando `scripts/agent-pool.mjs`. Arquitetura e elegibilidade: `docs/plans/agent-pool-orchestrator.md`.
|
||||
O pool mantém até **5 Cursor Cloud Agents** (configurável, máx 12) rodando `agent-work-issue` sobre Issues `ready` elegíveis para autonomia. O supervisor é **determinístico** (não é um agente): workflow **`.github/workflows/agent-pool.yml`** (tick stateless a cada 10 min + a cada merge em `main` + sob dispatch) rodando `scripts/agent-pool.mjs`. Arquitetura e elegibilidade: `docs/plans/agent-pool-orchestrator.md`.
|
||||
|
||||
**O pool NUNCA deploya** — deploy gated fica em `ci.yml` após merge em `main`.
|
||||
|
||||
|
|
@ -23,7 +19,7 @@ O pool mantém até **5 Cursor Cloud Agents** (configurável, máx 12) rodando `
|
|||
|
||||
## Fluxo obrigatório ao invocar (não pule)
|
||||
|
||||
**Nunca** rode `pnpm agent:pool -- start` no primeiro passo — e o dispatch do workflow não existe mais (OPS65: `start`/`stop` via CLI falham 404). O default é só leitura.
|
||||
**Nunca** rode `pnpm agent:pool -- start` (nem `gh workflow run … action=start`) no primeiro passo. O default é só leitura.
|
||||
|
||||
```
|
||||
- [ ] 1. Pré-check leve: `pnpm agent:pool -- doctor` se for a 1ª vez na sessão / suspeita de secret; senão pode ir direto ao status
|
||||
|
|
@ -91,7 +87,7 @@ pnpm agent:pool -- resume
|
|||
pnpm agent:pool -- status # leitura local (config, slots, fila, exclusões)
|
||||
```
|
||||
|
||||
Os wrappers disparam `workflow_dispatch` em `agent-pool.yml` via API do Forgejo (workflow removido no OPS65 — dispatch falha 404); acompanhe com `pnpm agent:pool -- status` (o job summary de cada run era a superfície remota de status: slots, spawns, falhas, decisão do tick). Estado escalar vive em `pool-state.json` na branch `pool-state`; workers/Issues em voo são derivados — Issues `in-progress` com marcador `pool-worker` nos comentários.
|
||||
Os wrappers disparam `gh workflow run agent-pool.yml -f action=…`; acompanhe com `gh run list --workflow agent-pool.yml --limit 3` (o job summary de cada run é a superfície remota de status: slots, spawns, falhas, decisão do tick). Estado escalar vive em repo variables `POOL_*` (Settings → Variables); workers/Issues em voo são derivados — Issues `in-progress` com marcador `pool-worker` nos comentários.
|
||||
|
||||
## Ciclo de vida de uma Issue no pool
|
||||
|
||||
|
|
|
|||
|
|
@ -33,9 +33,9 @@ Conduz **uma** Issue já claimada do plano de implementação ao merge em `main`
|
|||
- [ ] 1. Sessão: rename_chat + open_resource na intenção
|
||||
- [ ] 2. Modelo: spawn do pool já fixou (`model:` da Issue) — sem comparação
|
||||
- [ ] 3. Plan mode → plano de implementação (`*-impl.md`)
|
||||
- [ ] 4. Executar o impl plan (schema/server → UI → e2e local afetado → gates)
|
||||
- [ ] 4. Executar o impl plan (schema/server → UI → gates)
|
||||
- [ ] 5. /simplify → fixes; capture-review-debts (modo autônomo)
|
||||
- [ ] 6. PR Ready `--base main` (GitHub) + auto-merge nativo — `pnpm push -u origin HEAD` → `node scripts/github-pr.mjs` (ou `ManagePullRequest` `draft: false` no Cloud)
|
||||
- [ ] 6. PR Ready `--base main` + auto-merge + checks --required
|
||||
```
|
||||
|
||||
## Passo 0 — Prep
|
||||
|
|
@ -79,20 +79,13 @@ O spawn do pool já fixou o modelo (`model:` da Issue — ver `model-selection`)
|
|||
(executar → simplify → fechar em main), com os deltas do ator pool:
|
||||
|
||||
- **Branch:** `agent/<id>-<slug>` (worktrees Cursor podem já ter criado).
|
||||
- **E2E local afetado (OPS72):** mesmo passo discricionário do ator humano
|
||||
(rodar os e2e criados + da mesma superfície — ver `execution-pipeline.md`);
|
||||
**worktree com browsers Playwright** → rode; **Cursor Cloud sem browsers** →
|
||||
registre a justificativa no fechamento e siga — o CI de PR roda o blast
|
||||
radius quando o diff classifica `selected`; em diff high-risk (`full`) o PR
|
||||
não roda e2e e a rede final é o `verify` do deploy.
|
||||
- **UI:** shape → craft → critique → polish (harden/optimize só sob gatilho).
|
||||
- **`capture-review-debts`:** **autônomo** — só `expensive_lock` com score ≥4
|
||||
(Issues novas com `depends: [<id-pai>]` se necessário); score ≤3 /
|
||||
cheap_polish / defer_trigger → defer com gatilho no `*-impl.md` ou descarte —
|
||||
**não** polua a fila.
|
||||
- **Cloud:** PR via `ManagePullRequest` com `draft: false` no **GitHub**
|
||||
(repo/PR agora no GitHub; o tracker de Issues segue no Forgejo — OPS71);
|
||||
Prep Cloud no Passo 0.
|
||||
- **Cloud:** PR via `ManagePullRequest` com `draft: false`; Prep Cloud no
|
||||
Passo 0.
|
||||
|
||||
## Resumo final
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
---
|
||||
name: capture-review-debts
|
||||
description: Use when the user asks to register leftover /simplify or /impeccable findings as trackable Forgejo Issues, harvest session review debts, triage post-simplify or post-critique follow-ups, or says "registra os débitos", "o que ficou do simplify", "coloca como issue o que o critique apontou", "harvest debts".
|
||||
description: Use when the user asks to register leftover /simplify or /impeccable findings as trackable GitHub Issues, harvest session review debts, triage post-simplify or post-critique follow-ups, or says "registra os débitos", "o que ficou do simplify", "coloca como issue o que o critique apontou", "harvest debts".
|
||||
---
|
||||
|
||||
# Capturar débitos de /simplify e /impeccable em Issues
|
||||
|
|
@ -15,7 +15,7 @@ Após um ciclo de entrega, `/simplify` e `/impeccable` deixam achados **maiores
|
|||
|
||||
```
|
||||
- [ ] 1. Colher candidatos da sessão (simplify + impeccable)
|
||||
- [ ] 2. Deduplicar contra código, Issues (`pnpm issue all`) e planos
|
||||
- [ ] 2. Deduplicar contra código, Issues (gh issue list) e planos
|
||||
- [ ] 3. Pontuar importância e classificar destino
|
||||
- [ ] 4. Mesclar relacionados (mesmo lote / mesma superfície)
|
||||
- [ ] 5. Apresentar tabela de triage e obter confirmação
|
||||
|
|
@ -50,7 +50,7 @@ Contra o repositório **antes** de pontuar:
|
|||
| Check | Ação se verdadeiro |
|
||||
| ------------------------------------------------------ | --------------------------------------------------------------- |
|
||||
| Já no diff / código da sessão | → balde **já_resolvido** (não reabrir) |
|
||||
| Já em `docs/plans/*.md` ou numa Issue (grep + `pnpm issue all`) | → **absorver** no plano existente ou **descartar** como coberto |
|
||||
| Já em `docs/plans/*.md` ou numa Issue (grep + `gh issue list`) | → **absorver** no plano existente ou **descartar** como coberto |
|
||||
| Pré-existente em `main`, fora do escopo da entrega | → **descartar** deste lote (bug separado só se o usuário pedir) |
|
||||
| Intentional em `DESIGN.md` / decisão travada | → **descartar** (ex.: field-mode mobile documentado) |
|
||||
|
||||
|
|
@ -144,7 +144,7 @@ Para cada lote com destino **registrar** ou **absorver**:
|
|||
|
||||
Não implemente as fases aqui. Execução é via `work-issue` / `agent-work-issue`, só se o usuário (ou o pipeline autônomo) seguir.
|
||||
|
||||
**Próximo no fluxo de entrega:** após a triage (confirmada ou modo autônomo), o fechamento segue o Passo 6 de `agent-work-issue` / `work-issue` (`pnpm push` → PR no GitHub `--base main` com `Closes #N` → auto-merge nativo → CI).
|
||||
**Próximo no fluxo de entrega:** após a triage (confirmada ou modo autônomo), o fechamento segue o Passo 6 de `agent-work-issue` / `work-issue` (`pnpm push` → PR `--base main` com `Closes #N` → auto-merge → CI).
|
||||
|
||||
## Modo autônomo (`work-issue` pós-plano / `agent-work-issue` / pool)
|
||||
|
||||
|
|
|
|||
|
|
@ -5,15 +5,15 @@ description: Runs a Pass-style engineering audit of the Teqo codebase — code s
|
|||
|
||||
# Engineering Audit (Pass N)
|
||||
|
||||
**Audit solitário (não paralelizável):** quando esta skill roda, o desenvolvimento paralelo **pausa** — nenhum outro agente trabalhando no repo. O agente do audit também **executa as remediações P0/P1 na mesma sessão** para trazer o projeto aos trilhos (não só registra); P2/P3 seguem o fluxo normal de ledger. O **agent-pool está morto desde OPS65** (workflow removido; scripts/skills dormentes) — nada a pausar.
|
||||
**Audit solitário (não paralelizável):** quando esta skill roda, o desenvolvimento paralelo **pausa** — nenhum outro agente trabalhando no repo. O agente do audit também **executa as remediações P0/P1 na mesma sessão** para trazer o projeto aos trilhos (não só registra); P2/P3 seguem o fluxo normal de ledger. Se o **agent-pool** estiver ligado, pause-o no início do audit (`gh workflow run agent-pool.yml -f action=pause`) e retome ao final (`-f action=resume`); o tick recusa spawn enquanto `POOL_PAUSED=true`. Em **Cursor Cloud** o `gh` do agente é read-only e o pause por dispatch não está disponível — vale o precheck fail-closed da seção "Modo autônomo (Cursor Cloud)".
|
||||
|
||||
## Modo autônomo (Cursor Cloud)
|
||||
|
||||
A skill roda sem supervisão humana no paradigma de agentes paralelos. Quatro diferenças em relação à execução interativa (desktop):
|
||||
|
||||
1. **Precheck solitário, fail-closed.** Antes de qualquer varredura: `pnpm agent:pool -- status` (read-only). Pool `desligado` ou `pausado` → segue. Pool **ligado** → o agente Cloud não consegue pausá-lo (dispatch de workflow é escrita): **pare** com o remédio nomeado ("pause o pool — `pnpm agent:pool -- pause` — e re-dispare o audit"). Nunca rodar a varredura com o pool spawnando.
|
||||
1. **Precheck solitário, fail-closed.** Antes de qualquer varredura: `pnpm agent:pool -- status` (read-only). Pool `desligado` ou `pausado` → segue. Pool **ligado** → o agente Cloud não consegue pausá-lo (`gh workflow run` é escrita): **pare** com o remédio nomeado ("pause o pool — `pnpm agent:pool -- pause` — e re-dispare o audit"). Nunca rodar a varredura com o pool spawnando.
|
||||
2. **Entrega = PR Ready + auto-merge.** O gate "apresentar os três artefatos antes de escrever" (passo 5) não se aplica em modo autônomo — não há humano interativo na sessão. Os artefatos são escritos na branch e o **PR Ready → `main` com auto-merge** (mesmo contrato `agent-pr-workflow`) é a entrega do plano. Fechar o PR antes do merge = rejeição (o ledger sobrevive no histórico da branch). As remediações **P0/P1 e os guardrails das misses colhidas** seguem na mesma sessão, cada um em PR próprio com gate completo + auto-merge. P2/P3 continuam só ledger/plano.
|
||||
3. **Canais de PR:** fechar miss colhida = `Closes #N` no body do PR do guardrail (o flip `done`/`in-prod` no merge em `main` — keyword adjacente a cada número, `Closes #52, closes #73`, nunca `Closes #52, #73`). Em Cloud, usar `ManagePullRequest` com **`draft: false`** (o merge fica com o safety net `agent-pr-ready-automerge`). **Sem exceção:** o PR dos *artefatos* do audit também é Ready + auto-merge (igual remediações P0/P1 e guardrails). Push: `pnpm push -u origin HEAD`.
|
||||
3. **Canais de PR:** fechar miss colhida = `Closes #N` no body do PR do guardrail (o flip `done`/`in-prod` no merge em `main` — keyword adjacente a cada número, `Closes #52, closes #73`, nunca `Closes #52, #73`). Em Cloud, preferir `ManagePullRequest` com **`draft: false`**, depois `gh pr merge --auto --rebase`. **Sem exceção:** o PR dos *artefatos* do audit também é Ready + auto-merge (igual remediações P0/P1 e guardrails). Push: `pnpm push -u origin HEAD`.
|
||||
4. **Delta desde o último audit é o foco declarado** (passo 1): a varredura cobre o repo inteiro, mas o esforço concentra-se no churn desde a data do último Pass — é o trabalho paralelo dos agentes desde o último audit que precisa ser consolidado.
|
||||
|
||||
Read-only audit that sweeps the Teqo codebase for code smells, drift from documented patterns, and consolidation opportunities, then produces a findings ledger and a remediation plan. **No fixes in the audit run** — deliverables are updated docs, presented for sign-off before writing — exceto as remediações P0/P1 declaradas acima. Sign-off: interativo = apresentação prévia dos artefatos; autônomo (Cursor Cloud) = PR Ready + auto-merge dos artefatos (ver "Modo autônomo (Cursor Cloud)").
|
||||
|
|
@ -39,7 +39,7 @@ Method: the engineering skills this repo follows (improve-code-quality, clean-co
|
|||
1. Read-only on `src/`, `tests/`, `scripts/` durante a varredura. Os únicos writes da sessão são: os artefatos do passo 5 (após sign-off interativo; em modo autônomo, direto na branch do PR) e as remediações P0/P1 + guardrails de misses colhidas declarados no cabeçalho.
|
||||
2. Gate commands bare, never piped (`pnpm test | tail` swallows the exit code).
|
||||
3. Never `knip --fix` blind — verify with `git grep -w <symbol>` (knip cannot load `payload.config.ts`; ledgered P3).
|
||||
4. Production is live Postgres on the homeserver (`teqo_1313`) with real PII. Local DB only, `teqo_test` for tests. The audit needs no DB writes.
|
||||
4. Production is live Neon with real PII. Local DB only, `teqo_test` for tests. The audit needs no DB writes.
|
||||
5. Every claim gets a number (lines, exports, call sites, ms, kB). "Rejected by measurement" is an acceptable outcome for any hypothesis, including the ones below.
|
||||
|
||||
## Step 0a — Load the canon
|
||||
|
|
@ -63,12 +63,12 @@ Read, in this order:
|
|||
Antes da varredura, colha os défices comportamentais registrados pelo fluxo:
|
||||
|
||||
```bash
|
||||
pnpm issue all # kind `agent-miss` nos contadores; ou api.listIssues({ state: 'open', labels: 'kind:agent-miss' })
|
||||
gh issue list --label kind:agent-miss --state open
|
||||
```
|
||||
|
||||
**Investigue cada miss colhida** (leitura da Issue via API do Forgejo/MCP — disponível no Cloud): o corpo registra causa raiz, fix aplicado e o guardrail proposto por `agent:file-miss`. Verifique no código se o fix segurou e se a classe do defeito ainda é reproduzível. Miss cuja classe já tem guarda viva não gera guardrail novo — gera item de hardening da guarda existente (o passo 2 já trata guarda dodgeable como finding).
|
||||
**Investigue cada miss colhida** (`gh issue view <N>` — leitura, disponível no Cloud): o corpo registra causa raiz, fix aplicado e o guardrail proposto por `agent:file-miss`. Verifique no código se o fix segurou e se a classe do defeito ainda é reproduzível. Miss cuja classe já tem guarda viva não gera guardrail novo — gera item de hardening da guarda existente (o passo 2 já trata guarda dodgeable como finding).
|
||||
|
||||
Cada Issue colhida alimenta o **Passo 4b (recurrence prevention)**: toda miss vira candidata a guardrail determinístico (tipo / ESLint / convention spec / CI / pin comportamental). O ledger desses guardrails vive em **`docs/GUARDRAILS.md`** (criar o arquivo na primeira execução — é o escopo da Issue OPS2): uma linha por guardrail — miss de origem (link da Issue), classe 1–6, mecanismo, status. Quando o guardrail mergeia, **feche as Issues colhidas** (`done`) com comentário apontando o guardrail. Em Cursor Cloud o fechamento é determinístico e não exige escrita manual: o PR do guardrail carrega `Closes #N` (keyword repetida por número — o regex do `issue-done-on-main-merge.yml` não resolve `Closes #A, #B`) e o merge em `main` flippa a miss para `done`; guardrails de várias misses podem ir no mesmo PR desde que cada uma tenha seu `closes`. Miss que não admite guardrail determinístico vira convenção explícita marcada "judgment-only" — não fingir que doc é guarda.
|
||||
Cada Issue colhida alimenta o **Passo 4b (recurrence prevention)**: toda miss vira candidata a guardrail determinístico (tipo / ESLint / convention spec / CI / pin comportamental). O ledger desses guardrails vive em **`docs/GUARDRAILS.md`** (criar o arquivo na primeira execução — é o escopo da Issue OPS2): uma linha por guardrail — miss de origem (link da Issue), classe 1–6, mecanismo, status. Quando o guardrail mergeia, **feche as Issues colhidas** (`done`) com comentário apontando o guardrail. Em Cursor Cloud o fechamento é determinístico e não exige escrita `gh`: o PR do guardrail carrega `Closes #N` (keyword repetida por número — o regex do `issue-done-on-stage-merge.yml` não resolve `Closes #A, #B`) e o merge em `stage` flippa a miss para `done`; guardrails de várias misses podem ir no mesmo PR desde que cada uma tenha seu `closes`. Miss que não admite guardrail determinístico vira convenção explícita marcada "judgment-only" — não fingir que doc é guarda.
|
||||
|
||||
## Step 1 — Hotspot map
|
||||
|
||||
|
|
@ -166,7 +166,7 @@ Rules:
|
|||
1. `docs/IMPROVE-CODE-QUALITY-PLAN.md` — new Pass section (context, audit headlines with numbers, workstreams, decisions), matching earlier-pass format.
|
||||
2. `docs/TECH-DEBT.md` — new rows in the same tables/columns, marked `open — Pass N`; stale rows closed with evidence.
|
||||
3. `docs/plans/entrega-engenharia-pN.md` (pt-BR, like the other plans) — workstreams ordered P0→P1→P2; each item: goal, evidence, target shape, migration path (Branch by Abstraction / Parallel Change for wide merges), pins to write first, full gate, rollback. Items too big for one delivery → Issue rastreável via `plan-issue`. Every workstream item declares its **Guarda determinística** (class 1–6, step 4b), and the plan carries a **guard map** section: new guards shipped / existing guards hardened / judgment-only residue with the convention that stands in.
|
||||
4. **Interativo (desktop):** present ALL THREE for sign-off before writing. Then write and stop. **Autônomo (Cursor Cloud):** não há sign-off interativo — escreva os três na branch e abra o PR (**Ready**, base `main` — mesmo fluxo `agent-pr-workflow`; "Modo autônomo", item 2; o merge fica com o safety net). As remediações P0/P1 e os guardrails das misses colhidas seguem na mesma sessão, em PRs próprios com gate completo + auto-merge, sem esperar o merge dos artefatos.
|
||||
4. **Interativo (desktop):** present ALL THREE for sign-off before writing. Then write and stop. **Autônomo (Cursor Cloud):** não há sign-off interativo — escreva os três na branch e abra o PR (**Ready**, base `main`, **`gh pr merge --auto --rebase`** — mesmo fluxo `agent-pr-workflow`; "Modo autônomo", item 2). As remediações P0/P1 e os guardrails das misses colhidas seguem na mesma sessão, em PRs próprios com gate completo + auto-merge, sem esperar o merge dos artefatos.
|
||||
|
||||
## Execution rules for the eventual implementation (record in the plan)
|
||||
|
||||
|
|
|
|||
|
|
@ -70,10 +70,9 @@ PROD_DATABASE_URL="<unpooled Neon URL>" pnpm db:pull
|
|||
pnpm db:seed:posts
|
||||
```
|
||||
|
||||
- `scripts/seed-posts.mjs` fetches ~43 articles **live** from jorgesolla.com.br (WordPress REST API, HTML-crawl fallback) and imports them into `post`/`tag`/`media` as published `noticia` posts, converting the WP HTML body to Payload Lexical and uploading covers through the configured media storage (Garage S3 when the `S3_*` envs are set — the adapter overwrites the deterministic `<slug>.<ext>` key natively; local disk otherwise).
|
||||
- `scripts/seed-posts.mjs` fetches ~39 articles **live** from jorgesolla.com.br (WordPress REST API, HTML-crawl fallback) and imports them into `post`/`tag`/`media` as published `noticia` posts, converting the WP HTML body to Payload Lexical and uploading covers to Vercel Blob.
|
||||
- Same safety guard as `pnpm dev`: it refuses a non-local `DATABASE_URL` unless `ALLOW_REMOTE_DB=true`.
|
||||
- **Idempotent by slug** — re-running skips existing posts and reuses tags/media, so it never duplicates.
|
||||
- **Bucket-write guard (OPS60):** with the `S3_*` envs set (e.g. a worktree with the main repo's prod-credentials copy), the sync requires explicit write intent — `SEED_MEDIA_CONFIRM=1` — and refuses with exit 1 before any write without it; `--dry-run` stays flag-free (it never writes).
|
||||
- **Idempotent by slug** — re-running skips existing posts and reuses tags/media, so it never duplicates. Because the Vercel Blob store is shared across environments, it deletes any orphan blob under the deterministic `<slug>.<ext>` key before uploading, so it stays re-runnable.
|
||||
- **Post-seed revalidation (required for prod):** the seed writes from a CLI process outside the deployed runtime, so it does **not** bust production's frozen `posts` cache. After seeding prod content — or after ANY direct-DB write — bust it:
|
||||
|
||||
```bash
|
||||
|
|
|
|||
|
|
@ -89,4 +89,4 @@ Bipartir (`{id}-plan` Grok High → `{id}-exec` `kimi-k3-low`) só sob pedido ex
|
|||
- **`work-issue`:** **não verifica modelo** — a sessão nasce no modelo fixo do ambiente (máquina do humano: DeepSeek V4 Flash). `model:` da Issue é metadata consultiva (o pool spawna nele; o claim brief o imprime).
|
||||
- **`agent-work-issue`:** pool já fixou o modelo no spawn; sessão mais forte → informa e segue.
|
||||
- **`plan-issue`:** sugere via esta tabela (slug único completo com effort); bipartição só sob blast radius extremo / pedido explícito.
|
||||
- ~~`agent-pool` smoke~~ — **dormente (OPS65)**: workflow removido; pool morto.
|
||||
- **`agent-pool` smoke:** Composer default; Grok com effort explícito / Kimi K3 Low em Issues de teste bipartidas.
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
---
|
||||
name: plan-issue
|
||||
description: >-
|
||||
Transforma ideias humanas em Issues do Forgejo + planos de intenção em
|
||||
Transforma ideias humanas em Issues GitHub + planos de intenção em
|
||||
docs/plans/ (persona, fluxo, objetivo, direção suave no código — sem
|
||||
decisões duras de engenharia). Se o item muda UI, apresenta no gate um
|
||||
rascunho visual HTML+Tailwind (PNG embutido no plano de intenção). Divide
|
||||
|
|
@ -13,7 +13,7 @@ disable-model-invocation: true
|
|||
|
||||
# Planejar Issues (intenção, não engenharia)
|
||||
|
||||
Esta skill transforma ideias soltas em: (1) um **plano de intenção** em `docs/plans/<slug>.md` por item, e (2) uma **Issue rastreável no Forgejo** (`pnpm agent:register`, frontmatter `id/depends/serializes/priority/model`). As Issues do Forgejo (`git.solla.dev/fsolla/teqo`) são a fonte canônica de spec/status/deps/prio/modelo — `docs/roadmap.md` é legado congelado e **nunca** é editado aqui.
|
||||
Esta skill transforma ideias soltas em: (1) um **plano de intenção** em `docs/plans/<slug>.md` por item, e (2) uma **GitHub Issue rastreável** (`pnpm agent:register`, frontmatter `id/depends/serializes/priority/model`). GitHub Issues são a fonte canônica de spec/status/deps/prio/modelo — `docs/roadmap.md` é legado congelado e **nunca** é editado aqui.
|
||||
|
||||
## Ciclo de vida (obrigatório)
|
||||
|
||||
|
|
@ -29,7 +29,7 @@ rascunho local (Issue: —)
|
|||
|
||||
**Regras duras:**
|
||||
|
||||
1. **Nada no Forgejo antes do gate.** Antes da confirmação explícita do Passo 5: proibido `pnpm agent:register`, criar Issue/PR / push de PR de planos. Planos locais (`Issue: —`) ok.
|
||||
1. **Nada no GitHub antes do gate.** Antes da confirmação explícita do Passo 5: proibido `pnpm agent:register`, `gh issue create`, `gh pr create` / push de PR de planos. Planos locais (`Issue: —`) ok.
|
||||
2. **Confirmação = OK ao overview do lote** (ex. “confirma”, “pode registrar”). “Ok” ambíguo no meio da edição **não** dispara o Passo 6.
|
||||
3. **Register com `--plan` não nasce `ready`.** O script aplica `blocked` automaticamente quando `--plan` está presente (`--blocked` explícito ainda vale para chores sem plano). Sem `--plan` (chore body-only / `file-miss`), pode nascer `ready`.
|
||||
4. **Promote só depois do plano em `main`.** Nunca flipar para `ready` com o PR ainda aberto — isso recria a race de claim. Caminhos: (A) `pnpm agent:ready -- --issue N` no fim do Passo 6 após merge; (B) Action determinística no merge que lê `Related #N` (OPS18). Ambos idempotentes.
|
||||
|
|
@ -67,7 +67,7 @@ Aqui **não** se implementa código de produto, **não** se escreve plano de imp
|
|||
1. **Separe os itens.** Entrada pode ser 1 ideia ou N. Se ambíguo, assuma a leitura mais provável e liste — a confirmação vai no gate.
|
||||
2. **Fatia mínima útil.** Prefira várias Issues pequenas a um epic. Cada item deve caber num appetite curto e entregar um outcome verificável sozinho. Mesclar só quando separar criaria trabalho inútil (mesmo fluxo, mesma persona, mesma superfície sem valor incremental).
|
||||
3. **Dedup intra-lote:** mesclar | absorver (fase de plano existente, sem ID novo) | manter separados com `depends`.
|
||||
4. **Dedup contra o existente:** `pnpm issue all` + `issuesById()` + grep em `docs/plans/*.md` e `docs/roadmap.md` (legado).
|
||||
4. **Dedup contra o existente:** `gh issue list --state all` + `issuesById()` + grep em `docs/plans/*.md` e `docs/roadmap.md` (legado).
|
||||
- Já coberto / entregue → apontar e não criar.
|
||||
- Issue **`in-progress` / `done` / `in-prod`:** **não** editar o plano dela — se a intenção mudou, item **sucessor** (plano + Issue novos).
|
||||
- Issue só `blocked` / `ready` (ainda não claimada): pode editar o plano existente (fase de plano) sem ID novo.
|
||||
|
|
@ -150,7 +150,7 @@ Sem `--plan`: nasce `ready` (use `--blocked` só se quiser não-claimável sem p
|
|||
|
||||
2. Atualize `Issue: #N` (e status) no plano local.
|
||||
3. Commit + **`pnpm push`** + PR **Ready** `--base main` com **`Related #N`** (nunca `Closes #N` em PR só de `docs/plans/` — `plans-only-closes`).
|
||||
4. Auto-merge via safety net (`agent-pr-ready-automerge.yml` — o rollup `CI (PR) / checks` verde mergea por rebase); espere o merge em `main`.
|
||||
4. Auto-merge (`gh pr merge --auto --rebase`); espere o merge em `main`.
|
||||
5. **Promote** com o script (idempotente se já `ready`; só Issues `blocked` + link `docs/plans/`, sem gates humanos `needs:*` nem `in-progress`/`done`/`in-prod`):
|
||||
|
||||
```bash
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
---
|
||||
name: project-status
|
||||
description: Apresenta o estado do projeto Teqo a partir das Issues do Forgejo rastreáveis — overview (estado/prio/kind), fila atual na ordem do agent:claim com o modelo de cada item, grafo mermaid de dependências, bloqueios com motivo e sugestões de consolidação (merge/break-down). Read-only; NUNCA modifica Issues, em particular nunca toca Issues in-progress. Usar quando o usuário pedir status/visão geral do projeto, "como está o projeto", "qual a fila", "o que está bloqueado", "mostra o grafo", ou perguntar o que dá para consolidar.
|
||||
description: Apresenta o estado do projeto Teqo a partir das GitHub Issues rastreáveis — overview (estado/prio/kind), fila atual na ordem do agent:claim com o modelo de cada item, grafo mermaid de dependências, bloqueios com motivo e sugestões de consolidação (merge/break-down). Read-only; NUNCA modifica Issues, em particular nunca toca Issues in-progress. Usar quando o usuário pedir status/visão geral do projeto, "como está o projeto", "qual a fila", "o que está bloqueado", "mostra o grafo", ou perguntar o que dá para consolidar.
|
||||
---
|
||||
|
||||
# Status do projeto (read-only)
|
||||
|
||||
Esta skill apresenta o estado do projeto a partir das Issues do Forgejo rastreáveis — a fonte canônica de spec/status/deps/prio/modelo. **Read-only por contrato:** nenhuma Issue é criada, editada ou relabelada aqui, e Issues `in-progress` são intocáveis (risco de conflito com o agente que está executando).
|
||||
Esta skill apresenta o estado do projeto a partir das GitHub Issues rastreáveis — a fonte canônica de spec/status/deps/prio/modelo. **Read-only por contrato:** nenhuma Issue é criada, editada ou relabelada aqui, e Issues `in-progress` são intocáveis (risco de conflito com o agente que está executando).
|
||||
|
||||
## Fluxo
|
||||
|
||||
|
|
@ -22,6 +22,6 @@ pnpm agent:status
|
|||
- **Bloqueios** — cada `blocked` com o motivo (dep aberta, jurídico, `requirements-changed`).
|
||||
- **Sugestões de consolidação** — as heurísticas do script (merge por domínio+prio; break-down por gargalo de depends) como **texto para o usuário decidir**. Nunca aplique consolidação aqui: aplicação é manual ou via `plan-issue`/`pnpm agent:register`. Nunca proponha nada sobre Issues `in-progress`.
|
||||
|
||||
3. Se o usuário pedir interpretação além da saída (ex.: "o que destrava mais coisa?"), responda a partir do grafo — sem inventar dados que o script não imprimiu; se faltar dado, leia a Issue via API do Forgejo (MCP/`pnpm issue`) somente-leitura.
|
||||
3. Se o usuário pedir interpretação além da saída (ex.: "o que destrava mais coisa?"), responda a partir do grafo — sem inventar dados que o script não imprimiu; se faltar dado, rode `gh issue view <N>` somente-leitura.
|
||||
|
||||
**NÃO faz:** editar `docs/roadmap.md` (legado congelado), claim, registro, qualquer escrita de Issue.
|
||||
**NÃO faz:** editar `docs/roadmap.md` (legado congelado), claim, registro, qualquer escrita no GitHub.
|
||||
|
|
|
|||
|
|
@ -36,18 +36,17 @@ pular a pausa do impl plan; Draft / sem auto-merge.
|
|||
- [ ] 1. Contexto: Issue do prompt/`$ARGUMENTS` da sessão (ausente → UMA pergunta com validação; nunca claim)
|
||||
- [ ] 2. Abrir o plano de intenção do body da Issue (`Plano: docs/plans/...`; sem link → body é spec)
|
||||
- [ ] 3. Plan mode → escrever `docs/plans/<slug>-impl.md` → **PARAR e confirmar com o humano**
|
||||
- [ ] 4. Após “ok”: Passo 4 (execução → e2e local afetado → /simplify → débitos autônomos → PR, via `execution-pipeline.md`)
|
||||
- [ ] 4. Após “ok”: Passo 4 (execução → /simplify → débitos autônomos → PR, via `execution-pipeline.md`)
|
||||
```
|
||||
|
||||
## Passo 1 — Contexto da sessão
|
||||
|
||||
A Issue chega no prompt/`$ARGUMENTS` do `/work-issue` (`--issue <N>` ou o número —
|
||||
contrato OPS33; chamada manual: passar o número). Sempre consulte o Forgejo para o
|
||||
contrato OPS33; chamada manual: passar o número). Sempre consulte o GitHub para o
|
||||
resto (fonte única é a Issue, nunca um brief duplicado).
|
||||
|
||||
- **Presente** → valide o básico (issue do Forgejo `git.solla.dev/fsolla/teqo`:
|
||||
existe, `OPEN`, label `in-progress` — confirma que o claim foi feito fora da
|
||||
skill) e use.
|
||||
- **Presente** → valide o básico (`gh issue view <N>`: existe, `OPEN`, label
|
||||
`in-progress` — confirma que o claim foi feito fora da skill) e use.
|
||||
- **Ausente** → **uma** pergunta ao humano ("Qual Issue?"), valide o número
|
||||
informado com a mesma checagem e siga.
|
||||
- **Checagem falhou** (não existe / não `OPEN` / sem `in-progress`) → a Issue
|
||||
|
|
@ -96,12 +95,6 @@ nunca invente produto novo.
|
|||
fechar em main), com os deltas do ator humano:
|
||||
|
||||
- **Branch:** `<Code>-<slug>` do worktree — nunca crie branch nova na sessão.
|
||||
- **E2E local afetado (OPS72, discricionário):** antes do push, rode
|
||||
localmente os e2e que você **criou** + os da **mesma superfície afetada** —
|
||||
você decide quais (`pnpm test:e2e:affected` ou specs diretas). Não está no
|
||||
`gate:push`; ver a seção "E2E local afetado" do `execution-pipeline.md`
|
||||
(incl. a limitação da #72: `--no-deps` + projetos paralelos → `--workers=1`
|
||||
ou a cadeia padrão de projetos).
|
||||
- **UI:** shape → craft → critique → polish.
|
||||
- **`capture-review-debts`:** **autônomo** — colha, dedupe, pontue e **decida
|
||||
você mesmo o destino** (registrar/absorver/deferir/descartar) pela triage
|
||||
|
|
|
|||
|
|
@ -12,42 +12,12 @@ Ordem:
|
|||
testes de domínio. Invariantes do engineering-brief.
|
||||
2. **UI** — se Impeccable B/C/D: shape → craft → critique → polish. Tokens
|
||||
`data-theme='campaign'`; shells existentes.
|
||||
3. **E2E local afetado (OPS72, discricionário)** — antes do push, rode
|
||||
localmente os e2e que você **criou** + os da **mesma superfície afetada**
|
||||
(você decide quais). Ver §E2E local afetado.
|
||||
4. **Gates** — `pnpm gate:fast` na iteração; entrega com `pnpm push` (não
|
||||
3. **Gates** — `pnpm gate:fast` na iteração; entrega com `pnpm push` (não
|
||||
`git push` nu).
|
||||
|
||||
Tracer bullet cedo se o item for grande. Inclua o `*-impl.md` no commit da
|
||||
entrega.
|
||||
|
||||
## E2E local afetado (OPS72)
|
||||
|
||||
Política de e2e: **local = discricionário, CI/PR = blast radius, main = full
|
||||
antes do deploy**.
|
||||
|
||||
- **O que rodar:** os specs e2e que o diff **criou** (novos `.e2e.spec.ts`) +
|
||||
os da mesma superfície trabalhada (o `E2E_AFFECTED_MANIFEST` mapeia
|
||||
`src/` → specs; a mecânica do CI é a mesma). **É discricionário: você
|
||||
decide quais rodar** — não é um gate mecânico e não está no `gate:push`.
|
||||
- **Ferramenta:** `pnpm test:e2e:affected` (espelho local do classifier:
|
||||
modo `none`/`selected`/`full` conforme o diff; roda `migrate` +
|
||||
`db:seed:minimal` antes). Alternativa para specs diretas:
|
||||
`pnpm test:e2e --no-deps -- tests/e2e/<spec>.e2e.spec.ts` (ou
|
||||
`--no-deps --project=<família>`).
|
||||
- **CI/PR:** roda só o **blast radius** detectado (`ci-scope.mjs`, modo
|
||||
`selected`) — nunca `full`. Diff high-risk (schema/lockfile/harness)
|
||||
classifica `full` → o PR não roda e2e: o full fica para o `verify` do
|
||||
deploy manual (antes de publicar) e, nesse mesmo diff, o espelho local
|
||||
roda full.
|
||||
- **Limitação da #72 (S3-FOLLOWUP):** e2e local com `--no-deps` + projetos
|
||||
paralelos (`--project=a --project=b`) colide no `seedTestUser` (delete +
|
||||
create concorrentes nos `beforeAll` → `ValidationError: email` e falhas
|
||||
fantasma). Use `--workers=1` ou a cadeia padrão de projetos. Só afeta o
|
||||
local: no CI/prod mode a cadeia de deps dos projects é descartada (as
|
||||
famílias rodam em paralelo) e os runs selecionados filtram por arquivo —
|
||||
quase sempre uma família — então o seed não colide.
|
||||
|
||||
## /simplify + débitos
|
||||
|
||||
1. Rode o comando `/simplify` completo (3 reviewers paralelos via Task,
|
||||
|
|
@ -64,15 +34,11 @@ antes do deploy**.
|
|||
e rode `pnpm changelog:build` para regenerar `docs/CHANGELOG-AGENTS.md`
|
||||
(insert-only: entradas históricas nunca mudam; o diff será só a entrada
|
||||
nova + o agregado). Rode `pnpm changelog:check` para confirmar.
|
||||
3. **`pnpm push -u origin HEAD`** — origin é o **GitHub** (OPS71; o tracker de
|
||||
Issues continua no Forgejo por API).
|
||||
4. PR no **GitHub** via `GITHUB_TOKEN=<PAT> node scripts/github-pr.mjs --head <branch> --title "<id> — <título>" --body-file <arquivo>` — **Ready** (nunca draft; o script não tem flag draft), base `main`, body com `Closes #<N>` (ou `Related #N` em plans-only). Em Cursor Cloud: `ManagePullRequest` com `draft: false`.
|
||||
5. O safety net `agent-pr-ready-automerge.yml` arma o **auto-merge nativo do
|
||||
GitHub** (`enablePullRequestAutoMerge`, rebase) — o servidor mergea quando
|
||||
o required check `CI (PR) / checks` fica verde; nada a armar.
|
||||
6. No merge, o workflow `issue-done-on-main-merge.yml` flipa `done`/`in-prod`
|
||||
**no Forgejo** (lê o body do PR via API do GitHub, escreve no tracker por
|
||||
`FORGEJO_API_TOKEN`). Comente na Issue o desfecho em uma linha.
|
||||
3. **`pnpm push -u origin HEAD`**
|
||||
4. PR **Ready** (nunca draft): `gh pr create --base main` com `Closes #<N>`
|
||||
5. `gh pr merge --auto --rebase` imediatamente
|
||||
6. `gh pr checks <PR> --watch --required` (`checks`; ignore Vercel Git)
|
||||
7. CI flipa `done`/`in-prod` no merge. Comente na Issue o desfecho em uma linha.
|
||||
|
||||
## Deltas por ator
|
||||
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ No opencode, isso é o comando **`/worktree next`** / **`/worktree kill`** (`.op
|
|||
|
||||
**Default-go:** `next`, `plan` e `new` imprimem `cd <dir>` na última linha **por padrão**; `--stay` suprime; `--go` explícito continua aceito como no-op. No terminal interativo, use a função `worktree()` de **`.agents/shell/worktree.sh`** (uma linha de `source` no profile) para o `cd` ser aplicado de verdade no shell que te chamou — node não consegue mudar o cwd do shell pai. `kill` também imprime `cd <main>` no fim (a sessão nunca fica num diretório destruído).
|
||||
|
||||
**Launch do opencode (OPS26 + OPS33 + OPS31):** só a função shell marca `TEQO_WORKTREE_TERMINAL=1`; com o marcador, `next`/`plan`/`new` imprimem também a diretiva `launch opencode <dir> --model deepseek/deepseek-v4-flash --auto [--prompt "/work-issue --issue <N>"]` **antes** do `cd`, e a função a executa depois do cd — `next` abre o TUI com `/work-issue --issue <N>` já enviado (a Issue claimada vai no prompt; a skill lê o resto do Forgejo); `plan` abre com `/plan-issue` já enviado (OPS31: o TUI já cai no fluxo de planejamento); `new` abre sem `--prompt` ("apenas conversar"). Sem `exec`: sair do opencode volta ao shell no worktree. Presets são constantes em `scripts/lib/worktree.mjs`. **O comando `/worktree` do opencode (e qualquer automação sem o marcador) nunca lança o TUI.**
|
||||
**Launch do opencode (OPS26 + OPS33 + OPS31):** só a função shell marca `TEQO_WORKTREE_TERMINAL=1`; com o marcador, `next`/`plan`/`new` imprimem também a diretiva `launch opencode <dir> --model deepseek/deepseek-v4-flash --auto [--prompt "/work-issue --issue <N>"]` **antes** do `cd`, e a função a executa depois do cd — `next` abre o TUI com `/work-issue --issue <N>` já enviado (a Issue claimada vai no prompt; a skill lê o resto do GitHub); `plan` abre com `/plan-issue` já enviado (OPS31: o TUI já cai no fluxo de planejamento); `new` abre sem `--prompt` ("apenas conversar"). Sem `exec`: sair do opencode volta ao shell no worktree. Presets são constantes em `scripts/lib/worktree.mjs`. **O comando `/worktree` do opencode (e qualquer automação sem o marcador) nunca lança o TUI.**
|
||||
|
||||
## Fluxo quando invocado como skill (agentes que não têm opencode)
|
||||
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ bash "${SCRIPT_DIR}/ensure-postgres.sh"
|
|||
if [ ! -f .env ]; then
|
||||
sed \
|
||||
-e 's/YOUR_SECRET_HERE/cursor-cloud-local-secret/' \
|
||||
-e 's/YOUR_BLOB_TOKEN_HERE/vercel_blob_rw_cloud01_localplaceholder01/' \
|
||||
.env.example > .env
|
||||
echo "[cloud-setup] wrote .env from .env.example (local-only values)"
|
||||
fi
|
||||
|
|
|
|||
15
.env.example
|
|
@ -7,20 +7,7 @@
|
|||
DATABASE_URL=postgresql://teqo:teqo@localhost:5432/teqo
|
||||
|
||||
PAYLOAD_SECRET=YOUR_SECRET_HERE
|
||||
# Media storage (OPS52) — Garage S3 (homeserver). When ANY of the four required
|
||||
# envs (S3_BUCKET, S3_ENDPOINT, S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY) is set,
|
||||
# ALL of them must be: a partial config aborts the boot with the exact missing
|
||||
# names (fail-closed). When none is set, Payload falls back to local disk storage
|
||||
# (dev/test). The bucket stays PRIVATE: the app proxies the files through its
|
||||
# /media/file route (the plugin streams each object from Garage), so no public
|
||||
# bucket/DNS is needed. Production: S3_BUCKET=teqo-media,
|
||||
# S3_ENDPOINT=http://100.119.220.31:3900 (tailnet do homeserver). Dev may use its
|
||||
# own bucket — never share the production bucket across environments.
|
||||
S3_BUCKET=
|
||||
S3_ENDPOINT=
|
||||
S3_REGION=garage
|
||||
S3_ACCESS_KEY_ID=
|
||||
S3_SECRET_ACCESS_KEY=
|
||||
BLOB_READ_WRITE_TOKEN=YOUR_BLOB_TOKEN_HERE
|
||||
# Canonical public origin used in absolute campaign invite links. In production
|
||||
# this must be an exact HTTPS non-local origin: no credentials, path other than
|
||||
# `/`, query, or fragment. Production ignores request headers and invite
|
||||
|
|
|
|||
|
|
@ -11,5 +11,5 @@
|
|||
# TEQO_TEST_DATABASE_URL=postgresql://teqo:teqo@127.0.0.1:5433/teqo_test
|
||||
DATABASE_URL=postgresql://teqo:teqo@localhost:5432/teqo_test
|
||||
PAYLOAD_SECRET=test-only-secret-not-used-in-production
|
||||
# No S3_* set → media storage falls back to local disk in tests.
|
||||
BLOB_READ_WRITE_TOKEN=
|
||||
NEXT_PUBLIC_SITE_URL=http://localhost:3000
|
||||
|
|
|
|||
|
|
@ -1,2 +0,0 @@
|
|||
FORGEJO_REPOSITORY=fsolla/teqo
|
||||
WORKTREES_ROOT=~/.worktrees/teqo
|
||||
7
.github/actions/cancel-workflow-on-failure/action.yml
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
name: Cancel workflow on failure
|
||||
description: Cancels the entire workflow run when invoked after a job failure (fail-fast).
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Cancel workflow run
|
||||
uses: styfle/cancel-workflow-action@0.13.1
|
||||
76
.github/workflows/agent-pool.yml
vendored
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
# Agent pool supervisor (docs/plans/agent-pool-orchestrator.md).
|
||||
#
|
||||
# Stateless tick: reconciles pool claims and spawns Cursor Cloud workers up to
|
||||
# POOL_MAX_SLOTS over `ready` autonomous-eligible issues. Three triggers:
|
||||
# - workflow_dispatch — the canonical human channel (start/stop/pause/resume)
|
||||
# - schedule */10min — the periodic reconcile (default branch only)
|
||||
# - pull_request closed on main — immediate slot replenish after a merge
|
||||
#
|
||||
# Secrets:
|
||||
# CURSOR_API_KEY — Cursor Cloud Agents API key
|
||||
# POOL_GITHUB_TOKEN — PAT with actions:write + issues:write (required to read/
|
||||
# write repo Actions variables; default GITHUB_TOKEN often 403s — mirror of
|
||||
# the old PROMOTE_GITHUB_TOKEN pattern)
|
||||
name: Agent pool supervisor
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '*/10 * * * *'
|
||||
pull_request:
|
||||
types: [closed]
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
action:
|
||||
description: 'Ação do pool'
|
||||
required: true
|
||||
type: choice
|
||||
options:
|
||||
- start
|
||||
- stop
|
||||
- pause
|
||||
- resume
|
||||
- tick
|
||||
- status
|
||||
maxSlots:
|
||||
description: 'Slots simultâneos (só com action=start; default 5, máx 8)'
|
||||
required: false
|
||||
type: string
|
||||
|
||||
concurrency:
|
||||
group: agent-pool
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
actions: write
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
tick:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
if: github.event_name != 'pull_request' || github.event.pull_request.merged == true
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: pnpm/action-setup@v6
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- name: agent:pool workflow
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.POOL_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
CURSOR_API_KEY: ${{ secrets.CURSOR_API_KEY }}
|
||||
ACTION: ${{ inputs.action }}
|
||||
MAX_SLOTS: ${{ inputs.maxSlots }}
|
||||
run: |
|
||||
if [ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ]; then
|
||||
args=(--action "$ACTION" --actor "$GITHUB_ACTOR")
|
||||
if [ -n "$MAX_SLOTS" ]; then args+=(--max-slots "$MAX_SLOTS"); fi
|
||||
else
|
||||
args=(--action tick --actor "$GITHUB_ACTOR")
|
||||
fi
|
||||
node scripts/agent-pool.mjs workflow "${args[@]}"
|
||||
76
.github/workflows/agent-pr-ready-automerge.yml
vendored
|
|
@ -1,22 +1,7 @@
|
|||
# Safety net (OPS71 — GitHub): every same-repo PR to main must be Ready +
|
||||
# auto-merge armed. Covers Cloud ManagePullRequest defaulting to draft,
|
||||
# agents that forget to arm the merge, and human/worktree PRs (the OPS52
|
||||
# incident was a human PR whose merge failed silently). Draft PRs from
|
||||
# non-`cursor/*` branches are never touched — the draft flag is the actor's
|
||||
# veto (the CLI skips them). Fork PRs are excluded (the repo is public; a
|
||||
# merged fork PR would deploy) — same RCE-guard class as the other workflows.
|
||||
#
|
||||
# GitHub's NATIVE auto-merge replaces the Forgejo-era poll CLI (removed in
|
||||
# OPS71 Fase 2): `enablePullRequestAutoMerge` (GraphQL, REBASE —
|
||||
# the repo's canonical merge style) is server-side — GitHub only merges when
|
||||
# every required check (the `CI (PR) / checks` rollup of ci-pr.yml, OPS62) is
|
||||
# green and the PR is mergeable. The OPS64 "rollup can lie" defense is
|
||||
# structural here: a single sequential job posts one honest check-run and the
|
||||
# server enforces it — nothing to poll, nothing to trust.
|
||||
#
|
||||
# Permissions: contents+pull-requests write — enough for PATCH draft:false
|
||||
# and the GraphQL auto-merge mutation with the built-in GITHUB_TOKEN.
|
||||
name: PR Ready + auto-merge
|
||||
# Safety net: agent PRs (cursor/* → main) must be Ready + auto-merge.
|
||||
# Covers Cloud ManagePullRequest defaulting to draft and agents that forget
|
||||
# `gh pr merge --auto --rebase`. No skill opt-out (engineering-audit included).
|
||||
name: Agent PR Ready + auto-merge
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
|
|
@ -29,36 +14,31 @@ permissions:
|
|||
|
||||
jobs:
|
||||
ready-automerge:
|
||||
# Same-repo only. This MUST stay in the `if:` (pre-checkout): for a fork
|
||||
# PR, actions/checkout checks out the fork's head, so a same-repo guard
|
||||
# inside the CLI would already be running the fork's own script — RCE
|
||||
# with the Actions token. The draft policy lives in the CLI (skips
|
||||
# non-`cursor/*` drafts) because there the PR state is re-read from the
|
||||
# API, not from webhook payload fields.
|
||||
if: >-
|
||||
github.event.pull_request.state == 'open' &&
|
||||
github.event.pull_request.head.repo.full_name == github.repository
|
||||
startsWith(github.event.pull_request.head.ref, 'cursor/')
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
timeout-minutes: 5
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
# Script is plain Node (stdlib) — no pnpm. Disable package-manager-cache:
|
||||
# setup-node@v5 defaults to true and looks for pnpm before pnpm/action-setup.
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 24
|
||||
package-manager-cache: false
|
||||
- name: Mark Ready and arm auto-merge
|
||||
env:
|
||||
# Explicit `github.token` — the built-in GITHUB_TOKEN env is not
|
||||
# guaranteed to be injected on every event path; passing it via the
|
||||
# documented context is the robust form.
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
# OPS71-FLIP: arming with the built-in GITHUB_TOKEN makes the merge
|
||||
# happen as github-actions[bot], and GitHub's anti-recursion never
|
||||
# creates workflow runs for events triggered by GITHUB_TOKEN actions
|
||||
# — the post-merge `closed` flips would silently never run (PR #746).
|
||||
# AUTOMERGE_PAT (real user PAT) attributes the merge to a user, so
|
||||
# the `closed` event creates runs again. Fail-closed in the CLI.
|
||||
AUTOMERGE_PAT: ${{ secrets.AUTOMERGE_PAT }}
|
||||
run: node scripts/github-pr-automerge.mjs --pr "${{ github.event.pull_request.number }}"
|
||||
- name: Mark Ready and enable auto-merge
|
||||
run: |
|
||||
set -euo pipefail
|
||||
is_draft="$(gh pr view "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" --json isDraft --jq .isDraft)"
|
||||
if [ "$is_draft" = "true" ]; then
|
||||
echo "PR #$PR_NUMBER is draft — marking Ready"
|
||||
gh pr ready "$PR_NUMBER" --repo "$GITHUB_REPOSITORY"
|
||||
else
|
||||
echo "PR #$PR_NUMBER already Ready"
|
||||
fi
|
||||
# Arm auto-merge (idempotent when already enabled; fails only on hard blocks).
|
||||
if gh pr merge --auto --rebase "$PR_NUMBER" --repo "$GITHUB_REPOSITORY"; then
|
||||
echo "Auto-merge armed for PR #$PR_NUMBER"
|
||||
else
|
||||
# Already merged / already armed / race — succeed only if request exists or merged.
|
||||
armed="$(gh pr view "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" --json autoMergeRequest,state --jq '(.autoMergeRequest != null) or (.state == "MERGED")')"
|
||||
echo "gh pr merge --auto --rebase exited non-zero; armed_or_merged=$armed"
|
||||
[ "$armed" = "true" ]
|
||||
fi
|
||||
|
|
|
|||
2
.github/workflows/archive-cursor-agent.yml
vendored
|
|
@ -11,8 +11,6 @@ on:
|
|||
description: Cursor run id (run-…) optional cancel first
|
||||
required: false
|
||||
type: string
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
archive:
|
||||
runs-on: ubuntu-latest
|
||||
|
|
|
|||
518
.github/workflows/ci-pr.yml
vendored
|
|
@ -1,64 +1,211 @@
|
|||
# PR gate: feature → main (OPS71 — GitHub Actions). SINGLE sequential job
|
||||
# (OPS62, X1): one container, one checkout, one pnpm install, one runner slot.
|
||||
# Fail-fast is STRUCTURAL — a failed step skips every step after it, the job
|
||||
# fails and the run fails. GitHub-native `concurrency` cancels the in-flight
|
||||
# run when the PR gets a new push (the Forgejo era had no cancel API; here it
|
||||
# is free and matches the OPS62 "one slot per PR" spirit).
|
||||
#
|
||||
# The job id `checks` preserves the `CI (PR) / checks` check-run that the
|
||||
# branch protection rule and the native auto-merge rely on (OPS71): GitHub
|
||||
# names it <workflow name> / <job name> — keep both literals frozen. GitHub's
|
||||
# auto-merge is server-side: it only merges with every required check green,
|
||||
# so the OPS64 "rollup can lie" defense is structural here (single job posts
|
||||
# one honest check-run; no matrix, no shards).
|
||||
#
|
||||
# Job-level skips come from the `scope` classifier (scripts/ci-scope.mjs) run
|
||||
# as a step; its outputs drive `if:` on the heavy steps (code/build/test:
|
||||
# none/changed/full; e2e: none/selected/full). e2e policy (OPS72): the PR
|
||||
# runs ONLY the blast radius — the specs the classifier detected as touched
|
||||
# (mode `selected`), never `full`. A high-risk diff (schema/lockfile/test
|
||||
# harness) classifies as `full` → the PR simply runs no e2e: the local agent
|
||||
# mirror (`pnpm test:e2e:affected`) runs full in that same case, and the
|
||||
# deploy `verify` job runs the full suite before publishing.
|
||||
# PR gate: feature → main. Two-phase cascade:
|
||||
# Phase 1 (cheap, no Postgres): lint, format, typecheck, knip, cycles, unit
|
||||
# Phase 2 (expensive, Postgres; needs phase 1 green/skipped): int, build, e2e
|
||||
# Job-level skips come from the `scope` classifier (scripts/ci-scope.mjs). A
|
||||
# phase-1 failure skips phase 2 via `needs` + explicit if.
|
||||
#
|
||||
# Schema migrations are NOT serialized across PRs (migration-lock removed
|
||||
# 2026-08-12): a stale chain is caught by `pnpm migrate` + `test:int` on every
|
||||
# PR, incl. campaignMigrationReconciliation.
|
||||
# PR and on main (ci.yml), incl. campaignMigrationReconciliation — the check's
|
||||
# hard fail deadlocked every open schema PR the moment two coexisted.
|
||||
#
|
||||
# Fork-PRs are excluded at job level (`if:` same-repo): the repo is public and
|
||||
# a fork PR would burn hosted minutes running arbitrary code. Same guard class
|
||||
# as the RCE guard on the post-merge workflows.
|
||||
#
|
||||
# Postgres services (OPS71 — GitHub hosted): jobs run directly on the runner
|
||||
# VM (NOT inside the job-container network of the Forgejo era), so services
|
||||
# are reached at localhost on PUBLISHED host ports. Each hosted run is its own
|
||||
# VM, so fixed ports never collide across runs. Two isolated services — int
|
||||
# on 5432 and build+e2e on 5433 — keep e2e from inheriting int fixture
|
||||
# residue.
|
||||
# High-risk diffs (schema, lockfile, classifiers — see
|
||||
# scripts/lib/test-affected-core.mjs) force full unit/int/e2e. Push to main
|
||||
# always runs the full suite in ci.yml (no affected skips).
|
||||
name: CI (PR)
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ci-pr-${{ github.head_ref }}
|
||||
group: ci-pr-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: write
|
||||
pull-requests: read
|
||||
|
||||
env:
|
||||
DATABASE_URL: postgresql://teqo:teqo@localhost:5432/teqo_test
|
||||
PAYLOAD_SECRET: test-only-secret-not-used-in-production
|
||||
NEXT_PUBLIC_SITE_URL: http://localhost:3000
|
||||
|
||||
jobs:
|
||||
checks:
|
||||
if: github.event.pull_request.head.repo.full_name == github.repository
|
||||
scope:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 50
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
code_mode: ${{ steps.classify.outputs.code_mode }}
|
||||
build_mode: ${{ steps.classify.outputs.build_mode }}
|
||||
test_mode: ${{ steps.classify.outputs.test_mode }}
|
||||
e2e_mode: ${{ steps.classify.outputs.e2e_mode }}
|
||||
e2e_specs: ${{ steps.classify.outputs.e2e_specs }}
|
||||
e2e_matrix: ${{ steps.classify.outputs.e2e_matrix }}
|
||||
e2e_shards: ${{ steps.classify.outputs.e2e_shards }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: pnpm/action-setup@v6
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- name: Classify PR blast radius
|
||||
id: classify
|
||||
env:
|
||||
GITHUB_BASE_REF: ${{ github.base_ref }}
|
||||
run: |
|
||||
result=$(node scripts/ci-scope.mjs)
|
||||
echo "scope: $(echo "$result" | jq -c '{code:.code.mode,build:.build.mode,test:.test.mode,e2e:.e2e.mode,reason:{code:.code.reason,build:.build.reason,test:.test.reason,e2e:.e2e.reason}}')"
|
||||
echo "code_mode=$(echo "$result" | jq -r .code.mode)" >> "$GITHUB_OUTPUT"
|
||||
echo "build_mode=$(echo "$result" | jq -r .build.mode)" >> "$GITHUB_OUTPUT"
|
||||
echo "test_mode=$(echo "$result" | jq -r .test.mode)" >> "$GITHUB_OUTPUT"
|
||||
echo "e2e_mode=$(echo "$result" | jq -r .e2e.mode)" >> "$GITHUB_OUTPUT"
|
||||
echo "e2e_specs=$(echo "$result" | jq -r '[.e2e.specs[] | "tests/e2e/" + . + ".e2e.spec.ts"] | join(" ")')" >> "$GITHUB_OUTPUT"
|
||||
# OPS34: full runs split across shards (one runner per shard); selected
|
||||
# stays on a single runner (a second build for a handful of specs is
|
||||
# waste). Single source: e2eShardConfig in scripts/lib/test-affected-core.mjs.
|
||||
echo "e2e_matrix=$(echo "$result" | jq -c .e2e_shards.matrix)" >> "$GITHUB_OUTPUT"
|
||||
echo "e2e_shards=$(echo "$result" | jq -r .e2e_shards.total)" >> "$GITHUB_OUTPUT"
|
||||
- name: Cancel workflow on failure
|
||||
if: failure()
|
||||
uses: ./.github/actions/cancel-workflow-on-failure
|
||||
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: pnpm/action-setup@v6
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- name: Check test file locations
|
||||
run: node scripts/check-test-locations.mjs
|
||||
- run: pnpm lint
|
||||
- name: Cancel workflow on failure
|
||||
if: failure()
|
||||
uses: ./.github/actions/cancel-workflow-on-failure
|
||||
|
||||
format:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: pnpm/action-setup@v6
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm format:check
|
||||
- name: Cancel workflow on failure
|
||||
if: failure()
|
||||
uses: ./.github/actions/cancel-workflow-on-failure
|
||||
|
||||
typecheck:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
needs: [scope]
|
||||
if: needs.scope.outputs.code_mode != 'none'
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: pnpm/action-setup@v6
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm typecheck
|
||||
- name: Cancel workflow on failure
|
||||
if: failure()
|
||||
uses: ./.github/actions/cancel-workflow-on-failure
|
||||
|
||||
knip:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
needs: [scope]
|
||||
if: needs.scope.outputs.code_mode != 'none'
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: pnpm/action-setup@v6
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm knip
|
||||
- name: Cancel workflow on failure
|
||||
if: failure()
|
||||
uses: ./.github/actions/cancel-workflow-on-failure
|
||||
|
||||
cycles:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
needs: [scope]
|
||||
if: needs.scope.outputs.code_mode != 'none'
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: pnpm/action-setup@v6
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm check:cycles
|
||||
- name: Cancel workflow on failure
|
||||
if: failure()
|
||||
uses: ./.github/actions/cancel-workflow-on-failure
|
||||
|
||||
unit:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
needs: [scope]
|
||||
if: needs.scope.outputs.test_mode != 'none'
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: pnpm/action-setup@v6
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Unit tests (full suite)
|
||||
if: needs.scope.outputs.test_mode == 'full'
|
||||
run: pnpm test:unit
|
||||
|
||||
- name: Unit tests (changed since PR base)
|
||||
if: needs.scope.outputs.test_mode == 'changed'
|
||||
run: pnpm test:unit -- --changed origin/${{ github.base_ref }} --passWithNoTests
|
||||
- name: Cancel workflow on failure
|
||||
if: failure()
|
||||
uses: ./.github/actions/cancel-workflow-on-failure
|
||||
|
||||
int:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 25
|
||||
needs: [scope, lint, format, typecheck, knip, cycles, unit]
|
||||
if: |
|
||||
always()
|
||||
&& !cancelled()
|
||||
&& needs.scope.outputs.test_mode != 'none'
|
||||
&& needs.lint.result == 'success'
|
||||
&& needs.format.result == 'success'
|
||||
&& (needs.typecheck.result == 'success' || needs.typecheck.result == 'skipped')
|
||||
&& (needs.knip.result == 'success' || needs.knip.result == 'skipped')
|
||||
&& (needs.cycles.result == 'success' || needs.cycles.result == 'skipped')
|
||||
&& (needs.unit.result == 'success' || needs.unit.result == 'skipped')
|
||||
services:
|
||||
postgres-int:
|
||||
postgres:
|
||||
image: postgres:17-alpine
|
||||
env:
|
||||
POSTGRES_USER: teqo
|
||||
|
|
@ -71,19 +218,6 @@ jobs:
|
|||
--health-interval 5s
|
||||
--health-timeout 5s
|
||||
--health-retries 12
|
||||
postgres-build:
|
||||
image: postgres:17-alpine
|
||||
env:
|
||||
POSTGRES_USER: teqo
|
||||
POSTGRES_PASSWORD: teqo
|
||||
POSTGRES_DB: teqo_test
|
||||
ports:
|
||||
- 5433:5432
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U teqo"
|
||||
--health-interval 5s
|
||||
--health-timeout 5s
|
||||
--health-retries 12
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
|
|
@ -94,23 +228,138 @@ jobs:
|
|||
node-version: 24
|
||||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm migrate
|
||||
- run: pnpm db:seed:minimal
|
||||
|
||||
- name: Classify PR blast radius
|
||||
id: scope
|
||||
- name: Integration tests (full suite)
|
||||
if: needs.scope.outputs.test_mode == 'full'
|
||||
run: pnpm test:int
|
||||
|
||||
- name: Integration tests (changed since PR base)
|
||||
if: needs.scope.outputs.test_mode == 'changed'
|
||||
run: pnpm test:int -- --changed origin/${{ github.base_ref }} --passWithNoTests
|
||||
- name: Cancel workflow on failure
|
||||
if: failure()
|
||||
uses: ./.github/actions/cancel-workflow-on-failure
|
||||
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 25
|
||||
needs: [scope, lint, format, typecheck, knip, cycles, unit]
|
||||
if: |
|
||||
always()
|
||||
&& !cancelled()
|
||||
&& needs.scope.outputs.build_mode != 'none'
|
||||
&& needs.lint.result == 'success'
|
||||
&& needs.format.result == 'success'
|
||||
&& (needs.typecheck.result == 'success' || needs.typecheck.result == 'skipped')
|
||||
&& (needs.knip.result == 'success' || needs.knip.result == 'skipped')
|
||||
&& (needs.cycles.result == 'success' || needs.cycles.result == 'skipped')
|
||||
&& (needs.unit.result == 'success' || needs.unit.result == 'skipped')
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:17-alpine
|
||||
env:
|
||||
GITHUB_BASE_REF: ${{ github.base_ref }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
result=$(node scripts/ci-scope.mjs)
|
||||
echo "scope: $(echo "$result" | jq -c '{code:.code.mode,build:.build.mode,test:.test.mode,e2e:.e2e.mode,reason:{code:.code.reason,build:.build.reason,test:.test.reason,e2e:.e2e.reason}}')"
|
||||
echo "code_mode=$(echo "$result" | jq -r .code.mode)" >> "$GITHUB_OUTPUT"
|
||||
echo "build_mode=$(echo "$result" | jq -r .build.mode)" >> "$GITHUB_OUTPUT"
|
||||
echo "test_mode=$(echo "$result" | jq -r .test.mode)" >> "$GITHUB_OUTPUT"
|
||||
echo "e2e_mode=$(echo "$result" | jq -r .e2e.mode)" >> "$GITHUB_OUTPUT"
|
||||
echo "e2e_specs=$(echo "$result" | jq -r '[.e2e.specs[] | "tests/e2e/" + . + ".e2e.spec.ts"] | join(" ")')" >> "$GITHUB_OUTPUT"
|
||||
POSTGRES_USER: teqo
|
||||
POSTGRES_PASSWORD: teqo
|
||||
POSTGRES_DB: teqo_test
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U teqo"
|
||||
--health-interval 5s
|
||||
--health-timeout 5s
|
||||
--health-retries 12
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: pnpm/action-setup@v6
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- name: Build (against the local service database)
|
||||
run: pnpm build
|
||||
- name: Cancel workflow on failure
|
||||
if: failure()
|
||||
uses: ./.github/actions/cancel-workflow-on-failure
|
||||
|
||||
# Content guards are PR-scoped and cheap — run them before the suite so
|
||||
# a guard failure fails fast instead of after 20 minutes of tests.
|
||||
e2e:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
needs: [scope, lint, format, typecheck, knip, cycles, unit]
|
||||
if: |
|
||||
always()
|
||||
&& !cancelled()
|
||||
&& needs.scope.outputs.e2e_mode != 'none'
|
||||
&& needs.lint.result == 'success'
|
||||
&& needs.format.result == 'success'
|
||||
&& (needs.typecheck.result == 'success' || needs.typecheck.result == 'skipped')
|
||||
&& (needs.knip.result == 'success' || needs.knip.result == 'skipped')
|
||||
&& (needs.cycles.result == 'success' || needs.cycles.result == 'skipped')
|
||||
&& (needs.unit.result == 'success' || needs.unit.result == 'skipped')
|
||||
# OPS34: full mode splits across 2 runners (matrix from the scope job);
|
||||
# selected mode stays on one. fail-fast: false keeps both shards' logs.
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard: ${{ fromJson(needs.scope.outputs.e2e_matrix) }}
|
||||
env:
|
||||
# Explicit CI workers; the local default stays 2 (playwright.config.ts).
|
||||
PLAYWRIGHT_WORKERS: 2
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:17-alpine
|
||||
env:
|
||||
POSTGRES_USER: teqo
|
||||
POSTGRES_PASSWORD: teqo
|
||||
POSTGRES_DB: teqo_test
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U teqo"
|
||||
--health-interval 5s
|
||||
--health-timeout 5s
|
||||
--health-retries 12
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: pnpm/action-setup@v6
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- name: Install Playwright chromium
|
||||
run: pnpm exec playwright install --with-deps chromium
|
||||
- run: pnpm migrate
|
||||
- run: pnpm db:seed:minimal
|
||||
- name: Build for e2e
|
||||
run: pnpm build
|
||||
env:
|
||||
NEXT_DIST_DIR: .next/e2e
|
||||
|
||||
- name: E2E tests (full suite, shard ${{ matrix.shard }})
|
||||
if: needs.scope.outputs.e2e_mode == 'full'
|
||||
# No `--` separator: pnpm would forward it verbatim to playwright and
|
||||
# the flag would be parsed as a file pattern ("No tests found").
|
||||
run: pnpm test:e2e --shard=${{ matrix.shard }}/${{ needs.scope.outputs.e2e_shards }}
|
||||
|
||||
- name: E2E tests (manifest-selected specs)
|
||||
if: needs.scope.outputs.e2e_mode == 'selected'
|
||||
run: pnpm test:e2e -- ${{ needs.scope.outputs.e2e_specs }}
|
||||
- name: Cancel workflow on failure
|
||||
if: failure()
|
||||
uses: ./.github/actions/cancel-workflow-on-failure
|
||||
|
||||
docs-guards:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Changelog is append-only
|
||||
env:
|
||||
GITHUB_BASE_REF: ${{ github.base_ref }}
|
||||
|
|
@ -122,105 +371,50 @@ jobs:
|
|||
env:
|
||||
GITHUB_BASE_REF: ${{ github.base_ref }}
|
||||
run: node scripts/check-docs-conflict-markers.mjs
|
||||
- name: Cancel workflow on failure
|
||||
if: failure()
|
||||
uses: ./.github/actions/cancel-workflow-on-failure
|
||||
|
||||
plans-only-closes:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Block Closes/Fixes/Resolves on plans-only PRs
|
||||
env:
|
||||
GITHUB_BASE_REF: ${{ github.base_ref }}
|
||||
PR_BODY: ${{ github.event.pull_request.body }}
|
||||
run: node scripts/check-plans-only-pr-closes.mjs
|
||||
- name: Cancel workflow on failure
|
||||
if: failure()
|
||||
uses: ./.github/actions/cancel-workflow-on-failure
|
||||
|
||||
- name: Check test file locations
|
||||
run: node scripts/check-test-locations.mjs
|
||||
- name: Lint
|
||||
run: pnpm lint
|
||||
- name: Format check
|
||||
run: pnpm format:check
|
||||
- name: Typecheck
|
||||
if: steps.scope.outputs.code_mode != 'none'
|
||||
run: pnpm typecheck
|
||||
- name: Knip
|
||||
if: steps.scope.outputs.code_mode != 'none'
|
||||
run: pnpm knip
|
||||
- name: Check cycles
|
||||
if: steps.scope.outputs.code_mode != 'none'
|
||||
run: pnpm check:cycles
|
||||
|
||||
- name: Unit tests (full suite)
|
||||
if: steps.scope.outputs.test_mode == 'full'
|
||||
run: pnpm test:unit
|
||||
- name: Unit tests (changed since PR base)
|
||||
if: steps.scope.outputs.test_mode == 'changed'
|
||||
run: pnpm test:unit -- --changed origin/${{ github.base_ref }} --passWithNoTests
|
||||
- name: Prepare database URL (int service)
|
||||
checks:
|
||||
name: checks
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- scope
|
||||
- lint
|
||||
- format
|
||||
- typecheck
|
||||
- knip
|
||||
- cycles
|
||||
- unit
|
||||
- int
|
||||
- build
|
||||
- e2e
|
||||
- docs-guards
|
||||
- plans-only-closes
|
||||
if: always()
|
||||
steps:
|
||||
- name: All cascade checks green
|
||||
env:
|
||||
NEEDS: ${{ toJSON(needs) }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "DATABASE_URL=postgresql://teqo:teqo@localhost:5432/teqo_test" >> "$GITHUB_ENV"
|
||||
# vitest re-loads .env.test over the process env, so the service URL
|
||||
# must win through the .env.test.local layer (same mechanism
|
||||
# `pnpm worktree next` uses).
|
||||
echo "DATABASE_URL=postgresql://teqo:teqo@localhost:5432/teqo_test" > .env.test.local
|
||||
- name: Migrate (int database)
|
||||
run: pnpm migrate
|
||||
- name: Seed minimal (int database)
|
||||
run: pnpm db:seed:minimal
|
||||
|
||||
- name: Integration tests (full suite)
|
||||
if: steps.scope.outputs.test_mode == 'full'
|
||||
env:
|
||||
# vitest forks ≈ CPUs and each fork holds a Payload pool (max 10) →
|
||||
# overflows the service Postgres' 100 clients ("too many clients
|
||||
# already"). 4 forks stay well under. The unit step above stays
|
||||
# CPU-relative (no DB).
|
||||
VITEST_MAX_WORKERS: 4
|
||||
run: pnpm test:int
|
||||
- name: Integration tests (changed since PR base)
|
||||
if: steps.scope.outputs.test_mode == 'changed'
|
||||
env:
|
||||
VITEST_MAX_WORKERS: 4
|
||||
run: pnpm test:int -- --changed origin/${{ github.base_ref }} --passWithNoTests
|
||||
|
||||
- name: Prepare database URL (build/e2e service)
|
||||
if: steps.scope.outputs.build_mode != 'none' || steps.scope.outputs.e2e_mode == 'selected'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "DATABASE_URL=postgresql://teqo:teqo@localhost:5433/teqo_test" >> "$GITHUB_ENV"
|
||||
echo "DATABASE_URL=postgresql://teqo:teqo@localhost:5433/teqo_test" > .env.test.local
|
||||
- name: Migrate (build/e2e database)
|
||||
if: steps.scope.outputs.build_mode != 'none' || steps.scope.outputs.e2e_mode == 'selected'
|
||||
run: pnpm migrate
|
||||
- name: Seed minimal (build/e2e database)
|
||||
if: steps.scope.outputs.build_mode != 'none' || steps.scope.outputs.e2e_mode == 'selected'
|
||||
run: pnpm db:seed:minimal
|
||||
|
||||
- name: Build (against the local service database)
|
||||
if: steps.scope.outputs.build_mode != 'none'
|
||||
run: pnpm build
|
||||
|
||||
- name: Cache Playwright browsers
|
||||
if: steps.scope.outputs.e2e_mode == 'selected'
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
playwright-${{ runner.os }}-
|
||||
- name: Install Playwright chromium
|
||||
if: steps.scope.outputs.e2e_mode == 'selected'
|
||||
run: pnpm exec playwright install --with-deps chromium
|
||||
- name: Build for e2e
|
||||
# Selected mode needs the build too (the Playwright webServer serves
|
||||
# the `.next-e2e` production build).
|
||||
if: steps.scope.outputs.e2e_mode == 'selected'
|
||||
run: pnpm build
|
||||
env:
|
||||
NEXT_DIST_DIR: .next-e2e
|
||||
- name: E2E tests (blast radius — manifest-selected specs)
|
||||
if: steps.scope.outputs.e2e_mode == 'selected'
|
||||
# OPS72: the PR never runs e2e full — mode `full` (high-risk diff)
|
||||
# skips this step by design (local mirror + deploy verify cover it).
|
||||
# Explicit CI workers; the local default stays 2 (playwright.config.ts).
|
||||
# 4 workers in one process replace the former 2 shards × 2 workers.
|
||||
env:
|
||||
PLAYWRIGHT_WORKERS: 4
|
||||
# The `--` separates pnpm from the scope paths: pnpm consumes it and
|
||||
# playwright receives the bare spec paths (no flag parsing drift).
|
||||
run: pnpm test:e2e -- ${{ steps.scope.outputs.e2e_specs }}
|
||||
echo "$NEEDS" | jq -e '
|
||||
to_entries
|
||||
| all(.value.result == "success" or .value.result == "skipped")
|
||||
'
|
||||
|
|
|
|||
333
.github/workflows/ci.yml
vendored
Normal file
|
|
@ -0,0 +1,333 @@
|
|||
# Post-merge verifier on `main`: always the FULL suite (no affected skips).
|
||||
# When green, deploys production via `vercel deploy --prod` (Vercel Git builds
|
||||
# are ignored — see scripts/vercel-ignore-build.sh). If HEAD moved while this
|
||||
# run was in flight, `requeue` dispatches another run for the new tip.
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: main-verify
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: write
|
||||
|
||||
env:
|
||||
DATABASE_URL: postgresql://teqo:teqo@localhost:5432/teqo_test
|
||||
PAYLOAD_SECRET: test-only-secret-not-used-in-production
|
||||
NEXT_PUBLIC_SITE_URL: http://localhost:3000
|
||||
|
||||
jobs:
|
||||
static:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: pnpm/action-setup@v6
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: node scripts/check-test-locations.mjs
|
||||
- run: pnpm lint
|
||||
- run: pnpm format:check
|
||||
- run: pnpm typecheck
|
||||
- run: pnpm knip
|
||||
- run: pnpm check:cycles
|
||||
- run: pnpm test:unit
|
||||
- name: Cancel workflow on failure
|
||||
if: failure()
|
||||
uses: ./.github/actions/cancel-workflow-on-failure
|
||||
|
||||
int:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 25
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:17-alpine
|
||||
env:
|
||||
POSTGRES_USER: teqo
|
||||
POSTGRES_PASSWORD: teqo
|
||||
POSTGRES_DB: teqo_test
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U teqo"
|
||||
--health-interval 5s
|
||||
--health-timeout 5s
|
||||
--health-retries 12
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: pnpm/action-setup@v6
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm migrate
|
||||
- run: pnpm db:seed:minimal
|
||||
- run: pnpm test:int
|
||||
- name: Cancel workflow on failure
|
||||
if: failure()
|
||||
uses: ./.github/actions/cancel-workflow-on-failure
|
||||
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 25
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:17-alpine
|
||||
env:
|
||||
POSTGRES_USER: teqo
|
||||
POSTGRES_PASSWORD: teqo
|
||||
POSTGRES_DB: teqo_test
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U teqo"
|
||||
--health-interval 5s
|
||||
--health-timeout 5s
|
||||
--health-retries 12
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: pnpm/action-setup@v6
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- name: Build (against the local service database)
|
||||
run: pnpm build
|
||||
- name: Cancel workflow on failure
|
||||
if: failure()
|
||||
uses: ./.github/actions/cancel-workflow-on-failure
|
||||
|
||||
e2e:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 40
|
||||
# Main always runs the full suite: split it across 2 runners (OPS34), each
|
||||
# building its own `.next/e2e` (measured 2026-08-10: build 172s + tests
|
||||
# 196s + ~54s fixed → ≈ 5.5 min per shard vs 435s serial). `fail-fast:
|
||||
# false` so one shard's failure keeps the other's diagnostics. Keep the
|
||||
# [1, 2] / 2 literal in sync with `e2eShardConfig` in
|
||||
# scripts/lib/test-affected-core.mjs (ci-pr reads it via the scope job).
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard: [1, 2]
|
||||
env:
|
||||
# Explicit CI workers; the local default stays 2 (playwright.config.ts).
|
||||
PLAYWRIGHT_WORKERS: 2
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:17-alpine
|
||||
env:
|
||||
POSTGRES_USER: teqo
|
||||
POSTGRES_PASSWORD: teqo
|
||||
POSTGRES_DB: teqo_test
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U teqo"
|
||||
--health-interval 5s
|
||||
--health-timeout 5s
|
||||
--health-retries 12
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: pnpm/action-setup@v6
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- name: Install Playwright chromium
|
||||
run: pnpm exec playwright install --with-deps chromium
|
||||
- run: pnpm migrate
|
||||
- run: pnpm db:seed:minimal
|
||||
- name: Build for e2e
|
||||
run: pnpm build
|
||||
env:
|
||||
NEXT_DIST_DIR: .next/e2e
|
||||
- name: E2E tests (full suite, shard ${{ matrix.shard }})
|
||||
# No `--` separator: pnpm would forward it verbatim to playwright and
|
||||
# the flag would be parsed as a file pattern ("No tests found").
|
||||
run: pnpm test:e2e --shard=${{ matrix.shard }}/2
|
||||
- name: Cancel workflow on failure
|
||||
if: failure()
|
||||
uses: ./.github/actions/cancel-workflow-on-failure
|
||||
|
||||
checks:
|
||||
name: checks
|
||||
runs-on: ubuntu-latest
|
||||
needs: [static, int, build, e2e]
|
||||
if: always()
|
||||
steps:
|
||||
- name: All main verifier checks green
|
||||
env:
|
||||
NEEDS: ${{ toJSON(needs) }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "$NEEDS" | jq -e '
|
||||
to_entries
|
||||
| all(.value.result == "success" or .value.result == "skipped")
|
||||
'
|
||||
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [checks]
|
||||
if: needs.checks.result == 'success'
|
||||
# pull → local `vercel build` (pnpm install + Next/Payload build against prod env) → upload.
|
||||
timeout-minutes: 35
|
||||
outputs:
|
||||
stale: ${{ steps.tip.outputs.stale || 'false' }}
|
||||
cooldown: ${{ steps.cooldown.outputs.active || 'false' }}
|
||||
wait_seconds: ${{ steps.cooldown.outputs.wait_seconds || '0' }}
|
||||
env:
|
||||
VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
|
||||
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
|
||||
VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Guard stale HEAD
|
||||
id: tip
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git fetch origin main --quiet
|
||||
tip=$(git rev-parse origin/main)
|
||||
if [ "$tip" != "$GITHUB_SHA" ]; then
|
||||
echo "Skip deploy: origin/main is $tip, this run verified $GITHUB_SHA"
|
||||
echo "stale=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
echo "stale=false" >> "$GITHUB_OUTPUT"
|
||||
# Cooldown script is plain Node — no pnpm. Disable package-manager-cache
|
||||
# here: setup-node@v5 defaults to true and looks for pnpm before action-setup.
|
||||
- uses: actions/setup-node@v5
|
||||
if: steps.tip.outputs.stale != 'true'
|
||||
with:
|
||||
node-version: 24
|
||||
package-manager-cache: false
|
||||
- name: Require Vercel credentials
|
||||
if: steps.tip.outputs.stale != 'true'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
missing=()
|
||||
[ -z "${VERCEL_TOKEN:-}" ] && missing+=(VERCEL_TOKEN)
|
||||
[ -z "${VERCEL_ORG_ID:-}" ] && missing+=(VERCEL_ORG_ID)
|
||||
[ -z "${VERCEL_PROJECT_ID:-}" ] && missing+=(VERCEL_PROJECT_ID)
|
||||
if [ "${#missing[@]}" -gt 0 ]; then
|
||||
echo "::error::Missing repo secrets for gated deploy: ${missing[*]}. Add them under Settings → Secrets, then re-run this workflow."
|
||||
exit 1
|
||||
fi
|
||||
# Fail closed on obvious paste mistakes (must be team_/prj_ from .vercel/project.json).
|
||||
case "$VERCEL_ORG_ID" in team_*) ;; *)
|
||||
echo "::error::VERCEL_ORG_ID must look like team_… (from .vercel/project.json after vercel link)."
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
case "$VERCEL_PROJECT_ID" in prj_*) ;; *)
|
||||
echo "::error::VERCEL_PROJECT_ID must look like prj_… (from .vercel/project.json after vercel link)."
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
- name: Check deploy cooldown
|
||||
id: cooldown
|
||||
if: steps.tip.outputs.stale != 'true'
|
||||
run: node scripts/vercel-deploy-cooldown.mjs
|
||||
- name: Log deploy deferral
|
||||
if: steps.tip.outputs.stale != 'true' && steps.cooldown.outputs.active == 'true'
|
||||
run: |
|
||||
echo "Deploy deferred: last production deploy is younger than 15 minutes."
|
||||
echo "Requeue will retry in ${{ steps.cooldown.outputs.wait_seconds }}s."
|
||||
# `vercel build` shells out to pnpm (packageManager in package.json); without
|
||||
# these steps the runner only has npm → spawn pnpm ENOENT.
|
||||
- uses: pnpm/action-setup@v6
|
||||
if: steps.tip.outputs.stale != 'true' && steps.cooldown.outputs.active != 'true'
|
||||
- uses: actions/setup-node@v5
|
||||
if: steps.tip.outputs.stale != 'true' && steps.cooldown.outputs.active != 'true'
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
- name: Install Vercel CLI
|
||||
if: steps.tip.outputs.stale != 'true' && steps.cooldown.outputs.active != 'true'
|
||||
run: npm install --global vercel@55
|
||||
- name: Deploy production
|
||||
if: steps.tip.outputs.stale != 'true' && steps.cooldown.outputs.active != 'true'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Workflow-level env points at the CI teqo_test service. Drop it so
|
||||
# `vercel build` uses `.vercel/.env.production.local` from `vercel pull`
|
||||
# (otherwise Payload migrate hits 127.0.0.1:5432 → ECONNREFUSED).
|
||||
unset DATABASE_URL PAYLOAD_SECRET NEXT_PUBLIC_SITE_URL
|
||||
# Canonical CI link (Vercel KB): env VERCEL_*_ID + pull → build → deploy --prebuilt.
|
||||
# "Could not retrieve Project Settings" usually means bad/expired token or wrong IDs (403).
|
||||
vercel pull --yes --environment=production --token="$VERCEL_TOKEN"
|
||||
vercel build --prod --token="$VERCEL_TOKEN"
|
||||
# stdout is always the deployment URL (CLI contract). Capture it — do not
|
||||
# pipe the whole CLI log or the exit code / URL get lost.
|
||||
deploy_url=$(vercel deploy --prebuilt --prod --archive=tgz --token="$VERCEL_TOKEN")
|
||||
echo "Deployed: $deploy_url"
|
||||
# Git auto-deploy OFF is intentional. If "Auto-assign Custom Production
|
||||
# Domains" is also OFF, --prod only stages *.vercel.app and leaves
|
||||
# pt.jorgesolla.com.br on the previous Current — promote + verify here.
|
||||
node scripts/vercel-ensure-production-alias.mjs "$deploy_url"
|
||||
# Docs: promote staged production, then alias (Git Integration is OFF by design).
|
||||
vercel promote "$deploy_url" --token="$VERCEL_TOKEN" --scope="$VERCEL_ORG_ID" || true
|
||||
# `--yes` is only valid for `alias rm`, not `alias set` (CLI 55).
|
||||
vercel alias set "$deploy_url" pt.jorgesolla.com.br --token="$VERCEL_TOKEN" --scope="$VERCEL_ORG_ID"
|
||||
|
||||
requeue:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [checks, deploy]
|
||||
if: always()
|
||||
timeout-minutes: 40
|
||||
permissions:
|
||||
actions: write
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Re-dispatch if main advanced or deploy deferred
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
DEPLOY_RESULT: ${{ needs.deploy.result }}
|
||||
DEPLOY_COOLDOWN: ${{ needs.deploy.outputs.cooldown }}
|
||||
DEPLOY_WAIT_SECONDS: ${{ needs.deploy.outputs.wait_seconds }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git fetch origin main --quiet
|
||||
tip=$(git rev-parse origin/main)
|
||||
if [ "$tip" != "$GITHUB_SHA" ]; then
|
||||
echo "origin/main moved to $tip (verified $GITHUB_SHA) — requeue ci.yml"
|
||||
gh workflow run ci.yml --ref main
|
||||
exit 0
|
||||
fi
|
||||
if [ "${DEPLOY_RESULT:-}" != "success" ]; then
|
||||
echo "Deploy job did not succeed (${DEPLOY_RESULT:-skipped}) — idle"
|
||||
exit 0
|
||||
fi
|
||||
if [ "${DEPLOY_COOLDOWN:-}" = "true" ]; then
|
||||
wait="${DEPLOY_WAIT_SECONDS:-0}"
|
||||
echo "Deploy deferred by cooldown — waiting ${wait}s then requeue ci.yml"
|
||||
sleep "$wait"
|
||||
git fetch origin main --quiet
|
||||
tip=$(git rev-parse origin/main)
|
||||
if [ "$tip" != "$GITHUB_SHA" ]; then
|
||||
echo "main advanced during cooldown wait ($tip) — requeue for new tip"
|
||||
else
|
||||
echo "Cooldown elapsed for $GITHUB_SHA — requeue ci.yml"
|
||||
fi
|
||||
gh workflow run ci.yml --ref main
|
||||
exit 0
|
||||
fi
|
||||
echo "HEAD still $GITHUB_SHA — idle"
|
||||
170
.github/workflows/deploy.yml
vendored
|
|
@ -1,170 +0,0 @@
|
|||
# Manual production deploy (OPS71 — GitHub Actions). `workflow_dispatch`
|
||||
# only: there is NO automatic main verifier anymore (ci.yml eliminated in the
|
||||
# gate 2026-08-19) — publishing is a deliberate operator act, and this
|
||||
# workflow runs the FULL suite (no scope skips, e2e included) before the
|
||||
# deploy job touches the homeserver.
|
||||
#
|
||||
# TWO jobs, same workflow:
|
||||
# verify — ubuntu-latest (hosted): full cascade (guards for PR contracts are
|
||||
# left out — they gate PRs, not production).
|
||||
# deploy — needs: [verify], runs-on [self-hosted, homeserver]: the GitHub
|
||||
# self-hosted runner ON the homeserver executes
|
||||
# scripts/deploy-homeserver.sh LOCALLY (no SSH; outbound-only
|
||||
# runner, works behind the Cloudflare tunnel). The hosted job
|
||||
# never reaches the homeserver; the self-hosted job never counts
|
||||
# hosted minutes.
|
||||
#
|
||||
# The production build CANNOT leave the homeserver (migrate pre-build on the
|
||||
# prod DB — OPS66 — and the compose network / local registry localhost:5000),
|
||||
# which is exactly why the hosted job only verifies and the self-hosted job
|
||||
# only builds + deploys. Deploy is idempotent ("already deployed" guard in
|
||||
# deploy-homeserver.sh) and serialized by flock on the homeserver.
|
||||
#
|
||||
# The `deploy` job fetches the repo from github.com (public) — TEQO_REPO_URL
|
||||
# in deploy-homeserver.sh now defaults there; the script's HEAD guard only
|
||||
# deploys when the dispatched SHA is main's HEAD (a stale dispatch skips).
|
||||
|
||||
name: Deploy (manual)
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
PAYLOAD_SECRET: test-only-secret-not-used-in-production
|
||||
NEXT_PUBLIC_SITE_URL: http://localhost:3000
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 50
|
||||
services:
|
||||
# OPS71 — GitHub hosted: jobs run on the runner VM, so services are
|
||||
# reached at localhost on PUBLISHED host ports (fixed — each run is its
|
||||
# own VM). Two isolated services — int on 5432, build+e2e on 5433 —
|
||||
# keep e2e from inheriting int fixture residue.
|
||||
postgres-int:
|
||||
image: postgres:17-alpine
|
||||
env:
|
||||
POSTGRES_USER: teqo
|
||||
POSTGRES_PASSWORD: teqo
|
||||
POSTGRES_DB: teqo_test
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U teqo"
|
||||
--health-interval 5s
|
||||
--health-timeout 5s
|
||||
--health-retries 12
|
||||
postgres-build:
|
||||
image: postgres:17-alpine
|
||||
env:
|
||||
POSTGRES_USER: teqo
|
||||
POSTGRES_PASSWORD: teqo
|
||||
POSTGRES_DB: teqo_test
|
||||
ports:
|
||||
- 5433:5432
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U teqo"
|
||||
--health-interval 5s
|
||||
--health-timeout 5s
|
||||
--health-retries 12
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: pnpm/action-setup@v6
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Check test file locations
|
||||
run: node scripts/check-test-locations.mjs
|
||||
- name: Lint
|
||||
run: pnpm lint
|
||||
- name: Format check
|
||||
run: pnpm format:check
|
||||
- name: Typecheck
|
||||
run: pnpm typecheck
|
||||
- name: Knip
|
||||
run: pnpm knip
|
||||
- name: Check cycles
|
||||
run: pnpm check:cycles
|
||||
- name: Unit tests (full suite)
|
||||
run: pnpm test:unit
|
||||
|
||||
- name: Prepare database URL (int service)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "DATABASE_URL=postgresql://teqo:teqo@localhost:5432/teqo_test" >> "$GITHUB_ENV"
|
||||
# vitest re-loads .env.test over the process env, so the service URL
|
||||
# must win through the .env.test.local layer (same mechanism
|
||||
# `pnpm worktree next` uses).
|
||||
echo "DATABASE_URL=postgresql://teqo:teqo@localhost:5432/teqo_test" > .env.test.local
|
||||
- name: Migrate (int database)
|
||||
run: pnpm migrate
|
||||
- name: Seed minimal (int database)
|
||||
run: pnpm db:seed:minimal
|
||||
- name: Integration tests (full suite)
|
||||
env:
|
||||
# vitest forks ≈ CPUs and each fork holds a Payload pool (max 10) →
|
||||
# overflows the service Postgres' 100 clients. 4 forks stay well
|
||||
# under. The unit step above stays CPU-relative (no DB).
|
||||
VITEST_MAX_WORKERS: 4
|
||||
run: pnpm test:int
|
||||
|
||||
- name: Prepare database URL (build/e2e service)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "DATABASE_URL=postgresql://teqo:teqo@localhost:5433/teqo_test" >> "$GITHUB_ENV"
|
||||
echo "DATABASE_URL=postgresql://teqo:teqo@localhost:5433/teqo_test" > .env.test.local
|
||||
- name: Migrate (build/e2e database)
|
||||
run: pnpm migrate
|
||||
- name: Seed minimal (build/e2e database)
|
||||
run: pnpm db:seed:minimal
|
||||
- name: Build (against the service database)
|
||||
run: pnpm build
|
||||
|
||||
- name: Cache Playwright browsers
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
playwright-${{ runner.os }}-
|
||||
- name: Install Playwright chromium
|
||||
run: pnpm exec playwright install --with-deps chromium
|
||||
- name: Build for e2e
|
||||
# Same dir the Playwright webServer serves (playwright.config.ts
|
||||
# falls back to '.next-e2e'; gate-ci.mjs builds there too).
|
||||
run: pnpm build
|
||||
env:
|
||||
NEXT_DIST_DIR: .next-e2e
|
||||
- name: E2E tests (full suite, single job, 4 workers)
|
||||
# Explicit CI workers; the local default stays 2 (playwright.config.ts).
|
||||
# No `--` separator: pnpm would forward it verbatim to playwright and
|
||||
# the flag would be parsed as a file pattern ("No tests found").
|
||||
env:
|
||||
PLAYWRIGHT_WORKERS: 4
|
||||
run: pnpm test:e2e
|
||||
|
||||
deploy:
|
||||
name: deploy homeserver (teqo-1313)
|
||||
# OPS53 + OPS71. Runs on the GitHub self-hosted runner ON the homeserver
|
||||
# (labels self-hosted + homeserver): the script executes locally — no SSH
|
||||
# hop, no workstation involvement. Deliberately wired ONLY here (manual
|
||||
# dispatch), never in ci-pr.yml: a self-hosted job is code execution on
|
||||
# the production machine, and only an explicit dispatch with the full
|
||||
# verify suite green can reach it.
|
||||
needs: [verify]
|
||||
if: needs.verify.result == 'success'
|
||||
runs-on: [self-hosted, homeserver]
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- name: Deploy teqo-1313 on the homeserver
|
||||
run: bash scripts/deploy-homeserver.sh "$GITHUB_SHA"
|
||||
78
.github/workflows/issue-done-on-main-merge.yml
vendored
|
|
@ -1,80 +1,28 @@
|
|||
# Main merge flips claimed issues to done + in-prod.
|
||||
# OPS71: the PR lives on GitHub; the ISSUES stay on the Forgejo tracker. The
|
||||
# job passes the PR body via PR_BODY (GitHub event payload) and
|
||||
# scripts/forgejo-issue-transition.mjs flips labels/comments and closes the
|
||||
# issue on the Forgejo API with the FORGEJO_API_TOKEN PAT (the built-in
|
||||
# GITHUB_TOKEN cannot touch Forgejo issues — OPS61). The fork guard is the
|
||||
# same class as agent-pr-ready-automerge.yml: on a merged fork PR the checkout
|
||||
# pulls the fork's own script, which would run with the repo PAT (RCE).
|
||||
#
|
||||
# OPS71-FLIP: `workflow_dispatch` is the MANUAL recovery path. The `closed`
|
||||
# event does NOT create a run when the merge was performed by
|
||||
# github-actions[bot] (auto-merge armed with the built-in GITHUB_TOKEN —
|
||||
# GitHub's anti-recursion; live finding PR #746). Dispatch with the PR number:
|
||||
# the job fetches the PR via `gh` (same contract: merged + base main +
|
||||
# same-repo) and runs the same flip.
|
||||
name: Main merge flips claimed issues to done + in-prod
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [closed]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
pr:
|
||||
description: 'PR number (manual recovery when the closed event did not create a run)'
|
||||
required: true
|
||||
type: number
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: read
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
issue-done:
|
||||
if: >-
|
||||
(github.event_name == 'pull_request' && github.event.pull_request.base.ref == 'main' && github.event.pull_request.merged == true && github.event.pull_request.head.repo.full_name == github.repository) ||
|
||||
(github.event_name == 'workflow_dispatch' && inputs.pr != '')
|
||||
if: github.event.pull_request.base.ref == 'main' && github.event.pull_request.merged == true
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
FORGEJO_API_TOKEN: ${{ secrets.FORGEJO_API_TOKEN }}
|
||||
# Explicit base: in the GitHub Actions env, GITHUB_SERVER_URL points at
|
||||
# github.com and forgejo-api.mjs would resolve the wrong base (410).
|
||||
FORGEJO_API_URL: https://git.solla.dev/api/v1
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
# Script is plain Node (stdlib) — no pnpm. Disable package-manager-cache:
|
||||
# setup-node@v5 defaults to true and looks for pnpm before pnpm/action-setup.
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 24
|
||||
package-manager-cache: false
|
||||
- name: Fetch PR metadata (manual recovery — bot auto-merges skip the closed event)
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
id: pr
|
||||
run: |
|
||||
PR="${{ inputs.pr }}"
|
||||
STATE="$(gh pr view "$PR" --json state -q .state)"
|
||||
if [ "$STATE" != "MERGED" ]; then
|
||||
echo "PR #$PR não mergeada (state=$STATE) — skip"
|
||||
exit 0
|
||||
fi
|
||||
BASE="$(gh pr view "$PR" --json baseRefName -q .baseRefName)"
|
||||
REPO="$(gh pr view "$PR" --json headRepository -q .headRepository.nameWithOwner)"
|
||||
if [ "$BASE" != "main" ] || [ "$REPO" != "${{ github.repository }}" ]; then
|
||||
echo "PR #$PR fora do contrato (base=$BASE repo=$REPO — precisa main + same-repo) — skip"
|
||||
exit 0
|
||||
fi
|
||||
BODY="$(gh pr view "$PR" --json body -q .body)"
|
||||
{
|
||||
echo "valid=true"
|
||||
echo "pr_number=$PR"
|
||||
echo "pr_body<<EOF"
|
||||
echo "$BODY"
|
||||
echo "EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
- name: Flip Closes/Fixes #N issues to done and in-prod
|
||||
if: steps.pr.outputs.valid == 'true' || github.event_name == 'pull_request'
|
||||
env:
|
||||
PR_BODY: ${{ steps.pr.outputs.pr_body || github.event.pull_request.body }}
|
||||
run: node scripts/forgejo-issue-transition.mjs --pr "${{ steps.pr.outputs.pr_number || github.event.pull_request.number }}"
|
||||
run: |
|
||||
body="$(gh pr view "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" --json body --jq .body)"
|
||||
numbers="$(printf '%s' "$body" | grep -oiE '(closes|fixes) #[0-9]+' | grep -oE '[0-9]+' | sort -u)"
|
||||
for n in $numbers; do
|
||||
gh issue edit "$n" --repo "$GITHUB_REPOSITORY" --remove-label in-progress --add-label done --add-label in-prod || true
|
||||
gh issue comment "$n" --repo "$GITHUB_REPOSITORY" \
|
||||
--body "Merged em main via PR #${PR_NUMBER} — \`done\` + \`in-prod\`. Deploy de produção segue o verificador \`ci.yml\` (gated)." || true
|
||||
done
|
||||
|
|
|
|||
|
|
@ -1,88 +1,44 @@
|
|||
# Safety net OPS18: when a PR merges to main with `Related #N`, promote Issues
|
||||
# that are still awaiting their intention plan (`blocked` + docs/plans/ link)
|
||||
# to `ready`. Idempotent with `pnpm agent:ready` (agent happy path). OPS71:
|
||||
# the PR lives on GitHub, the issues on the Forgejo tracker — the job passes
|
||||
# the PR body via PR_BODY and scripts/agent-promote-related-on-merge.mjs flips
|
||||
# labels on the Forgejo API (FORGEJO_API_TOKEN; the built-in GITHUB_TOKEN
|
||||
# cannot touch Forgejo issues — OPS61).
|
||||
# to `ready`. Idempotent with `pnpm agent:ready` (agent happy path).
|
||||
#
|
||||
# Does NOT run on open PRs — only after merge — so the claim race stays closed.
|
||||
# Job is gated on Related # in the body so non-plan merges skip checkout/Node.
|
||||
# Fork guard: on a merged fork PR the checkout pulls the fork's own script,
|
||||
# which would run with the repo PAT (RCE — same class as
|
||||
# agent-pr-ready-automerge.yml).
|
||||
#
|
||||
# OPS71-FLIP: `workflow_dispatch` is the MANUAL recovery path — the `closed`
|
||||
# event does NOT create a run when the merge was performed by
|
||||
# github-actions[bot] (auto-merge armed with the built-in GITHUB_TOKEN;
|
||||
# GitHub's anti-recursion, live finding PR #746). Dispatch with the PR number:
|
||||
# the job fetches the PR via `gh` (same contract: merged + base main +
|
||||
# same-repo) and runs the same promotion.
|
||||
name: Promote plan-issue Related on main merge
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [closed]
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
pr:
|
||||
description: 'PR number (manual recovery when the closed event did not create a run)'
|
||||
required: true
|
||||
type: number
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: read
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
plan-issue-ready:
|
||||
if: >-
|
||||
(github.event_name == 'pull_request' && github.event.pull_request.merged == true && github.event.pull_request.head.repo.full_name == github.repository && (contains(github.event.pull_request.body, 'Related #') || contains(github.event.pull_request.body, 'related #') || contains(github.event.pull_request.body, 'RELATED #'))) ||
|
||||
(github.event_name == 'workflow_dispatch' && inputs.pr != '')
|
||||
github.event.pull_request.merged == true &&
|
||||
(
|
||||
contains(github.event.pull_request.body, 'Related #') ||
|
||||
contains(github.event.pull_request.body, 'related #') ||
|
||||
contains(github.event.pull_request.body, 'RELATED #')
|
||||
)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
env:
|
||||
FORGEJO_API_TOKEN: ${{ secrets.FORGEJO_API_TOKEN }}
|
||||
# Explicit base: in the GitHub Actions env, GITHUB_SERVER_URL points at
|
||||
# github.com and forgejo-api.mjs would resolve the wrong base (410).
|
||||
FORGEJO_API_URL: https://git.solla.dev/api/v1
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
PR_BODY: ${{ github.event.pull_request.body }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
# Script is plain Node (stdlib) — no pnpm. Disable
|
||||
# Script is plain Node (gh CLI, stdlib) — no pnpm. Disable
|
||||
# package-manager-cache: setup-node@v5 defaults to true and looks for
|
||||
# pnpm (packageManager in package.json) before pnpm/action-setup.
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 24
|
||||
package-manager-cache: false
|
||||
- name: Fetch PR metadata (manual recovery — bot auto-merges skip the closed event)
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
id: pr
|
||||
run: |
|
||||
PR="${{ inputs.pr }}"
|
||||
STATE="$(gh pr view "$PR" --json state -q .state)"
|
||||
if [ "$STATE" != "MERGED" ]; then
|
||||
echo "PR #$PR não mergeada (state=$STATE) — skip"
|
||||
exit 0
|
||||
fi
|
||||
BASE="$(gh pr view "$PR" --json baseRefName -q .baseRefName)"
|
||||
REPO="$(gh pr view "$PR" --json headRepository -q .headRepository.nameWithOwner)"
|
||||
if [ "$BASE" != "main" ] || [ "$REPO" != "${{ github.repository }}" ]; then
|
||||
echo "PR #$PR fora do contrato (base=$BASE repo=$REPO — precisa main + same-repo) — skip"
|
||||
exit 0
|
||||
fi
|
||||
BODY="$(gh pr view "$PR" --json body -q .body)"
|
||||
{
|
||||
echo "valid=true"
|
||||
echo "pr_number=$PR"
|
||||
echo "pr_body<<EOF"
|
||||
echo "$BODY"
|
||||
echo "EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
- name: Promote Related #N awaiting plan → ready
|
||||
if: steps.pr.outputs.valid == 'true' || github.event_name == 'pull_request'
|
||||
env:
|
||||
PR_BODY: ${{ steps.pr.outputs.pr_body || github.event.pull_request.body }}
|
||||
run: node scripts/agent-promote-related-on-merge.mjs --pr "${{ steps.pr.outputs.pr_number || github.event.pull_request.number }}"
|
||||
run: node scripts/agent-promote-related-on-merge.mjs --pr "$PR_NUMBER"
|
||||
|
|
|
|||
72
.github/workflows/vercel-promote.yml
vendored
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
# Promote a READY production deployment to custom domains (no rebuild).
|
||||
# Use when `vercel deploy --prod` staged a deployment but left pt.jorgesolla.com.br
|
||||
# on an older Current (Auto-assign Custom Production Domains OFF).
|
||||
name: Vercel promote production
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
deployment:
|
||||
description: 'Deployment URL or id (empty = latest READY production)'
|
||||
required: false
|
||||
type: string
|
||||
default: ''
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
promote:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
env:
|
||||
VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
|
||||
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
|
||||
VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 24
|
||||
package-manager-cache: false
|
||||
- name: Require Vercel credentials
|
||||
run: |
|
||||
set -euo pipefail
|
||||
missing=()
|
||||
[ -z "${VERCEL_TOKEN:-}" ] && missing+=(VERCEL_TOKEN)
|
||||
[ -z "${VERCEL_ORG_ID:-}" ] && missing+=(VERCEL_ORG_ID)
|
||||
[ -z "${VERCEL_PROJECT_ID:-}" ] && missing+=(VERCEL_PROJECT_ID)
|
||||
if [ "${#missing[@]}" -gt 0 ]; then
|
||||
echo "::error::Missing secrets: ${missing[*]}"
|
||||
exit 1
|
||||
fi
|
||||
- name: Install Vercel CLI
|
||||
run: npm install --global vercel@55
|
||||
- name: Promote to custom production domain
|
||||
env:
|
||||
DEPLOYMENT_REF: ${{ inputs.deployment }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -n "${DEPLOYMENT_REF:-}" ]; then
|
||||
ref="$DEPLOYMENT_REF"
|
||||
else
|
||||
ref=$(node --input-type=module -e '
|
||||
import { fetchLatestProductionReadyDeployment } from "./scripts/lib/vercel-production-alias.mjs";
|
||||
const d = await fetchLatestProductionReadyDeployment({
|
||||
token: process.env.VERCEL_TOKEN,
|
||||
projectId: process.env.VERCEL_PROJECT_ID,
|
||||
teamId: process.env.VERCEL_ORG_ID,
|
||||
});
|
||||
if (!d) {
|
||||
console.error("no READY production deployment");
|
||||
process.exit(1);
|
||||
}
|
||||
process.stdout.write(d.url ? `https://${d.url}` : d.id);
|
||||
')
|
||||
echo "Latest READY production: $ref"
|
||||
fi
|
||||
node scripts/vercel-ensure-production-alias.mjs "$ref"
|
||||
# Docs: when Auto-assign is/was OFF, prefer `vercel promote` then `alias set`.
|
||||
vercel promote "$ref" --token="$VERCEL_TOKEN" --scope="$VERCEL_ORG_ID" || true
|
||||
# `--yes` is only valid for `alias rm`, not `alias set` (CLI 55).
|
||||
vercel alias set "$ref" pt.jorgesolla.com.br --token="$VERCEL_TOKEN" --scope="$VERCEL_ORG_ID"
|
||||
5
.gitignore
vendored
|
|
@ -5,7 +5,6 @@
|
|||
/.pnp
|
||||
.pnp.js
|
||||
.yarn/install-state.gz
|
||||
/.pnpm-store
|
||||
|
||||
/.idea/*
|
||||
!/.idea/runConfigurations
|
||||
|
|
@ -23,7 +22,6 @@
|
|||
# next.js
|
||||
/.next/
|
||||
/.next-e2e/
|
||||
/.next-e2e-check/
|
||||
/out/
|
||||
|
||||
# production
|
||||
|
|
@ -90,6 +88,3 @@ next-env.d.ts
|
|||
|
||||
# Proprietary design font files kept only in the local workspace
|
||||
/public/arial-mt/
|
||||
|
||||
/data/encode/
|
||||
/data/poster-candidates/
|
||||
|
|
|
|||
|
|
@ -1,8 +1,6 @@
|
|||
# Build outputs and tool caches
|
||||
.next/
|
||||
.next-e2e/
|
||||
.next-e2e-check/
|
||||
.pnpm-store/
|
||||
out/
|
||||
build/
|
||||
playwright-report/
|
||||
|
|
|
|||
27
AGENTS.md
|
|
@ -10,8 +10,8 @@ RUN apk add --no-cache libc6-compat && corepack enable
|
|||
FROM base AS deps
|
||||
WORKDIR /app
|
||||
|
||||
# pnpm-workspace.yaml carries onlyBuiltDependencies — pnpm 10 no longer reads the "pnpm" field.
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||
# Install the pinned package manager and dependency graph.
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
# One-shot image used to apply committed Payload migrations before a release.
|
||||
|
|
@ -32,9 +32,7 @@ ENV NEXT_PUBLIC_SITE_URL=$NEXT_PUBLIC_SITE_URL
|
|||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
# Static generation reads Payload data. BuildKit mounts keep credentials out of
|
||||
# layers and image history; migrations run separately through the migrator —
|
||||
# the deploy script applies them BEFORE this build (OPS66: static routes read
|
||||
# the new schema, so the build must see a migrated DB).
|
||||
# layers and image history; migrations run separately through the migrator.
|
||||
RUN --mount=type=secret,id=database_url,env=DATABASE_URL \
|
||||
--mount=type=secret,id=payload_secret,env=PAYLOAD_SECRET \
|
||||
NEXT_OUTPUT_STANDALONE=1 \
|
||||
|
|
|
|||
34
README.md
|
|
@ -6,16 +6,16 @@
|
|||
|
||||
> **Cheatsheet de operação agentic (humanos)** — o fluxo em 5 linhas:
|
||||
>
|
||||
> 1. O agente roda `pnpm agent:claim` (ou `pnpm worktree next --issue N`) e pega a próxima Issue `ready` por prioridade (`prio:P0..P3`). O **tracker de Issues vive no Forgejo** (`git.solla.dev/fsolla/teqo`); o código/PR/CI vive no **GitHub**.
|
||||
> 2. Ele implementa, roda o fast gate (`lint + typecheck + unit`) e `pnpm push -u origin HEAD` — abre a PR para `main` (Ready, `Closes #N`).
|
||||
> 3. CI verde (`CI (PR) / checks`) → o safety net `agent-pr-ready-automerge.yml` arma o **auto-merge nativo** (rebase) — o servidor só mergea com o required check verde.
|
||||
> 4. Publicar é **manual**: dispatch de `deploy.yml` roda a suíte full (`verify`) e o job `deploy` publica no homeserver (`jorgesolla1313.com.br`, runner self-hosted) — migrações aplicadas antes do rollout. Nada é automático pós-merge.
|
||||
> 5. Secrets humanos (uma vez): `FORGEJO_API_TOKEN` no GitHub (flips pós-merge no Forgejo); envs de prod em `~/stack/teqo-1313.env` no homeserver. Branch protection de `main` já aplicada (reaplicar: `pnpm configure:branch-protection`).
|
||||
> 1. O agente roda `pnpm agent:claim` e pega a próxima Issue `ready` por prioridade (`prio:P0..P3`).
|
||||
> 2. Ele implementa, roda o fast gate (`lint + typecheck + unit`) e abre **PR para `main`** — sozinho.
|
||||
> 3. CI verde → o PR mergeia em `main` automaticamente; o verificador `ci.yml` roda a suíte completa.
|
||||
> 4. Suíte verde → Action dispara `vercel deploy --prod` (Vercel Git builds estão desligados).
|
||||
> 5. Secrets humanos (uma vez): `VERCEL_*`, `POOL_GITHUB_TOKEN`; `pnpm configure:branch-protection`.
|
||||
>
|
||||
> Comandos: `pnpm agent:claim | agent:register | agent:prioritize | agent:file-miss | worktree next` e `pnpm db:seed:minimal`.
|
||||
> Comandos: `pnpm agent:claim | agent:register | agent:prioritize | agent:file-miss | agent:pool` e `pnpm db:seed:minimal`.
|
||||
> Labels: estado `ready|in-progress|blocked|done|in-prod`, `prio:P0..P3`, `kind:*`, `needs:migration|consent`, `requirements-changed`.
|
||||
> **Agente faz sozinho:** claim → implementar → PR → main. **Só humano:** deploy (dispatch), envs do homeserver, branch protection, runbook de rollback.
|
||||
> Tudo em detalhe: [`docs/AGENT-OPS.md`](docs/AGENT-OPS.md) · CI: `.github/workflows/ci-pr.yml` · Deploy: `.github/workflows/deploy.yml`.
|
||||
> **Agente faz sozinho:** claim → implementar → PR → main. **Só humano:** secrets Vercel/pool, branch protection, envs Neon.
|
||||
> Tudo em detalhe: [`docs/AGENT-OPS.md`](docs/AGENT-OPS.md) · CI: `.github/workflows/ci-pr.yml` + `ci.yml`.
|
||||
|
||||
Teqo starts as the official digital platform for **deputado Jorge Solla** and evolves into a **white-label civic engagement platform** for politicians in Brazil.
|
||||
|
||||
|
|
@ -35,7 +35,7 @@ Initial delivery focuses on Jorge Solla's public website, editorial CMS, and the
|
|||
|
||||
- Public-facing content and updates (news/`post` + `tag` live)
|
||||
- Institutional pages and biography (still pending a `Pages` collection)
|
||||
- Internal campaign operations: municípios (435, pré-definidos), leaderships, estimates, updates, WhatsApp invites
|
||||
- Internal campaign operations: electoral nuclei, leaderships, estimates, updates, WhatsApp invites
|
||||
- Editorial operations through Payload CMS
|
||||
|
||||
### Phase 2: White-Label Platform
|
||||
|
|
@ -49,7 +49,7 @@ Teqo then becomes a configurable base product for other politicians in Brazil:
|
|||
|
||||
## Local Development
|
||||
|
||||
> **Production safety:** the production database is a live Postgres on the homeserver (`teqo_1313`) holding real citizens' data. Local development and tests run against a **local** Postgres and must never point at production. `pnpm dev` refuses to start against a non-local database, and the test suite refuses any database whose name doesn't end in `_test`.
|
||||
> **Production safety:** the production database is a live Neon Postgres holding real citizens' data. Local development and tests run against a **local** Postgres and must never point at production. `pnpm dev` refuses to start against a non-local database, and the test suite refuses any database whose name doesn't end in `_test`.
|
||||
|
||||
### First-time setup
|
||||
|
||||
|
|
@ -62,8 +62,8 @@ Teqo then becomes a configurable base product for other politicians in Brazil:
|
|||
DATABASE_URL=postgresql://teqo:teqo@localhost:5432/teqo
|
||||
```
|
||||
5. Load a schema + content into the local database — pick one:
|
||||
- **Mirror production content (recommended):** `PROD_DATABASE_URL="<prod connection string>" pnpm db:pull`
|
||||
Copies production's schema and content into local. It only reads production and **excludes supporter PII** (`contact`/`signature`/`subscription` data). The prod string is the `DATABASE_URL` of `~/stack/teqo-1313.env` on the homeserver (Postgres `teqo_1313`).
|
||||
- **Mirror production content (recommended):** `PROD_DATABASE_URL="<unpooled Neon URL>" pnpm db:pull`
|
||||
Copies production's schema and content into local. It only reads production and **excludes supporter PII** (`contact`/`signature`/`subscription` data).
|
||||
- **Empty schema from migrations:** `pnpm migrate`
|
||||
6. Start the dev server: `pnpm dev`
|
||||
7. Open: `http://localhost:3000`
|
||||
|
|
@ -84,7 +84,7 @@ curl -X POST "https://<prod-domain>/api/revalidate" -H "x-revalidate-secret: $RE
|
|||
curl -X POST "https://<prod-domain>/api/revalidate?tag=global_privacy-policy" -H "x-revalidate-secret: $REVALIDATE_SECRET"
|
||||
```
|
||||
|
||||
`REVALIDATE_SECRET` must be set in the production env (`~/stack/teqo-1313.env` on the homeserver — see `.env.example`). See the "Posts & Tags" section of `AGENTS.md` for the full model.
|
||||
`REVALIDATE_SECRET` must be set in the Vercel production env (see `.env.example`). See the "Posts & Tags" section of `AGENTS.md` for the full model.
|
||||
|
||||
### Database migrations
|
||||
|
||||
|
|
@ -97,7 +97,7 @@ To change the schema:
|
|||
3. Review and commit the generated files in `src/migrations/` (both `.ts` and `.json`, plus `index.ts`).
|
||||
4. Apply it locally: `pnpm migrate` (check status anytime with `pnpm migrate:status`).
|
||||
|
||||
**Deploying to production:** merges to `main` with production changes are deployed by the `deploy` job of `ci.yml` (windowed — see `docs/AGENT-OPS.md`); the remote script (`scripts/deploy-homeserver.sh`) builds on the homeserver and applies pending migrations through the compose maintenance service `teqo-1313-migrate` **before** the rollout. Do not run migrations against production by hand. Runbook (rollback, known failures): `docs/ops/teqo-1313-deploy.md`.
|
||||
**Deploying to production:** `pnpm build` runs `payload migrate` before building, so every Vercel deploy automatically applies pending migrations to the production database. Do not run migrations against production by hand.
|
||||
|
||||
### Running tests
|
||||
|
||||
|
|
@ -113,7 +113,7 @@ Then run:
|
|||
- `pnpm test:e2e` — Playwright (requires the test DB schema and a free port)
|
||||
- `pnpm test:all` — unit + integration + E2E
|
||||
|
||||
Quality gates (also enforced by CI on the Forgejo Actions): `pnpm lint` (zero warnings — `--max-warnings=0`), `pnpm typecheck`, and `pnpm exec knip` (dead files/dependencies fail; delete what your change orphaned). Standards: `.agents/rules/engineering-standards.mdc`.
|
||||
Quality gates (also enforced by CI on the GitHub mirror): `pnpm lint` (zero warnings — `--max-warnings=0`), `pnpm typecheck`, and `pnpm exec knip` (dead files/dependencies fail; delete what your change orphaned). Standards: `.agents/rules/engineering-standards.mdc`.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
|
|
@ -124,10 +124,10 @@ Quality gates (also enforced by CI on the Forgejo Actions): `pnpm lint` (zero wa
|
|||
|
||||
## Campaign (`/campanha`)
|
||||
|
||||
Internal campaign tool for municípios (435 pré-definidos), local leaderships, vote estimates, field updates, and WhatsApp invites. Authenticated separately from `/admin` via the `campaignUser` collection (`coordinator` / `advisor` / `leader` / `candidate`). Operational status and decisions: [`.agents/rules/projects/nucleos-eleitorais.mdc`](.agents/rules/projects/nucleos-eleitorais.mdc). Conventions and deploy checklist: `AGENTS.md` (“Campaign auth” / “Campaign Municípios model”).
|
||||
Internal campaign tool for electoral nuclei, local leaderships, vote estimates, field updates, and WhatsApp invites. Authenticated separately from `/admin` via the `campaignUser` collection (`geral` / `coordenador` / `lideranca`). Operational status and decisions: [`.agents/rules/projects/nucleos-eleitorais.mdc`](.agents/rules/projects/nucleos-eleitorais.mdc). Conventions and deploy checklist: `AGENTS.md` (“Campaign auth” / “Campaign nuclei MVP”).
|
||||
|
||||
**Production blocker:** do not load real leadership data or enable invites until counsel-approved `Consent.key = 'lideranca-autopreenchimento'` exists. Absolute invite URLs require `NEXT_PUBLIC_SITE_URL` as an exact HTTPS DNS origin in production.
|
||||
|
||||
## Roadmap
|
||||
|
||||
Backlog and future plans live as tracked [Forgejo Issues](https://git.solla.dev/fsolla/teqo/issues) (spec + deps + prio + model per issue; flow skills: `plan-issue` → `work-issue` → `project-status`; `docs/roadmap.md` is a frozen legacy stub).
|
||||
Backlog and future plans live as tracked [GitHub Issues](https://github.com/fsolla/teqo/issues) (spec + deps + prio + model per issue; flow skills: `plan-issue` → `work-issue` → `project-status`; `docs/roadmap.md` is a frozen legacy stub).
|
||||
|
|
|
|||
|
|
@ -1,27 +1,27 @@
|
|||
# AGENT-OPS — operação do paradigma de agentes paralelos
|
||||
|
||||
Uma página. Norte: simplicidade (Forgejo = tracker, GitHub = Actions), agente entrega em `main`, deploy de produção é **manual** com verificação full (OPS71).
|
||||
Uma página. Norte: simplicidade (só GitHub + Cursor), agente entrega em `main`, deploy de produção só após o verificador full-suite verde.
|
||||
|
||||
## Ambientes de dados (test ladder — nunca misturar)
|
||||
|
||||
| Ambiente | Conteúdo | Quem usa |
|
||||
| ------------------------------------------- | ------------------------------------------------------------ | -------------------------------------------- |
|
||||
| **Mínimo** (`teqo_test` local / service CI) | schema migrado + `pnpm db:seed:minimal` (sintético, sem PII) | agentes, Cursor Cloud, CI |
|
||||
| **Prod** (`teqo_1313`, homeserver) | real | job `deploy` do `deploy.yml` (OPS53 + OPS71) |
|
||||
| Ambiente | Conteúdo | Quem usa |
|
||||
| ------------------------------------------- | ------------------------------------------------------------ | -------------------------------- |
|
||||
| **Mínimo** (`teqo_test` local / service CI) | schema migrado + `pnpm db:seed:minimal` (sintético, sem PII) | agentes, Cursor Cloud, CI |
|
||||
| **Prod** (Neon prod) | real | deploy Vercel gated por `ci.yml` |
|
||||
|
||||
Agentes **nunca** recebem `DATABASE_URL` de prod e nunca setam `ALLOW_REMOTE_DB`. Não há mais smoke `stage` / `ALLOW_STAGE_TEST_DB` / `ci-stage.yml`.
|
||||
Agentes **nunca** recebem `DATABASE_URL` de prod e nunca setam `ALLOW_REMOTE_DB`. Não há mais smoke Neon `stage` / `ALLOW_STAGE_TEST_DB` / `ci-stage.yml`.
|
||||
|
||||
## Fluxo
|
||||
|
||||
```text
|
||||
claim → feature branch → PR --base main (GitHub) → CI PR green (cascade + skips) → auto-merge nativo em main (rebase)
|
||||
main → (opcional, deliberado) workflow_dispatch deploy.yml → verify full suite verde → job deploy no homeserver (OPS53)
|
||||
claim → feature branch → PR --base main → CI PR green (cascade + skips) → auto-merge em main
|
||||
main → ci.yml full suite → vercel deploy --prod (se verde) → requeue se HEAD andou
|
||||
```
|
||||
|
||||
**Skills:** `plan-issue` (intenção + Issues) → `work-issue` (humano: Issue já claimada → impl plan → confirmação → execução) ou `agent-work-issue` (pool: já claimada → impl plan → execução sem pausa) → `/simplify` → `capture-review-debts` → PR `--base main` (GitHub) → `project-status`. `docs/roadmap.md` = legado congelado; fonte canônica = Issues do Forgejo (`git.solla.dev/fsolla/teqo`).
|
||||
**Skills:** `plan-issue` (intenção + Issues) → `work-issue` (humano: Issue já claimada → impl plan → confirmação → execução) ou `agent-work-issue` (pool: já claimada → impl plan → execução sem pausa) → `/simplify` → `capture-review-debts` → PR `--base main` → `project-status`. `docs/roadmap.md` = legado congelado; fonte canônica = GitHub Issues.
|
||||
|
||||
- **Agente faz sozinho:** claim (pool-supervisor no pool; humano via `pnpm agent:claim` fora da sessão — OPS33: `worktree next` claima) → implementa → **e2e local afetado** (OPS72, discricionário: roda os e2e que criou + os da mesma superfície — `pnpm test:e2e:affected`; ver skill) → **`pnpm push`** (origin = GitHub) → PR via `node scripts/github-pr.mjs` **Ready** (nunca draft) com `Closes #N` → o safety net `agent-pr-ready-automerge.yml` arma o **auto-merge nativo do GitHub** (`enablePullRequestAutoMerge`, rebase) — o servidor mergea quando o required check `CI (PR) / checks` fica verde. Regra always-on: `.agents/rules/agent-pr-workflow.mdc`. Em Cursor Cloud: `ManagePullRequest` com `draft: false` (o default draft da tool **não** vale neste repo) — o merge continua pelo safety net.
|
||||
- **Só humano:** envs de produção no homeserver (`~/stack/teqo-1313.env`, fora do repo — o deploy as lê via BuildKit secrets), `GITHUB_TOKEN` local (PAT) para os scripts GitHub, secrets no repo GitHub, `pnpm configure:branch-protection` (idempotente; reaplicar se o drift voltar), runbook manual/rollback (`docs/ops/teqo-1313-deploy.md`).
|
||||
- **Agente faz sozinho:** claim (pool-supervisor no pool; humano via `pnpm agent:claim` fora da sessão — OPS33: `worktree next` claima) → implementa → **`pnpm push`** → PR **Ready** (nunca draft) com `Closes #N` → `gh pr merge --auto --rebase` → `gh pr checks --watch --required`. Regra always-on: `.agents/rules/agent-pr-workflow.mdc`. Em Cursor Cloud: `ManagePullRequest` com `draft: false`, depois armar auto-merge via `gh pr merge --auto --rebase` (o default draft da tool **não** vale neste repo).
|
||||
- **Só humano:** secrets Vercel (`VERCEL_TOKEN`, `VERCEL_ORG_ID`, `VERCEL_PROJECT_ID`), `POOL_GITHUB_TOKEN`, `pnpm configure:branch-protection`, editar envs Neon/Vercel.
|
||||
|
||||
### Dono do PR, dono do CI
|
||||
|
||||
|
|
@ -30,35 +30,34 @@ main → (opcional, deliberado) workflow_dispatch deploy.yml → verify full sui
|
|||
### Bypass de CI (cutover / transição)
|
||||
|
||||
- **Local:** se `gate:push` ainda espelha pipeline antigo e bloqueia push legítimo da PR que o muda → `git push --no-verify` permitido como escape de transição (não WIP permanente). Documentado aqui; após o cutover, entregas passam no CI novo sem bypass.
|
||||
- **Cutover humano:** pode relaxar temporary required checks se o flip de proteção travar merges; reverter para `CI (PR) / checks` em `main` assim que o CI novo estiver verde.
|
||||
- **Cutover humano:** pode relaxar temporary required checks se o flip de proteção travar merges; reverter para `checks` em `main` assim que o CI novo estiver verde.
|
||||
|
||||
## Comandos
|
||||
|
||||
| Comando | Faz |
|
||||
| ------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `pnpm agent:claim [-- --dry-run]` | Fila ready+unblocked por prio → `in-progress` + brief (Forgejo) |
|
||||
| `pnpm worktree next [--issue N] [--stay]` | Claim determinístico (mesma fila/lock do claim) → worktree + provisionamento |
|
||||
| `pnpm agent:register -- --id X --title T [--prio P1] [--depends A,B] [--plan docs/plans/x.md] [--model slug]` | Cria Issue no Forgejo (`--plan` ⇒ `blocked` até `agent:ready`; sem plano ⇒ `ready`) |
|
||||
| `pnpm agent:ready -- --issue N[,N…]` | Pós-merge do plano: `blocked`→`ready` (só Issues com link `docs/plans/`) |
|
||||
| `pnpm agent:status` | Overview / fila / mermaid |
|
||||
| `pnpm agent:prioritize -- <issue> <P0..P3>` | Troca `prio:*` |
|
||||
| `pnpm agent:file-miss -- --title ...` | Issue `kind:agent-miss` |
|
||||
| `pnpm agent:pool -- status\|tick --dry-run\|doctor` | Pool Cloud **dormente (OPS65)** — o workflow `agent-pool.yml` foi removido; dispatch falha 404; scripts/skill ficam para histórico |
|
||||
| `pnpm push [-- -u origin HEAD …]` | Canônico: ensure-deps + gate:push + push para o **GitHub** (origin) |
|
||||
| `GITHUB_TOKEN=… node scripts/github-pr.mjs --head <b> --title <t> --body-file <f>` | Abre PR no **GitHub** (Ready, base `main`, nunca draft) |
|
||||
| `GITHUB_TOKEN=… pnpm configure:branch-protection [-- --dry-run]` | Proteção de `main` no **GitHub**: required check `CI (PR) / checks` (idempotente — cria/atualiza/no-op) |
|
||||
| `pnpm db:seed:minimal` | DB mínimo sintético |
|
||||
| Comando | Faz |
|
||||
| ------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- |
|
||||
| `pnpm agent:claim [-- --dry-run]` | Fila ready+unblocked por prio → `in-progress` + brief |
|
||||
| `pnpm worktree next [--issue N] [--stay]` | Claim determinístico (mesma fila/lock do claim) → worktree + provisionamento |
|
||||
| `pnpm agent:register -- --id X --title T [--prio P1] [--depends A,B] [--plan docs/plans/x.md] [--model slug]` | Cria Issue (`--plan` ⇒ `blocked` até `agent:ready`; sem plano ⇒ `ready`) |
|
||||
| `pnpm agent:ready -- --issue N[,N…]` | Pós-merge do plano: `blocked`→`ready` (só Issues com link `docs/plans/`) |
|
||||
| `pnpm agent:status` | Overview / fila / mermaid |
|
||||
| `pnpm agent:prioritize -- <issue> <P0..P3>` | Troca `prio:*` |
|
||||
| `pnpm agent:file-miss -- --title ...` | Issue `kind:agent-miss` |
|
||||
| `pnpm agent:pool -- status\|tick --dry-run\|doctor` | Pool Cloud; start/stop via `gh workflow run agent-pool.yml` |
|
||||
| `pnpm push [-- -u origin HEAD …]` | Canônico: ensure-deps + gate:push + push |
|
||||
| `pnpm configure:branch-protection [-- --dry-run]` | Proteção de `main`: `checks`, `strict=false`, 0 reviews |
|
||||
| `pnpm db:seed:minimal` | DB mínimo sintético |
|
||||
|
||||
Labels (Forgejo): `ready|in-progress|blocked|done|in-prod`, `prio:*`, `kind:*`, `needs:*`.
|
||||
Labels: `ready|in-progress|blocked|done|in-prod`, `prio:*`, `kind:*`, `needs:*`.
|
||||
|
||||
**plan-issue (OPS17 + OPS18):** gate pós-overview fecha o lote antes de qualquer Issue/PR; register com `--plan` nasce `blocked` (não entra na fila do claim/pool); após o PR de planos mergear em `main` (`Related #N`), promote dual idempotente: (A) `pnpm agent:ready` na sessão; (B) Action `plan-issue-ready-on-main-merge.yml` lê `Related #N` e promove Issues ainda “aguardando plano” (`blocked` + link `docs/plans/`). Chores sem plano continuam nascendo `ready`.
|
||||
|
||||
## Contrato de PR
|
||||
|
||||
1. Schema → atualizar `db:seed:minimal` no mesmo PR (`needs:migration`).
|
||||
2. Migrations NÃO são serializadas entre PRs: o CI (migrate + int, incl. `campaignMigrationReconciliation`) valida a cadeia em todo PR; rebase antes de `migrate:create` continua obrigatório.
|
||||
3. Ready + auto-merge: `pnpm push -u origin HEAD` (GitHub) → PR via `node scripts/github-pr.mjs` (Ready, base `main`, `Closes #N`) → o safety net `agent-pr-ready-automerge.yml` (`scripts/github-pr-automerge.mjs`) arma o **auto-merge nativo do GitHub por rebase** — o servidor espera o required check `CI (PR) / checks` e mergea; nada a armar por PR, não há `gh` (OPS50) e não há poll (OPS71). **Merge é por rebase** (nunca merge commit): preserva a linha histórica limpa do `main` e é o modo do auto-merge.
|
||||
4. **Changelog da entrega (OPS44):** toda entrega escreve `docs/changelog/<data>-<id>.md` (ex. `2026-08-13-ops44.md`) e roda `pnpm changelog:build` antes do push — o agregado `docs/CHANGELOG-AGENTS.md` é **insert-only** (nunca remove/reescreve entradas; entradas históricas não são migradas). Guard de CI `docs-guards`: (a) agregado é append-only (multiset sobre blobs — perda de linha falha); (b) `docs/changelog/` é additions-only (M/D falham); (c) agregado está up to date com `docs/changelog/` (`changelog:check`); (d) nenhum marcador de conflito em diffs de markdown (docs/, AGENTS.md, .agents/ — cobre PRs docs-only, onde a suíte unit não roda). **Desde OPS63 os mesmos três checks rodam também no pre-push local** (`gate:push` → `gate:ci`, fase 1 — mesmos scripts, default `origin/main`). Restauração legítima (header muda, restauração estilo D8) escapa escrevendo **`changelog-rewrite: <motivo>` como linha própria** no body do PR — definição canônica aqui; o texto do checkbox do PR template nunca ativa (linha-ancorado). **O escape é CI-only (body do PR):** no pre-push local a restauração legítima falha com a mesma mensagem do CI — bypass documentado é `git push --no-verify` direto (o CI, com o escape no body, continua sendo a via da restauração).
|
||||
2. Migrations NÃO são serializadas entre PRs: o CI (migrate + int, incl. `campaignMigrationReconciliation`) valida a cadeia em todo PR e na main; rebase antes de `migrate:create` continua obrigatório.
|
||||
3. Ready + auto-merge: `gh pr create --base main` → `gh pr merge --auto --rebase`; `gh pr checks --watch --required` (Vercel Git não é gate). **Merge é por rebase** (nunca merge commit): preserva a linha histórica limpa do `main` e é o modo que o CI guarda contra conflitos latentes.
|
||||
4. **Changelog da entrega (OPS44):** toda entrega escreve `docs/changelog/<data>-<id>.md` (ex. `2026-08-13-ops44.md`) e roda `pnpm changelog:build` antes do push — o agregado `docs/CHANGELOG-AGENTS.md` é **insert-only** (nunca remove/reescreve entradas; entradas históricas não são migradas). Guard de CI `docs-guards`: (a) agregado é append-only (multiset sobre blobs — perda de linha falha); (b) `docs/changelog/` é additions-only (M/D falham); (c) agregado está up to date com `docs/changelog/` (`changelog:check`); (d) nenhum marcador de conflito em diffs de markdown (docs/, AGENTS.md, .agents/ — cobre PRs docs-only, onde a suíte unit não roda). Restauração legítima (header muda, restauração estilo D8) escapa escrevendo **`changelog-rewrite: <motivo>` como linha própria** no body do PR — definição canônica aqui; o texto do checkbox do PR template nunca ativa (linha-ancorado). A própria troca `--merge` → `--rebase` deste doc usou o escape — header do agregado mudou.
|
||||
|
||||
## Registries compartilhados (serialização por arquivo, não por PR)
|
||||
|
||||
|
|
@ -68,54 +67,45 @@ Arquivos de "lista" que N agentes escrevem em paralelo (changelog, plans, migra
|
|||
- **`serializes` no plano de intenção** nomeia o registro exato que a Issue toca (ex. `serializes: 'docs/plans/'`), para o executor agendar com clareza — é dica de ordem, não lock (rebase continua obrigatório).
|
||||
- O rebase (Contrato de PR item 3) é o mecanismo real de conciliação: conflitos de append resolvem com `git checkout --ours/--theirs` por lado.
|
||||
|
||||
## CI por alvo (GitHub Actions, OPS71)
|
||||
## CI por alvo
|
||||
|
||||
| Workflow | Trigger | Banco | Passos |
|
||||
| ------------------------------------ | --------------------------------------------------------------- | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `ci-pr.yml` | PR → `main` (same-repo) | 2× service Postgres 17 | Job único `checks` sequencial (OPS62 X1): guards → lint→format→typecheck→knip→cycles→unit→int→build→e2e; skips via `ci-scope.mjs` (step `scope`); **fail-fast estrutural** — step vermelho interrompe o job, o run falha; `concurrency` nativo cancela o run em push novo; e2e **apenas blast radius** (`selected`, nunca `full` — OPS72) em 1 processo com `PLAYWRIGHT_WORKERS=4`; 2 services por nome na rede do job (`postgres-int`/`postgres-build`, sem publish host); fork-PR excluída no `if:` do job (repo público — não queima minutes) |
|
||||
| `deploy.yml` | **`workflow_dispatch` manual** | 2× service Postgres 17 | **Dois jobs:** `verify` (hosted, suíte **full** sem skips — lint→format→typecheck→knip→cycles→unit→int→build→e2e full) → `deploy` (`needs: [verify]`, `runs-on: [self-hosted, homeserver]`) executa `scripts/deploy-homeserver.sh <sha>` **localmente** no homeserver (sem SSH; idempotente; HEAD guard; flock; migrator→migrate→runner→rollout→smoke) |
|
||||
| `issue-done-on-main-merge.yml` | PR merged → `main` (same-repo) + **dispatch manual (recovery)** | — | `Closes`/`Fixes` → `done` + `in-prod` + **close** **no Forgejo** (PAT `FORGEJO_API_TOKEN`; body do PR via `PR_BODY` do evento GitHub — no dispatch, busca via `gh` com o mesmo contrato: merged + main + same-repo) |
|
||||
| `plan-issue-ready-on-main-merge.yml` | PR merged → `main` (same-repo) + **dispatch manual (recovery)** | — | `Related #N` aguardando plano → `ready` **no Forgejo** (OPS18; soft-skip; mesmo PAT) |
|
||||
| `agent-pr-ready-automerge.yml` | PR same-repo → `main` (open/sync/…) | — | Safety net via `github-pr-automerge.mjs` (plain Node, REST+GraphQL): draft `cursor/*` → Ready (PATCH); arm `enablePullRequestAutoMerge` (rebase) **com o `AUTOMERGE_PAT`** (fail-closed — nunca o `GITHUB_TOKEN` nativo, cujo merge como `github-actions[bot]` não cria runs no `closed`; OPS71-FLIP) — o **servidor** espera o required check `CI (PR) / checks` (o OPS64 "rollup pode mentir" é estrutural aqui: job único posta um check-run honesto); draft não-`cursor/*` = veto, skip |
|
||||
| `archive-cursor-agent.yml` | dispatch manual (ops) | — | Helper: cancela run + arquiva um agente Cursor Cloud (`CURSOR_API_KEY`) — **dormente (OPS65)** como o pool |
|
||||
| Workflow | Trigger | Banco | Passos |
|
||||
| ------------------------------------ | -------------------------------------- | ------------------- | --------------------------------------------------------------------------------------------------------------------- |
|
||||
| `ci-pr.yml` | PR → `main` | service Postgres 17 | Fase 1 barata → fase 2 cara; skips via `ci-scope.mjs`; fail-fast cancela o run no 1º job vermelho; rollup `checks` |
|
||||
| `ci.yml` | push / dispatch `main` | service Postgres | Full sempre; fail-fast cancela o run no 1º job vermelho → `deploy` (`vercel deploy --prod`) → `requeue` se HEAD andou |
|
||||
| `issue-done-on-main-merge.yml` | PR merged → `main` | — | `Closes`/`Fixes` → `done` + `in-prod` |
|
||||
| `plan-issue-ready-on-main-merge.yml` | PR merged → `main` | — | `Related #N` aguardando plano → `ready` (OPS18; soft-skip) |
|
||||
| `agent-pr-ready-automerge.yml` | PR `cursor/*` → `main` (open/sync/…) | — | Draft→Ready + `gh pr merge --auto --rebase` (safety net; audit incluso) |
|
||||
| `agent-pool.yml` | schedule / PR closed `main` / dispatch | — | Supervisor do pool (`POOL_GITHUB_TOKEN`) |
|
||||
|
||||
`agent-pool.yml` foi **removido no OPS65** (pool dormente; o tick `*/10` custava 144 `pnpm install`/dia na workstation). O verificador de main (`ci.yml`) foi **eliminado no OPS71** (gate 2026-08-19): só o `verify` do dispatch manual roda a suíte full.
|
||||
|
||||
Action runtimes: `actions/checkout@v5`, `actions/setup-node@v5`, `pnpm/action-setup@v6` e `actions/cache@v4`.
|
||||
Action runtimes: `actions/checkout@v5`, `actions/setup-node@v5`, `pnpm/action-setup@v6` e `styfle/cancel-workflow-action@0.13.1` usam Node 24 nativo — sem `FORCE_JAVASCRIPT_ACTIONS_TO_NODE24`.
|
||||
|
||||
Fast gate: `pnpm gate:fast`. Push: `pnpm push`.
|
||||
|
||||
### Secrets (humano, uma vez — repo GitHub)
|
||||
### Secrets (humano, uma vez)
|
||||
|
||||
| Secret | Uso |
|
||||
| ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `FORGEJO_API_TOKEN` | Scripts Forgejo (`forgejo-api.mjs`) e flips pós-merge (`issue-done`, `plan-issue-ready`) — o `GITHUB_TOKEN` nativo do GitHub não toca Issues do Forgejo (OPS61) |
|
||||
| `AUTOMERGE_PAT` | **Arm do auto-merge nativo** (`agent-pr-ready-automerge.yml`) — PAT de usuário real (fsolla) com `pull-requests: write` + `contents: write` no repo. **Sem ele o job falha fail-closed**: o `GITHUB_TOKEN` nativo armado faria o merge como `github-actions[bot]` e o `closed` do PR não criaria runs (anti-recursão do GitHub) — os flips pós-merge ficariam silenciosamente mortos (OPS71-FLIP, PR #746) |
|
||||
| `CURSOR_API_KEY` | Supervisor do pool (Cursor Cloud) — **dormente (OPS65)**; só o helper `archive-cursor-agent.yml` a usa hoje |
|
||||
| Secret | Uso |
|
||||
| ------------------- | ------------------------------------------------------------------------------------ |
|
||||
| `VERCEL_TOKEN` | Deploy gated em `ci.yml` |
|
||||
| `VERCEL_ORG_ID` | idem |
|
||||
| `VERCEL_PROJECT_ID` | idem |
|
||||
| `POOL_GITHUB_TOKEN` | PAT `actions:write` + `issues:write` (variables do pool; `GITHUB_TOKEN` costuma 403) |
|
||||
|
||||
Local: `GITHUB_TOKEN` (PAT com escopo `repo`) para `node scripts/github-pr.mjs`, `pnpm configure:branch-protection` e qualquer script GitHub — o push SSH usa a chave `~/.ssh/id_ed25519` registrada no GitHub. Envs de produção (DB, S3, VAPID, `REVALIDATE_SECRET`, `NEXT_PUBLIC_SITE_URL`) vivem em `~/stack/teqo-1313.env` no homeserver — nunca como secrets do GitHub. `POOL_GITHUB_TOKEN` é **legado** (era da transição GitHub pré-OPS50).
|
||||
Vercel Git builds: `scripts/vercel-ignore-build.sh` skipeia **todas** as branches (incl. `main`) — prod só via Action.
|
||||
|
||||
### Flips pós-merge (OPS71-FLIP): o evento `closed` e a recuperação manual
|
||||
**Dois toggles distintos no dashboard Vercel (não confundir):**
|
||||
|
||||
O flip `issue-done`/`plan-issue-ready` roda no `pull_request: closed` do PR no GitHub — mas **o merge feito pelo auto-merge armado com o `GITHUB_TOKEN` nativo é atribuído a `github-actions[bot]` e NÃO cria workflow runs** (anti-recursão documentada do GitHub: eventos acionados por ações do `GITHUB_TOKEN` não geram runs; o `closed` fica no timeline do PR com 0 runs — achado ao vivo no PR #746). Isso só não acontece porque o arm agora usa o `AUTOMERGE_PAT` (merge atribuído a usuário real → runs criados, comportamento do PR #742).
|
||||
|
||||
**Se mesmo assim o flip não rodou** (evento perdido, run falhou, PR mergeado por bot antes do fix): o workflow tem **`workflow_dispatch` de recuperação** — dispatch de `issue-done-on-main-merge.yml` (ou `plan-issue-ready-on-main-merge.yml`) com o input `pr` = número do PR; o job busca o PR via `gh` e aplica o mesmo contrato do evento (merged + base main + same-repo, senão skip). Fallback local (sem GitHub): `PR_BODY='Closes #N' node scripts/forgejo-issue-transition.mjs --pr <N>` com o `GITHUB_TOKEN` do ambiente (PAT). O flip agora também **fecha** a Issue (`PATCH state=closed` — o merge no GitHub não fecha Issues do Forgejo como o merge nativo da era Forgejo fazia). Falha em qualquer passo = job vermelho (OPS61, nunca mentir); o `forgejo-api.mjs` retenta 410/5xx transitórios (OPS67 + OPS71-FLIP: 410 do proxy retenta em qualquer método — a origem nunca viu o request).
|
||||
|
||||
Branch protection de `main` no **GitHub** (aplicada no cutover do OPS71, via `pnpm configure:branch-protection`): required check `CI (PR) / checks` (a UI exibe workflow/job; o **literal de match é `checks`** — o GitHub casa pelo nome do check-run — pin ao vivo PR #742), `strict=false`, 0 reviews, `enforce_admins: true` (nem admin mergea com CI vermelho — mesmo invariante do rule-based Forgejo). O auto-merge nativo espera esse mesmo check antes de mergear; a regra no servidor é o gate real de qualquer caminho de merge, inclusive API manual. Reaplicar/consertar drift: `pnpm configure:branch-protection` (idempotente).
|
||||
|
||||
Deploy (OPS53 + OPS71): **manual** via `workflow_dispatch` no `deploy.yml` — o job `verify` (hosted, suíte full incl. e2e full) roda **antes** do job `deploy` (`needs: [verify]`), que executa `scripts/deploy-homeserver.sh <sha>` no runner self-hosted do GitHub **no homeserver** (conecta outbound ao GitHub — funciona atrás do Cloudflare tunnel; o hosted nunca toca o homeserver; o self-hosted não conta minutos hosted). O script: HEAD guard (`TEQO_REPO_URL` — default `https://github.com/fsolla/teqo.git`, público) → flock → guard "already deployed" (revision do container) → build do migrator (BuildKit, secrets de `~/stack/teqo-1313.env`) → push/tag em `localhost:5000` → swap dos tags no compose (backup) → **migrations** (`teqo-1313-migrate` — antes do build do runner, OPS66) → build do runner (contra o banco JÁ migrado) → rollout → healthcheck → smoke. Falha = job vermelho; nada é publicado pela metade. Runbook (rollback, falhas conhecidas): `docs/ops/teqo-1313-deploy.md`. O deploy Vercel foi removido no OPS50; o verificador automático de main foi eliminado no OPS71 — se um dia o merge em main quiser rede de regressão, o `ci.yml` volta com 1 linha de trigger (sem deploy).
|
||||
1. **Git / Branch tracking — não disparar build em push** — o que queremos OFF (já pinado em `vercel.json` `git.deploymentEnabled: false` + ignore script). Desligar “automatic deploy from main” no dashboard é o mesmo eixo — **não** confunda com o item 2.
|
||||
2. **Auto-assign Custom Production Domains** (Environments → Production → Branch Tracking) — deve ficar **ON**. Se OFF, `vercel deploy --prod` no Actions cria um deployment "staged" que só recebe `*.vercel.app` e **não** move `pt.jorgesolla.com.br`.
|
||||
3. **Domains → `pt.jorgesolla.com.br` → Git Branch** — deve ficar **vazio** (Production). Domínio com Git Branch só auto-alia via Git Integration; com Git deploy OFF + CLI `--prod`, o host fica preso no Current antigo. O step pós-deploy `scripts/vercel-ensure-production-alias.mjs` reativa auto-assign, **limpa gitBranch**, faz `promote` + `POST /v2/deployments/{id}/aliases` + `vercel alias set`, e **não** confia no campo `alias` de `GET /v13` (pode listar o hostname de produção sem o domínio estar nesse deployment). Emergência sem rebuild: workflow `Vercel promote production`.
|
||||
|
||||
## Cursor Cloud
|
||||
|
||||
`.cursor/environment.json` instala deps + Postgres nativo + `db:seed:minimal`. Sem secrets de prod. Preferir `pnpm push`; escape `git push --no-verify` só no cutover documentado acima. PR via `ManagePullRequest` (draft: false) — o merge continua pelo safety net do GitHub.
|
||||
`.cursor/environment.json` instala deps + Postgres nativo + `db:seed:minimal`. Sem secrets de prod. Preferir `pnpm push`; escape `git push --no-verify` só no cutover documentado acima.
|
||||
|
||||
## Agent pool
|
||||
|
||||
**Dormente (OPS65)** — o workflow `agent-pool.yml` foi removido e o dispatch
|
||||
via CLI falha 404; scripts/skills ficam para histórico. O que foi:
|
||||
Supervisor remoto (`agent-pool.yml`): schedule + replenish em merge → `main` +
|
||||
dispatch. Workers PR `--base main`. Default model `composer-2.5`. Doctor:
|
||||
`pnpm agent:pool -- doctor`. Skill: `agent-pool`.
|
||||
Supervisor remoto (`agent-pool.yml`): schedule + replenish em merge → `main` + dispatch. Workers PR `--base main`. Default model `composer-2.5`. **Secret `POOL_GITHUB_TOKEN`** obrigatório se `GITHUB_TOKEN` 403 em `/actions/variables`. Doctor: `pnpm agent:pool -- doctor`. Skill: `agent-pool`.
|
||||
|
||||
## Guardrails / leitura
|
||||
|
||||
|
|
|
|||
|
|
@ -4,116 +4,8 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente
|
|||
|
||||
---
|
||||
|
||||
**S10 — Pixel do Meta (Facebook) na página pública de campanha (2026-08-19):** o time de campanha paga pode configurar o ID numérico do Pixel (Dataset do Events Manager) no admin — global `SiteSettings` → group "Rastreamento / Ads" → "ID do Pixel do Meta (Facebook)", mesma validação/normalização do precedente dos abaixo-assinados (5–20 dígitos, sem HTML/JS; regras agora vivem num único dono em `facebookPixel.ts`, com o `Petition` refatorado para o helper compartilhado). Com o ID configurado, a home pública de campanha (`/`) carrega o base code + `PageView` do Meta (injeção no layout do grupo `(home)` via `next/script`, escopada só à página de campanha — nada de PageView vazando para notícias/etc.); sem ID, nada é carregado (fail-closed). A conclusão da captura de novidades (S9) dispara exatamente um `Lead` (`content_name` "novidades-da-campanha", `eventID` UUID) no padrão do `PetitionForm` — nunca no page load. Leitura via `getCachedGlobal('site-settings')` (ISR sob a tag `global_site-settings`; a edição do admin já revalida a home). Migração `20260819_213947_add_site_settings_facebook_pixel_id` (colunas `tracking_facebook_pixel_id` + versão, sem backfill). Fora da fatia (gatilhos herdados do plano da petição): CAPI/Advanced Matching, dedup `eventID`, fila/retry de `Lead` e cookie banner/CMP. E2e novo `campaignHomePixel` (script `#meta-pixel-*` presente só com ID configurado, CDN stubada; `mode: serial` por precedente).
|
||||
|
||||
**C141 — perfis de permissão por assessor: Visão × Edição configuráveis (2026-08-19):** o coordenador/candidato define por assessor, em `/campanha/assessores` (coluna "Permissão" com badge + popover, seção no detalhe e no draft de novo assessor), o que ele **vê** (`carteira|tudo`) e o que ele **edita** (`carteira|tudo|somente_leitura`) — contas novas e existentes seguem no padrão Carteira·Edita carteira até configurarem. Enforcement fail-closed no servidor: a leitura alarga com Visão "Tudo" via prólogo novo `resolveProfileScopedRead` (municípios, lideranças, updates, pledges, atividades, contatos derivados), a escrita segue o eixo de Edição (`advisorEditingAccess`) nos `canUpdate*`/`canCreate*` de todos os domínios, no check do giro e no create individual de atividade (ambos contra `getWritableMunicipalityIds`), e a ficha de contatos passou a ser autorizada pelo escopo de escrita (`assertPersonContactWritable` — antes era "você edita o que vê", que a Visão "Tudo" alargaria em escrita de PII). Caps mantidos: demandas seguem a regra própria (C143), apoiadores (PII) e telefones de contas ficam na carteira mesmo com Tudo, e escritas de coordenação (contas, `municipality.advisors`, nível de envolvimento E14, `allocationDecision`) continuam restritas. Combinação incoerente (Edição "Tudo" sem Visão "Tudo") é rejeitada no zod, na action e no hook `enforceCoherentAdvisorProfile` (REST/admin incluídos). Migration `20260819_214755_add_campaign_user_advisor_permission_profile` (duas colunas select com default `'carteira'`, sem backfill).
|
||||
|
||||
**OPS73 — deploy do fix do admin em produção (2026-08-20):** o `/admin` do Payload (jorgesolla1313.com.br) voltou a renderizar o formulário de login em produção — o bug de "tela branca" que travava o CMS do time editorial/campanha está resolvido e verificado. Durante o diagnóstico descobriu-se que a premissa do plano ("produção nunca recebeu o fix OPS69") estava errada: o deploy das 23:09 de 2026-08-19 (revision `8c734ba`) já continha o fix do importMap **no histórico**, mas o commit S9 (`8d2352c0`, captura de novidades na home) tinha regenerado o importMap **sem** as envs `S3_*`, removendo o handler `@payloadcms/storage-s3/client#S3ClientUploadHandler` e reintroduzindo o erro `getFromImportMap: PayloadComponent not found in importMap` — o admin abria em branco de novo ("funcionou e no deploy seguinte parou"). Verificado também que o **main do GitHub havia divergido do Forgejo** (pós-cutover OPS71): o GitHub tinha o fix + OPS71, mas não as features S8 (seção "Nossa história") e S9 (captura de novidades) que produção já rodava — deployar o main como estava regrediria as duas. A entrega: PR #751 re-homou no GitHub main as features S8+S9 (cherry-pick dos commits aprovados no Forgejo `8d2352c0` + `488dcf7d`), mantendo o handler no importMap (prova: `generate:importmap` com envs `S3_*` dummy → diff vazio vs main); CI verde; aguardando o merge. Deploy pós-merge via o pipeline canônico do OPS71 (dispatch manual do `deploy.yml`: `verify` full → job `deploy` no runner self-hosted recém-instalado no homeserver — 2 tentativas "stale run" por merges paralelos no meio do verify, terceira aplicou `dba75ff8`); migração C143 (responsáveis de demandas, aditiva+backfill) aplicada. Verificação pós-deploy: container na revision `dba75ff8`, healthy, **log sem nenhum `getFromImportMap`**, `/admin` renderizando o formulário de login (2 inputs email/senha + botão Login, título "Login - Payload", 0 erros de console), e a home pública mantendo "Nossa história" (S8) e o form de novidades (S9). Evidência: `docs/plans/ops73-deploy-admin-prod-fixed-evidence.png`. Lições registradas: a regeneração do importMap sem envs `S3_*` re-órfão o handler (classe exata que o guard #87 deve impedir no CI); divergência de plataformas pós-cutover — workspace do homeserver ainda apontava para o Forgejo local (`localhost:3000`) e o S10 (pixel) segue pendente de re-home no GitHub.
|
||||
|
||||
**OPS73 — deploy do fix do admin em produção (2026-08-20):** o `/admin` do Payload (jorgesolla1313.com.br) voltou a renderizar o formulário de login em produção — o bug de "tela branca" que travava o CMS do time editorial/campanha está resolvido e verificado. O diagnóstico mostrou que a premissa do plano (fix nunca tinha chegado a prod) estava errada em dois pontos: (1) o deploy das 23:09 de 2026-08-19 (revision `8c734ba`) já continha o fix do importMap no histórico, mas o commit S9 (`8d2352c0`) regenerou o importMap **sem** as envs `S3_*`, removendo o handler `@payloadcms/storage-s3/client#S3ClientUploadHandler` e reintroduzindo `getFromImportMap: PayloadComponent not found` (admin em branco de novo); (2) o **main do GitHub havia divergido do Forgejo** (pós-cutover OPS71) — o GitHub tinha o fix + OPS71, mas não as features S8 ("Nossa história") e S9 (captura de novidades) que produção já rodava. A entrega (PR #751): re-home do S8+S9 no GitHub main (cherry-pick `8d2352c0`+`488dcf7d`), mantendo o handler no importMap (prova: `generate:importmap` com envs `S3_*` dummy → diff vazio vs main); deploy via `deploy.yml` no runner self-hosted do homeserver (`dba75ff8`), após 2 "stale run" por merges paralelos no verify; migração C143 (responsáveis de demandas, aditiva+backfill) aplicada. Verificação pós-deploy: revision `dba75ff8`, healthy, **log sem `getFromImportMap`**, `/admin` renderizando o login (2 inputs + botão, título "Login - Payload", 0 erros de console), home mantendo S8/S9. Evidência: `docs/plans/ops73-deploy-admin-prod-fixed-evidence.png`. Lições: regeneração do importMap sem envs `S3_*` re-órfão o handler (classe do guard OPS70/#87); divergência de plataformas pós-cutover (workspace homeserver ainda aponta Forgejo local; S10 pixel pendente de re-home).
|
||||
|
||||
**S11** — o feed do Instagram configurado **sem aparecer na home** fica diagnosticável: fim do fail-closed silencioso da S3. A assessoria vê, na mesma global onde preenche o token, o estado da sincronização (`instagramSyncStatus` jsonb hidden na `social-feed-settings`) e o **porquê** de os posts não aparecerem — o painel `InstagramSyncStatusPanel` (campo `ui` irmão do picker) mostra 5 estados fiéis ao rascunho aprovado (desativado / ainda não configurado / falha com motivo em linguagem de produto + "Tentar sincronizar de novo" / sincronizado · há X min · N posts / aguardando 1ª sync). A causa da ocorrência de produção (board vazio com token+ID preenchidos) é agora confirmável em 1 clique após o deploy. Engenharia: `loadInstagramFeed` passou a parsear o body de erro da Graph API e lançar `InstagramApiError` (`status` + `{message,type}`); `describeInstagramError` mapeia para copy pt-BR com a correção — OAuthException/expired → "token inválido/expirado ou emitido via Facebook Login — gere pelo Instagram Login" (o refresh endpoint da Graph API só renova tokens de Instagram Login), "Invalid user id" → "confira o ID da conta Business/Creator", 5xx → indisponível, `TypeError` → rede. Estado escrito por 3 caminhos: render da home (`getInstagramFeed`, dentro do `unstable_cache`, SQL direto sem hooks — padrão anti-`revalidateTag`-no-cache da S2), `afterChange` da global (sync **awaited** quando a credencial IG muda — `instagramCredentialsChanged(doc, previousDoc)`, gate que pula save de exclusões/YouTube; o sync é importado lazy no hook para o `payload.config.ts` não puxar o módulo do feed no load, que é o que todo spec int importa) e a rota `POST /api/social-feed/sync` (botão; `payload.auth` sessão admin exigindo `users` + `isSameOriginRequest` CSRF — allowlistada no spec de convenção de POST routes com razão, é auth de admin ≠ envelope `/campanha`). **Bug real descoberto e corrigido na entrega:** o `afterChange` roda DENTRO da transação do save (a row lock fica presa até os hooks retornarem), então escrever o status/snapshot via pool numa segunda conexão **deadlockava no lock da própria linha** (~80-230s por save, transações-zumbi segurando o lock). A correção roteia os persists do sync-hook pela **mesma transação** (`getPostgresTransactionDatabase` — o helper transacional da C6) com um `req` opcional no `syncInstagramFeed`; o render e a rota sem transação aberta seguem no pool. Diagnóstico reproduzido em `pg_stat_activity` (UPDATE `transactionid` Lock vs `idle in transaction`) e fix verificado (45ms vs 232s). Stub IG ganha estado `invalid-token` (400 OAuthException) para o e2e da cena de falha; runbook `docs/ops/instagram-feed-token-runbook.md` (o que o painel mostra, validação via curl `/me` e `refresh_access_token`, geração pelo Instagram Login, o que NÃO fazer). Migration `20260820_003426_add_instagram_sync_status` (ADD COLUMN jsonb nullable, não destrutiva). **/simplify (3 reviewers):** absorvi a correção P1 de diagnóstico — o mapeamento de ID exigia só "user" (regex frouxo) e mensagens reais de token OAuthException ("the **user** must be logged in", "user changed password") caíam na branch de ID, mostrando a razão errada; passei a exigir "user" E "id" juntos (`isInvalidUserIdMessage`) e adicionei o mapeamento de `AbortError` (timeout → "demorou demais"). Painel: gate passou a respeitar o master `enabled` (master off → "Instagram desativado", nunca "Sincronizado no board"), rótulo do botão no estado de primeira sync ("Tentar sincronizar" vs "de novo"), `outcome` transiente limpo ao editar credencial e `aria-live` nos boxes interativos. Testes: caminho transacional do `syncInstagramFeed` (persist na mesma transação) + fallback pool cobertos por unit; e2e do painel ganhou as cenas fail-500 e "desativado"; cleanup do stub global virou `afterEach`. Débitos registrados: #124 (janela de lock: fetch de rede no sync-hook segura a row lock até 10s, acoplando a home ao save) e #125 (polish P3 — tipo de persist duplicado, rota 200 vs 400, last-writer-wins stale, `5 posts` hardcoded). Verificação: unit 2463 + int 791 verdes; lint/format/tsc/cycles verdes; `pnpm build` ok; e2e local — smoke admin 5/5 (painel ok → falha token → retry → falha 500 → desativado → não configurado) + describe serial frontend 19/19.
|
||||
|
||||
**C143** — demandas visíveis apenas para responsáveis explícitos + candidato/coordenador. Novo campo `campaignDemand.responsibles` (hasMany → `campaignUser`, staff elegível via `eligibleCampaignStaffWhere` com hook `beforeValidate` que descarta ids não-staff, fail-closed) e regra de leitura/atualização por membership: assessor só vê demandas onde é responsável (`{ responsibles: { contains } }`), candidato/coordenador/admin sempre, leader nunca — a regra substitui o escopo municipal de demandas em `src/utilities/access/demands.ts` e todas as superfícies (lista, URL, busca global, demandas de atividade, C90) herdam via `overrideAccess: false`; criação sem responsáveis fica visível só a unrestricted (fail-closed). Criador entra automaticamente como responsável no hook `enforceDemandWorkflow` (cobre form, wizard e rascunhos C90); migration `20260819_233503_add_campaign_demand_responsibles` adiciona `campaign_user_id` a `campaign_demand_rels` (FK cascade) e faz backfill de `created_by_id` → responsável para demandas pré-C143. UI: picker multi-select `DemandResponsibleMultiSelect` (chips com avatar, chip do criador rotulado "(criador)" e travado na criação, sugestões = assessores do município) na criação (`/demandas/nova` em 2 colunas + wizard "Registrar pedido" empilhado) e card de gestão `DemandResponsiblesCard` no detalhe (add/remove com save full-replace + advisory lock, rótulo "Visível para: Responsáveis · Candidato · Coordenador"); busca por `searchDemandResponsibleOptions` (query curta → assessores do município; senão → staff por nome). Testes: int reescritos para a regra nova (workflow/homeSearch) + 4 casos novos (membership, fail-closed sem responsáveis, gestão add/remove/self-removal, responsável fora da carteira, descarte de id não-staff); e2e novo `campaignDemandVisibility.e2e.spec.ts` (assessor do mesmo município não vê lista/URL; hand-off de acesso add/revoke pela UI); fixture e2e ganhou `createCampaignDemand`. Verificação: unit 2444 + int 795 verdes, e2e 5/5 local (visibility + wizard + demanda do municipalidades), lint/format/knip/cycles/tsc/build limpos; importMap regenerado com `S3_*` dummy (OPS69). Nota de deploy: conferir na primeira aplicação em prod `SELECT count(*) FROM campaign_demand_rels WHERE path='responsibles'` = nº de demandas com `created_by_id`. Plano de implementação: `docs/plans/demandas-responsaveis-impl.md`.
|
||||
|
||||
**OPS72** — política de e2e: **CI de PR sem e2e full; local afetado como passo da skill; full só antes do deploy**. Decisão do gate (2026-08-19): (1) **local = discricionário** — o agente roda os e2e que criou + os da mesma superfície afetada, decide quais (`pnpm test:e2e:affected` é a ferramenta, não um gate mecânico; não está no `gate:push`); (2) **CI de PR = blast radius** — `ci-pr.yml` perdeu o step `E2E tests (full suite)`; o e2e roda só em modo `selected` (specs que o classifier detectou como tocadas); diff high-risk (schema/lockfile/harness) classifica `full` e o PR simplesmente não roda e2e — deliberado: o espelho local roda full nesse mesmo diff e o `verify` do deploy roda full antes de publicar; (3) **main = full no deploy** — inegociável, já existia no `deploy.yml` (nada mudou lá). Docs: `work-issue` e `agent-work-issue` (checklist + Passo 4), `execution-pipeline.md` (nova seção "E2E local afetado" com o critério + a **limitação da #72**: `--no-deps` + projetos paralelos colide no `seedTestUser` → `--workers=1` ou a cadeia padrão de projetos), `AGENTS.md` (parágrafo OPS59 → política OPS72), `docs/AGENT-OPS.md` (fluxo do agente + linha do `ci-pr.yml` na tabela), `.agents/rules/agent-pr-workflow.mdc` (descrição do `gate:push`), mensagens informativas do `gate-ci.mjs` (texto-only — e2e continua fora do gate) e comentários do workflow. Mecânica intocada por escopo (`ci-scope.mjs`, manifest, `run-e2e-affected.mjs`); o fix da #72 fica na fila (só documentado). Verificação: format/lint/tsc verdes, unit 2444 + int 791 verdes; o e2e local afetado rodou a suíte **full** (o diff toca `scripts/gate-ci.mjs`, high-risk → modo `full` — a política em ação): 154 passed em dev mode; 8 falhas de ambiente (timeouts/cold-compile sob carga) triadas — 5/8 passaram em re-run e as 3 restantes passaram em `E2E_PROD=1` (o modo do CI/verify; 41 passed + 2 flaky que passaram isoladas), zero regressão de produto; o CI deste PR não roda e2e (modo `full` por design desta mesma política). Impl plan: `docs/plans/ops72-e2e-local-afetado-ci-sem-full-impl.md`. Nota: `E2E_PROD=1` local regenera o `importMap.js` **sem** as envs de S3 (classe OPS69 — o guard do OPS70/#87 cobre; restaurado na entrega com envs dummy).
|
||||
|
||||
**OPS72** — política de e2e: **CI de PR sem e2e full; local afetado como passo da skill; full só antes do deploy**. Decisão do gate (2026-08-19): (1) **local = discricionário** — o agente roda os e2e que criou + os da mesma superfície afetada, decide quais (`pnpm test:e2e:affected` é a ferramenta, não um gate mecânico; não está no `gate:push`); (2) **CI de PR = blast radius** — `ci-pr.yml` perdeu o step `E2E tests (full suite)`; o e2e roda só em modo `selected` (specs que o classifier detectou como tocadas); diff high-risk (schema/lockfile/harness) classifica `full` e o PR simplesmente não roda e2e — deliberado: o espelho local roda full nesse mesmo diff e o `verify` do deploy roda full antes de publicar; (3) **main = full no deploy** — inegociável, já existia no `deploy.yml` (nada mudou lá). Docs: `work-issue` e `agent-work-issue` (checklist + Passo 4), `execution-pipeline.md` (nova seção "E2E local afetado" com o critério + a **limitação da #72**: `--no-deps` + projetos paralelos colide no `seedTestUser` → `--workers=1` ou a cadeia padrão de projetos), `AGENTS.md` (parágrafo OPS59 → política OPS72), `docs/AGENT-OPS.md` (fluxo do agente + linha do `ci-pr.yml` na tabela), `.agents/rules/agent-pr-workflow.mdc` (descrição do `gate:push`), mensagens informativas do `gate-ci.mjs` (texto-only — e2e continua fora do gate) e comentários do workflow. Mecânica intocada por escopo (`ci-scope.mjs`, manifest, `run-e2e-affected.mjs`); o fix da #72 fica na fila (só documentado). Verificação: format/lint/tsc verdes, unit 2444 + int 791 verdes; o e2e local afetado rodou a suíte **full** (o diff toca `scripts/gate-ci.mjs`, high-risk → modo `full` — a política em ação): 154 passed em dev mode; 8 falhas de ambiente (timeouts/cold-compile sob carga) triadas — 5/8 passaram em re-run e as 3 restantes passaram em `E2E_PROD=1` (o modo do CI/verify; 41 passed + 2 flaky que passaram isoladas), zero regressão de produto; o CI deste PR não roda e2e (modo `full` por design desta mesma política). Impl plan: `docs/plans/ops72-e2e-local-afetado-ci-sem-full-impl.md`. Nota: `E2E_PROD=1` local regenera o `importMap.js` **sem** as envs de S3 (classe OPS69 — o guard do #77 cobre; restaurado na entrega com envs dummy).
|
||||
|
||||
**OPS72** — política de e2e: **CI de PR sem e2e full; local afetado como passo da skill; full só antes do deploy**. Decisão do gate (2026-08-19): (1) **local = discricionário** — o agente roda os e2e que criou + os da mesma superfície afetada, decide quais (`pnpm test:e2e:affected` é a ferramenta, não um gate mecânico; não está no `gate:push`); (2) **CI de PR = blast radius** — `ci-pr.yml` perdeu o step `E2E tests (full suite)`; o e2e roda só em modo `selected` (specs que o classifier detectou como tocadas); diff high-risk (schema/lockfile/harness) classifica `full` e o PR simplesmente não roda e2e — deliberado: o espelho local roda full nesse mesmo diff e o `verify` do deploy roda full antes de publicar; (3) **main = full no deploy** — inegociável, já existia no `deploy.yml` (nada mudou lá). Docs: `work-issue` e `agent-work-issue` (checklist + Passo 4), `execution-pipeline.md` (nova seção "E2E local afetado" com o critério + a **limitação da #72**: `--no-deps` + projetos paralelos colide no `seedTestUser` → `--workers=1` ou a cadeia padrão de projetos), `AGENTS.md` (parágrafo OPS59 → política OPS72), `docs/AGENT-OPS.md` (fluxo do agente + linha do `ci-pr.yml` na tabela), `.agents/rules/agent-pr-workflow.mdc` (descrição do `gate:push`), mensagens informativas do `gate-ci.mjs` (texto-only — e2e continua fora do gate) e comentários do workflow. Mecânica intocada por escopo (`ci-scope.mjs`, manifest, `run-e2e-affected.mjs`); o fix da #72 fica na fila (só documentado). Verificação: format/lint/tsc verdes, unit 2444 + int 791 verdes; o e2e local afetado rodou a suíte **full** (o diff toca `scripts/gate-ci.mjs`, high-risk → modo `full` — a política em ação): 154 passed em dev mode; 8 falhas de ambiente (timeouts/cold-compile sob carga) triadas — 5/8 passaram em re-run e as 3 restantes passaram em `E2E_PROD=1` (o modo do CI/verify; 41 passed + 2 flaky que passaram isoladas), zero regressão de produto; o CI deste PR não roda e2e (modo `full` por design desta mesma política). Impl plan: `docs/plans/ops72-e2e-local-afetado-ci-sem-full-impl.md`. Nota: `E2E_PROD=1` local regenera o `importMap.js` **sem** as envs envs de S3 (classe OPS69 — o guard do #77 cobre; restaurado na entrega com envs dummy).
|
||||
|
||||
**OPS72** — política de e2e: **CI de PR sem e2e full; local afetado como passo da skill; full só antes do deploy**. Decisão do gate (2026-08-19): (1) **local = discricionário** — o agente roda os e2e que criou + os da mesma superfície afetada, decide quais (`pnpm test:e2e:affected` é a ferramenta, não um gate mecânico; não está no `gate:push`); (2) **CI de PR = blast radius** — `ci-pr.yml` perdeu o step `E2E tests (full suite)`; o e2e roda só em modo `selected` (specs que o classifier detectou como tocadas); diff high-risk (schema/lockfile/harness) classifica `full` e o PR simplesmente não roda e2e — deliberado: o espelho local roda full nesse mesmo diff e o `verify` do deploy roda full antes de publicar; (3) **main = full no deploy** — inegociável, já existia no `deploy.yml` (nada mudou lá). Docs: `work-issue` e `agent-work-issue` (checklist + Passo 4), `execution-pipeline.md` (nova seção "E2E local afetado" com o critério + a **limitação da #72**: `--no-deps` + projetos paralelos colide no `seedTestUser` → `--workers=1` ou a cadeia padrão de projetos), `AGENTS.md` (parágrafo OPS59 → política OPS72), `docs/AGENT-OPS.md` (fluxo do agente + linha do `ci-pr.yml` na tabela), `.agents/rules/agent-pr-workflow.mdc` (descrição do `gate:push`), mensagens informativas do `gate-ci.mjs` (texto-only — e2e continua fora do gate) e comentários do workflow. Mecânica intocada por escopo (`ci-scope.mjs`, manifest, `run-e2e-affected.mjs`); o fix da #72 fica na fila (só documentado). Verificação: format/lint/tsc verdes, unit 2444 + int 791 verdes; o e2e local afetado rodou a suíte **full** (o diff toca `scripts/gate-ci.mjs`, HIGH*RISK → modo `full` — a política em ação): 154 passed em dev mode; 8 falhas de ambiente (timeouts/cold-compile sob carga) triadas — 5/8 passaram em re-run e as 3 restantes passaram em `E2E_PROD=1` (o modo do CI/verify; 41 passed + 2 flaky que passaram isoladas), zero regressão de produto; o CI deste PR não roda e2e (modo `full` por design desta mesma política). Impl plan: `docs/plans/ops72-e2e-local-afetado-ci-sem-full-impl.md`. Nota: `E2E_PROD=1` local regenera o `importMap.js` **sem** as envs `S3*\*` (classe OPS69 — o guard do #77 cobre; restaurado na entrega com envs dummy).
|
||||
|
||||
**OPS72** — política de e2e: **CI de PR sem e2e full; local afetado como passo da skill; full só antes do deploy**. Decisão do gate (2026-08-19): (1) **local = discricionário** — o agente roda os e2e que criou + os da mesma superfície afetada, decide quais (`pnpm test:e2e:affected` é a ferramenta, não um gate mecânico; não está no `gate:push`); (2) **CI de PR = blast radius** — `ci-pr.yml` perdeu o step `E2E tests (full suite)`; o e2e roda só em modo `selected` (specs que o classifier detectou como tocadas); diff high-risk (schema/lockfile/harness) classifica `full` e o PR simplesmente não roda e2e — deliberado: o espelho local roda full nesse mesmo diff e o `verify` do deploy roda full antes de publicar; (3) **main = full no deploy** — inegociável, já existia no `deploy.yml` (nada mudou lá). Docs: `work-issue` e `agent-work-issue` (checklist + Passo 4), `execution-pipeline.md` (nova seção "E2E local afetado" com o critério + a **limitação da #72**: `--no-deps` + projetos paralelos colide no `seedTestUser` → `--workers=1` ou a cadeia padrão de projetos), `AGENTS.md` (parágrafo OPS59 → política OPS72), `docs/AGENT-OPS.md` (fluxo do agente + linha do `ci-pr.yml` na tabela), `.agents/rules/agent-pr-workflow.mdc` (descrição do `gate:push`), mensagens informativas do `gate-ci.mjs` (texto-only — e2e continua fora do gate) e comentários do workflow. Mecânica intocada por escopo (`ci-scope.mjs`, manifest, `run-e2e-affected.mjs`); o fix da #72 fica na fila (só documentado). Verificação: format/lint/tsc verdes, unit 2444 + int 791 verdes; o e2e local afetado rodou a suíte **full** (o diff toca `scripts/gate-ci.mjs`, HIGH_RISK → modo `full` — a política em ação): 154 passed em dev mode; 8 falhas de ambiente (timeouts/cold-compile sob carga) triadas — 5/8 passaram em re-run e as 3 restantes passaram em `E2E_PROD=1` (o modo do CI/verify; 41 passed + 2 flaky que passaram isoladas), zero regressão de produto; o CI deste PR não roda e2e (modo `full` por design desta mesma política). Impl plan: `docs/plans/ops72-e2e-local-afetado-ci-sem-full-impl.md`.
|
||||
|
||||
**OPS72** — política de e2e: **CI de PR sem e2e full; local afetado como passo da skill; full só antes do deploy**. Decisão do gate (2026-08-19): (1) **local = discricionário** — o agente roda os e2e que criou + os da mesma superfície afetada, decide quais (`pnpm test:e2e:affected` é a ferramenta, não um gate mecânico; não está no `gate:push`); (2) **CI de PR = blast radius** — `ci-pr.yml` perdeu o step `E2E tests (full suite)`; o e2e roda só em modo `selected` (specs que o classifier detectou como tocadas); diff high-risk (schema/lockfile/harness) classifica `full` e o PR simplesmente não roda e2e — deliberado: o espelho local roda full nesse mesmo diff e o `verify` do deploy roda full antes de publicar; (3) **main = full no deploy** — inegociável, já existia no `deploy.yml` (nada mudou lá). Docs: `work-issue` e `agent-work-issue` (checklist + Passo 4), `execution-pipeline.md` (nova seção "E2E local afetado" com o critério + a **limitação da #72**: `--no-deps` + projetos paralelos colide no `seedTestUser` → `--workers=1` ou a cadeia padrão de projetos), `AGENTS.md` (parágrafo OPS59 → política OPS72), `docs/AGENT-OPS.md` (fluxo do agente + linha do `ci-pr.yml` na tabela), `.agents/rules/agent-pr-workflow.mdc` (descrição do `gate:push`), mensagens informativas do `gate-ci.mjs` (texto-only — e2e continua fora do gate) e comentários do workflow. Mecânica intocada por escopo (`ci-scope.mjs`, manifest, `run-e2e-affected.mjs`); o fix da #72 fica na fila (só documentado). Verificação: format/lint/tsc/unit verdes; `test:e2e:affected` com modo `none` (diff sem blast radius e2e); CI do PR valida (e2e não roda — docs/skills/workflow não são superfície e2e). Impl plan: `docs/plans/ops72-e2e-local-afetado-ci-sem-full-impl.md`.
|
||||
|
||||
**OPS71-INFRA (2026-08-19):** runner **self-hosted do GitHub instalado no homeserver** (Issue #113) — o job `deploy` do `deploy.yml` (dispatch manual) agora roda localmente no homeserver, sem SSH. Detalhes: `~/actions-runner` v2.336.0 linux-x64 (sha256 conferido contra a API de releases), nome `teqo-1313-runner`, labels `self-hosted` + `homeserver`, registrado com o PAT do OPS71 (registration token TTL 1h gerado e aplicado na sequência). Roda como **systemd user** `actions.runner.fsolla-teqo.teqo-1313-runner.service` (linger já ativo; `svc.sh install` exige root — unit user criado manualmente do template oficial), usuário `fsolla` (docker + `~/stack` envs), workdir `_work`, outbound-only (funciona atrás do Cloudflare tunnel). Verificado `online` na API do GitHub com as labels corretas. Registrado em infra-solla `STATE.md` (seção Teqo: runner + fluxo deploy manual atualizado; linha do repo/CI corrigida para o pós-cutover GitHub). Workspace `~/teqo-deploy` re-aponta para o GitHub no primeiro deploy via `set-url` idempotente do script. Pendência registrada: secret `CURSOR_API_KEY` no GitHub fica dormente (helper `archive-cursor-agent.yml`; a key não existe no ambiente — pool morto OPS65) até o usuário pedir a reativação.
|
||||
|
||||
**S9 — captura de novidades da campanha na home pública (2026-08-19):** a home de campanha (`jorgesolla1313.com.br`) ganhou a seção "Receba as novidades da campanha" logo acima do rodapé — nome + WhatsApp obrigatórios, e-mail/estado/cidade/comentário opcionais, toggle pré-selecionado "Quero fazer parte do time" (frequência + grupos de WhatsApp vs. comunicações esporádicas) e confirmação in-place com atalho para as bandeiras. O CTA secundário do hero virou o atalho da seção (âncora `#novidades`, scroll suave no container da home; "Conhecer bandeiras" sai, as bandeiras seguem no rodapé e no estado de sucesso). O registro é `Contact` + `Subscription` em transação com consentimento NOVO por chave estável `campanha-novidades` (fail-closed: sem o texto aprovado no admin, o form recusa a captura — nada é gravado); a escolha do toggle sobrevive como `subscription.campaignLevel` (`time|esporadico`, coluna própria na lista do admin). Migração `20260819_192605_add_campaign_newsletter_capture` (DROP NOT NULL em `contact.state` — estado opcional no form, ficha staff via zod segue exigindo — + coluna `campaign_level` com enum). Precedente `submitWhatsapp` preservado; action nova usa `withPayloadTransaction` (o dono do concern de transação, não o hand-roll gêmeo — `submitWhatsapp`/`submitPetitionSignature` ficam como débito de alinhamento). S10 (pixel Meta) dispara `Lead` na costura do sucesso do form. Chave de consentimento pendente de aprovação jurídica antes da ativação.
|
||||
|
||||
**S7** — polimento dos cards da seção "Acompanhe de perto" da home de campanha (jorgesolla1313.com.br), look editorial e direto no lugar do "card de app": cantos retos (**sem `rounded-xl` no card e `rounded-lg` na capa**), capa de **borda a borda** (o `p-3` saiu do contêiner do card e foi para o bloco de texto — título/subtítulo/meta continuam respirando `p-3` das bordas; `mt-auto` da meta e `overflow-hidden` do zoom hover intactos) e, por decisão do humano posterior ao gate, **sem borda (`border --campaign-line`) e sem sombra** (`shadow-sm`/`hover:shadow-md`) — o card branco lê contra a banda do S5 só pela forma. **Chips de badge removidos de todos os cards** (decisão B do gate): nem os tipos de post do site (`POST_TYPE_BADGE_LABELS` deletado de `src/utilities/posts.ts`, junto com o describe no unit test) nem as origens externas YouTube/Instagram (sinalização externa segue por `target="_blank"`/`rel="noopener"`); `badgeLabel` saiu do tipo `CampaignContentCardData`. Superfície única no componente dono (`CampaignContentCard.tsx`) — bento e carrossel herdam; `ContentShareButton` (S4), banda do S5, carrossel e fail-closed da seção intocados. Zero migration/schema/Consent. e2e do describe da seção atualizados para o novo estado: loop de badges → ausência (`toHaveCount(0)` para os 4 tipos + YouTube/Instagram), pins CSS do card (`border-radius`/`border-width`/`box-shadow` + raio da capa) e pin geométrico da capa de borda a borda (boundingBox do card × capa). Verificação: gate:fast verde, unit 2415 + int 791 verdes, e2e frontend 19/19 local, format/knip/cycles limpos. Plano de implementação: `docs/plans/cards-acompanhe-de-perto-polimento-impl.md`.
|
||||
|
||||
**S8** — seção "Nossa história" na home de campanha (jorgesolla1313.com.br): o vídeo da trajetória do Dep. Jorge Solla (~1:40) ganha lugar próprio na home, após a seção "Bandeiras" e antes do rodapé — eyebrow "Nossa história" + título "Conheça a trajetória de Jorge Solla" + frame vertical 9:16 (desktop 2 colunas, copy à esquerda e frame 300px à direita centralizados; mobile empilhado, frame full-width cap 420px). **Divergência aprovada em execução pelo humano:** o vídeo é embutido do canal do deputado no YouTube (Short `i_fbclWWC5o`) em vez do asset self-hosted do plano de intenção — cada play embutido conta view no canal; `youtube-nocookie` (LGPD), `loading="lazy"` nativo (zero bytes até o frame se aproximar do viewport), `playsinline`, `allowfullscreen`, `title` a11y, `allow` com `compute-pressure` (silencia o console.error do player — o guard do e2e falha em console.error); CTA "Assistir à história" rola até o player (play cross-origin não é disparável). Os encodes one-off H.264/AV1 + poster da foto JOA00327 (14.9 MB, validados por design-vision contra o orçamento de 15 MB da intenção) ficam como artefato local gitignored em `data/encode/` — fallback pronto se um dia se voltar a self-hosted. Zero schema/migration/Consent; nenhuma seção existente tocada. e2e novo (posição, copy, atributos do embed, CTA→viewport, layout desktop 2 colunas com centro vertical, 9:16 mobile sem overflow) + abort global do `youtube-nocookie` no fixture `e2eTest.ts` (hermeticidade CI). Verificação: unit 2416 + int 791 verdes, tsc/lint/format/cycles/build verdes; e2e frontend 20/20; knip = limitação P3 pré-existente. /simplify: 3 revisores paralelos absorvidos (JSDoc enxuto, e2e sem assert redundante, plano sem conteúdo morto, desktop asserts); 2 débitos deferidos no impl plan (rótulo do CTA se a assessoria reabrir copy; dedupe da classe do CTA com `CampaignContentSection` quando S5 #33 terminar).
|
||||
|
||||
**OPS69** — o admin do Payload abria **em branco em produção** (P0, 2026-08-19): o `importMap` commitado (`src/app/(payload)/admin/importMap.js`) ficou sem a entrada `@payloadcms/storage-s3/client#S3ClientUploadHandler` — o commit OPS52 (`dabc00f4`) removeu o handler do Vercel Blob e **não** adicionou o do S3, porque o `pnpm generate:importmap` rodou **sem** as envs `S3_*`: com o plugin `s3Storage` condicional, sem as envs o importMap sai consistente (dev/test, storage local) e com elas (produção, Garage) o admin precisa do handler e o montava → `getFromImportMap: PayloadComponent not found in importMap` → login nunca renderizava. Fix mínimo (opção A do plano, aprovado no gate): regenerar o importMap com as 4 `S3_*` dummy inline (a mecânica do CLI: `iterateConfig` sem DB; `loadEnvConfig` do `@next/env` não sobrescreve envs já no processo) → diff de **exatamente +2 linhas** (import + entrada do `S3ClientUploadHandler`), nenhum outro drift. Verificação: smoke headless com S3 ativo no dev local — `/admin/login` renderiza (6 inputs, formulário visível, **zero erros de console**) vs. o branco reproduzido no diagnóstico; gates completos verdes (tsc/lint/format/knip/cycles, unit+int 791, build). Sem migration/access/Consent/UI. Docs: linha no AGENTS.md (checklist local: `generate:importmap` precisa das `S3_*` setadas — valores dummy ok — enquanto o plugin for condicional; sem elas o importMap fica órfão, classe OPS69) + item sucessor registrado (guard de CI que regenera com envs dummy e falha o gate se o arquivo commitado divergir — `depends` #77). Verificação pós-deploy: `/admin` abre o login e o log do container não re-emite o `getFromImportMap` para o handler (runbook `docs/ops/teqo-1313-deploy.md`).
|
||||
|
||||
**OPS62-FOLLOWUP** — flake do `municipalitiesWithoutUpdateTool.unit.spec.ts` (2 ocorrências 2026-08-18: gate local 19:22 e CI run 779 23:53) eliminado pela raiz, com a hipótese original da Issue refutada por reprodução. Causa raiz: o fixture do teste (não o ambiente nem locale/ICU): `daysAgo()` chamava `Date.now()` **por chamada**; as duas construções `daysAgo(40)` de `Beta`/`Alpha` podiam cair em milissegundos diferentes sob carga (GC/preempção do fork do vitest na máquina compartilhada), deixando o timestamp de `Alpha` estritamente mais novo — em `sortStale` (`getMunicipalitiesWithoutUpdate.ts`) o sort por timestamp vencia o tie-break por nome e o teste recebia `[Beta, Alpha]` no lugar de `[Alpha, Beta]` (só o par trocado, resto correto — "sort não atuou" estava errado; o sort atuou sobre timestamps acidentalmente desiguais). Repro mecânica (sort de produção verbatim + fixture): fixture atual 0.08% de falhas em 200k runs sem pausa e **100%** com pausa de 1 ms injetada; fixture ancorado 0 em 2000 runs. Fix (test-only, nada em produção — o sort real é determinístico sobre timestamps fixos do DB): âncora `fixtureNowMs = Date.now()` no load do módulo (padrão do `prioritiesTool.unit.spec.ts`) + hoist do timestamp empatado (`const stale40 = daysAgo(40)` compartilhado por `Beta`/`Alpha` — igualdade estrutural, imune até a regressão da âncora) + comentário explicando a classe de flake. Scan da mesma classe: `prioritiesTool` (âncora no load) e `suggestionCatalog` (now fixo) já seguros; nenhum outro spec combina `Date.now()` por chamada com assunção de igualdade. Verificação: 30/30 runs do spec, unit full (249 arquivos/2405 testes), int (89/791), tsc, lint, format, check:cycles verdes (knip só com o P3 ledgered de sempre). /simplify: 3 reviewers aprovaram; débito absorvido no próprio teste (comentário do porquê). Impl plan: `docs/plans/ops62-followup-ci-flake-fixture-timestamps-impl.md`.
|
||||
|
||||
**OPS67** — o cliente `scripts/lib/forgejo-api.mjs` ganhou **retry com backoff** nos fetchs: um `fetch failed` transitório (rede/DNS/reset — o sintoma que matava o run do safety net `agent-pr-ready-automerge.yml` no meio da espera de checks) agora retenta em **qualquer método**, e a leitura de body interrompida no meio (mesma classe de falha de rede) retenta **em GET**; 5xx do Cloudflare (502/503/504) retenta **só em GET** (protege o poll de 45 min do `waitForChecks` sem arriscar duplicar efeitos colaterais de escrita — body do 5xx cancelado antes do retry para liberar o socket); 4xx e body-drop em escrita continuam falhando na hora, fail-closed intacto. Política: 3 retries, base 300 ms ×2, jitter ±20%, `console.warn` pt-BR em cada retry (`tentativa N/4`), validação `retries >= 0`/`backoffMs >= 0`. Opções flat no `createApi` (`retries`, `backoffMs`, `jitter`, `sleepImpl`) — defaults preservam o comportamento de todos os 19 call sites (safety net, claim/status, issue-transition, pool, worktree) sem mudança de chamador; zero-dep mantido. Testes: 12 specs novas em `forgejoApi.unit.spec.ts` (rede GET/POST → sucesso, body-drop GET retenta + POST fail-closed, exaustão de rede/5xx, `retries: 0`, backoff exponencial [100, 200, 400], jitter ±20% com `Math.random` pinado, 4xx sem retry, 5xx-GET retry/exaustão, 5xx-POST sem retry) + pin no spec 403 existente (1 fetch por invocação). Verificação: unit 2403 verdes; lint/format/typecheck/knip/cycles verdes. Impl plan: `docs/plans/ops67-forgejo-api-retry-backoff-impl.md` (aprovado no gate humano; refinamentos do /simplify absorvidos na execução).
|
||||
|
||||
**S4** — botão discreto de compartilhar no WhatsApp por card da seção "Acompanhe de perto" da home (jorgesolla1313.com.br): cada card (Artigo/YouTube/Instagram, o board S1+S2+S3) ganha um botão redondo no canto superior direito (visual 28px sobre área de toque 44px, `aria-label` "Compartilhar", sem competir com o CTA do card) que abre um popover de 2 ações: **"Compartilhar no WhatsApp"** — `wa.me` sem destinatário (WhatsApp do próprio remetente; o `buildWhatsAppTextShareUrl` do S6 foi movido para `src/lib/phone.ts` — dono do wa.me, re-exportado no `homeSearchShare.ts` — e o `contentShare.ts` reusa, sem twin) com mensagem pré-configurada **por fonte** (decisão B do gate: "Olha isso do Solla: {título} — {link}" / "Olha esse vídeo…" / "Olha esse post…"; link = página canônica do artigo resolvida contra `window.location.origin` no cliente, ou URL da plataforma para YT/IG; nova aba `noopener noreferrer") — e **"Copiar link"** (clipboard com "Link copiado" + `aria-live`para leitores de tela, fallback honesto em falha). Estrutura: o card inteiro é um anchor — o botão vive como **irmão** absoluto num wrapper`relative`(botão dentro do anchor seria HTML inválido); o conteúdo do popover usa`ui/Popover`(Radix, portal — escapa o`overflow-x-auto`do carrossel mobile que cliparia um menu absoluto), sem roles`menu`/`menuitem`(a semântica honesta de popover com 2 ações — Radix já emite`aria-expanded`). Zero migration/schema/Consent/escrita no servidor — montagem de URL 100% cliente, nada coletado (anti-goal da intenção). Lib pura `src/lib/contentShare.ts`(prefixos por fonte, resolução de link fail-soft contra permalink malformado, URL wa.me) + 11 units; e2e novo no describe serial da seção (cadeia 19/19 verde local): botão por card do bento, mensagem por fonte com URL absoluta,`target/rel`, copiar→clipboard real (`expect.poll`), Escape/clique-fora, mobile ≥44px + 28px visual + portal dentro do viewport, card ativo pinado por identidade. Verificação: unit 2402 + int 791 verdes, tsc/lint/format/cycles/build verdes; knip = limitação P3 pré-existente (não carrega `payload.config.ts`); CI valida. /simplify: 3 revisores paralelos absorvidos (twin do wa.me resolvido pelo owner move, máquina de feedback de copiar simplificada para effect único, foco/roles honestos, min-h-11 nos itens, `aria-live`, origin lazy sem guard morto, malformed-href fail-soft, e2e endurecido: pin de identidade do card ativo + unmount do popover antes do próximo trigger + helper de decode `+`); 1 débito deferido (dedupe do blob de 15 campos do `updateSocialFeedSettings` nos 5 testes do describe — quando os testes S2/S3 forem tocados de novo).
|
||||
|
||||
**S6-FOLLOWUP** — CLI Playwright 1.58.2 e filtros e2e locais: `pnpm test:e2e:affected` rodava a suíte completa silenciosamente em runs selecionados. Causa raiz (medida com o binário do repo, config real): o commander parseia posicionais e `-g`/`--list` funcionam — quem arrastava a suíte era o closure de dependências dos projects no dev mode (`setup ← campaign ← frontend ← admin` em `playwright.config.ts`): `collectProjectsAndTestFiles` filtra os arquivos por project, mas `buildProjectsClosure` repõe todos os arquivos de cada project-dependência — filtrar um spec do `frontend` puxava `campaign` (160) + `setup` (2), 177 no total com o próprio arquivo (15); `admin` puxava a suíte inteira. Em prod mode (`CI=1`) os projects não têm deps e o filtro sempre funcionou (por isso o CI selecionado estava correto). Fix: `run-e2e-affected.mjs` agora gera `test:e2e <passthrough verbatim> --no-deps -- <paths>` via helpers puros novos em `scripts/lib/playwright-e2e-args.mjs` (unit-testados, `tests/unit/playwrightE2eArgs.unit.spec.ts`) — `--no-deps` desliga o closure em runs filtrados (suíte completa mantém a cadeia OPS34 de prewarm); de quebra, o passthrough do script foi corrigido (`pnpm test:e2e:affected -- tests/x.spec.ts` descartava o path — pnpm consome o `--` e o argv nunca o continha; agora `parsePassthroughArgs` só remove um `--` líder e repassa o resto verbatim, preservando `-g grade`). Hardening: `scripts/run-e2e-affected.mjs` entrou em `HIGH_RISK_EXACT` (era o espelho local do job e2e sem gate — um PR só de runner não rodaria e2e). Docs: receitas de filtro no `playwright.config.ts` (posicional exige `--no-deps`; `--project=<família> --no-deps`; `-g`/`--list` funcionam), header do script e nota no AGENTS.md. Sem mudança de specs, CI (`--shard`) ou `gate:ci`. Verificação: unit 2343+ verdes; contagem por `--list` do binário com a forma final (18); CI do PR roda o e2e completo (o diff toca `playwright.config.ts` e `run-e2e-affected.mjs`, high-risk). Débito absorvido: hang do `pnpm test:e2e` via pnpm neste worktree (pré-existente, ambiente — binário direto funciona; não escopo). Impl plan: `docs/plans/s6-followup-playwright-filtros-cli-ignorados-impl.md`.
|
||||
|
||||
**S2** — seção "Acompanhe de perto" da home de campanha (jorgesolla1313.com.br) passa a incluir os vídeos do canal oficial do YouTube (`@JorgeSollaDep`), estendendo o board da S1. Fonte automática servidor-side: global `SocialFeedSettings` nova (grupo `Configurações`, read/update admin-only — a chave da API nunca vaza pelo REST), com kill switch `enabled` ("Pausar feed"), toggle `youtubeEnabled`, chave + channel ID, `youtubeMaxItems` (1–5, default 3) e `excludedItems` (platform+id+motivo opcional — mesmo mecanismo/lugar da S3), migration `20260818_111748_add_social_feed_settings` não destrutiva (editada para conter só o DDL — o `migrate:create` regenerava drift de migrations hand-written sem sidecar JSON de 20260811). Loader `src/utilities/youtubeFeed.ts` (irmão de `posts.ts`, pinado no spec de convenções): parsers/format puros (`formatYouTubeViews` pt-BR "12,4 mil"/"1,2 mi", thumbnails maxres??high??medium), fetch Data API v3 em dois passos (search → statistics, `fetchImpl`/`YOUTUBE_API_BASE_URL` injetáveis), `unstable_cache` tag `social-feed` + `revalidate: 300` (TTL da entrada, não da página — o bug do S1 era page-level); **fail-closed com snapshot persistido por SQL direto** (`payload.db.drizzle`, sem hooks — `revalidateTag` dentro de `unstable_cache` LANÇA no Next 15.4, verificado no source; o snapshot cru pré-exclusão sobrevive a edições de settings por merge do update), exclusões aplicadas na leitura. Card generalizado `CampaignArticleCard` → `CampaignContentCard` (git mv; `external?: boolean` → `<a target="_blank" rel="noopener noreferrer">`); a seção mescla artigos visíveis + vídeos elegíveis por recência (grande = mais recente — aceite S2; degrade para o comportamento S1 sem config), meta "há X · N visualizações" (relativa < 48h, absoluta além), link "YouTube →" derivado do channelId; carrossel mobile renomeado para "Conteúdos recentes". next.config ganha `i.ytimg.com` + host local do stub no remotePatterns. Testes: 15 units novas (formatters, parsers, eligibility, fetch com fetchImpl fake); e2e com stub HTTP node (`tests/e2e/youtube-stub.mjs`, webServer do Playwright vira array, porta = dev+1000; `POST /__stub/state` alterna ok/fail; thumbnails servidas localmente) — 4 testes novos no describe serial da seção (full-state com exclusão + link externo + carrossel, fail-closed sem snapshot, snapshot persistente com maxItems mudando na API down, kill switch) + reset de settings no beforeAll; conventions specs atualizados (pin do youtubeFeed, settle após about:blank). Verificação: unit 2306 + int 791 verdes; e2e frontend 14/14 dev e 6/6 prod-mode (`E2E_PROD=1` — valida TTL+tags+snapshot no build real); lint/format/tsc/knip/cycles/build verdes. `/simplify` 3 reviewers (sem P0): P1 corrigido — label do kill switch invertido ("Pausar feed" marcado = ativo → "Feed ativo"); P2 absorvidos (className do link do header extraída, `Promise.all` posts+feed no cache frio, comentário do sentinela 0 do merge, helper único `tests/helpers/youtubeStub.ts` para a porta do stub, `waitForHomeSectionState` fundido no `waitForHomeHTML` com orçamento 12s do S1, precondição do persist documentada); débito REGISTRADO (#40, prio:P2, depends #18): o `validate` do `itemId` lê `data.platform` que é sempre undefined (Payload passa o doc inteiro; verificado no source) — produção sem validação de ID de vídeo, e o e2e depende dessa mortandade (ids do stub de 20 chars); correção em dupla documentada na issue. Descartados com justificativa: ramo maxres do search (pinned por teste), tag `social-feed` fora da convenção `global_*` (precedente election-tse/municipality-catalog), remotePattern `localhost/thumbs` incondicional (decisão deliberada dos 3 modos e2e), persist não-atômico (comentado); achados visuais do design-vision mapeados para a Issue #33 (separação visual branco-sobre-branco, fora de escopo por decisão do gate).
|
||||
|
||||
**S6** — home de campanha (jorgesolla1313.com.br) sem carrossel no desktop: as seções "Por que essa eleição importa" (3 cards) e "Nossa caminhada" (6 cards) mostram agora todo o conteúdo de uma vez em ≥1024px — grade estática 3 colunas (problem: 3×437px, bandeiras: 3×2 com altura por conteúdo, padding `p-4`), sem auto-avanço, sem chips/indicadores e sem `aria-current`. Mobile (<1024px) 100% preservado: carrossel com auto-avanço ~4s, chips sincronizados, swipe — componente e geometria mobile intocados. Padrão S1 (renderização-irmã com toggle CSS `hidden lg:grid`/`lg:hidden`): grade server-rendered (zero JS no desktop) + carrossel client embrulhado; os dois cards extraídos do `CampaignCarousel` para `CampaignProblemCard`/`CampaignFlagCard` (presentacionais, fonte única do markup, `size='spacious'` só na grade) e o contrato da grade (data-grid + aria-label + 3 colunas) vive num `CampaignCardGrid` local do page. O carrossel ficou byte-equivalente (só re-roteia os cards; unit tests intocados) e ganhou guard de `display:none` no auto-avanço — o timer nunca roda no desktop (chain morto numa subárvore oculta). Geometria: a seção flags recalibrada no desktop (`height: clamp(418px, calc(807.7px - 28.13vw), 520px)`, âncoras medidas em 1024/1280/1440+, respiro ≥30px, `overflow: hidden` para zoom de texto ≥125% não pintar sobre o footer); a seção problem manteve a geometria (respiro ~31px). Zero schema/migration/Consent; mesma copy/imagens/ordem; S1 e o resto da home intocados. Verificação: unit 2318 + int 791 verdes, e2e frontend 11/11 (novo teste de grade desktop com invariantes 3×1/3×2, ausência de regiões/chips/`aria-current`, respiro ≥24 e `scrollHeight ≤ clientHeight` em 1024/1280/1440/1920; teste de carrossel migrado para viewport mobile 390px com auto-avanço + chips; swipe/mobileSpacing intactos), lint/format/typecheck/knip/cycles/build verdes. `/simplify` 3 reviewers: `lg:` órfãos removidos do carrossel, grade extraída em `CampaignCardGrid`, `overflow: hidden` (zoom ≥125%), guard do timer, `document.fonts.ready` + eixo x + pino de clip no e2e, âncoras no comentário do clamp. Débitos: Issue #58 registrada (S6-FOLLOWUP — CLI do Playwright 1.58.2 ignora `--grep`/posicionais e o `test:e2e:affected` roda a suíte completa; `depends: [S6]`); defer com gatilho para os literais de gap da grade (recalibração Penpot do carrossel); descartados contratos de tipo dos cards (armadilha knip), aria-labels duplicados (copy pinada no e2e) e pino de tablet 768–1023. Impl plan: `docs/plans/home-campanha-sem-carrossel-desktop-impl.md`.
|
||||
|
||||
**S3** — seção "Acompanhe de perto" da home de campanha (jorgesolla1313.com.br) passa a incluir os posts do Instagram oficial, completando o board do plano-site §4.2 (artigos S1 + YouTube S2 + Instagram). Fonte automática servidor-side: loader irmão `src/utilities/socialFeed/instagramFeed.ts` (os feeds externos ganharam o subfolder `socialFeed/` — o pin da S2 predizia exatamente isso quando a segunda plataforma entrasse; `git mv` do youtubeFeed + imports atualizados): Graph API (`GET /{ig-user-id}/media` com `children{media_url,thumbnail_url}` p/ carrossel + `GET /{ig-user-id}?fields=username`), `unstable_cache` tag `social-feed` + `revalidate: 300`, snapshot cru (`{ username, posts }`, pré-exclusão) persistido via SQL direto (padrão anti-`revalidateTag`-no-cache da S2), exclusões aplicadas na leitura, **refresh de token automático** (refresh-on-failure + retry 1x, token renovado persistido por SQL direto; nota honesta no admin: o refresh endpoint só renova tokens de Instagram Login — page tokens business caem no fail-closed + rotação manual). Global `SocialFeedSettings` ganha `instagramEnabled/AccessToken/UserId/MaxItems` + `instagramFeedSnapshot` (hidden) — `excludedItems` já cobria `platform: 'instagram'` desde a S2, sem migration no mecanismo; migration `20260818_192948_add_instagram_social_feed_fields` não destrutiva (DDL revisado, sem o drift do `migrate:create` da S2). **Picker de exclusão no admin** (requisito nº 1 da intenção — posts de grade): primeiro componente custom do repo (`src/components/admin/InstagramPostExclusionPicker.tsx`, campo `ui` na global, path string + importmap regenerado — o import direto de `@payloadcms/ui` na config quebra o `generate:importmap` no tsx por causa do `.css` do react-image-crop; com path string o scanner resolve sem carregar o módulo); lista os posts do snapshot (thumbnail + legenda + data + ID) com toggle "Não exibir"/"Reexibir" + motivo opcional que escreve em `excludedItems` via `useField`. UI: `CampaignContentCardData.coverAspect` (`'video'` default — S1/S2 intactos; `'square'` 1:1 do rascunho aprovado para IG), badge "Instagram", título = legenda (fallback "Publicação no Instagram" para post sem legenda — entra no automático para exclusão manual), meta = data relativa, clique no `permalink` (nova aba, `noopener`), link do header "Seguir no Instagram →" (copy do rascunho aprovado; username derivado da API — com API fora e sem snapshot o link some junto com os cards, divergência documentada do "YouTube →" que sobrevive por ter o channelId configurado). next.config ganha `https://*.cdninstagram.com/**`. Testes: 16 units novas (parsers com caption null/carrossel/item sem permalink, thumbnail por media_type, eligibility, fetch + refresh-retry com `fetchImpl`); e2e: stub irmão `tests/e2e/instagram-stub.mjs` (porta dev+2000; a derivação da porta virou `tests/helpers/socialStub.ts` — rename do youtubeStub.ts mantendo UMA spelling), 3 testes novos no describe serial (full-state com exclusão + fallback de legenda + aspect 1:1 + link do perfil + carrossel, fail-closed sem snapshot, snapshot persistente com maxItems mudando na API down) + kill switch estendido com IG configurado + smoke do picker no admin (seed do snapshot via REST, toggle, Salvar, verificação do `excludedItems`). Verificação: unit 2351 + int 791 verdes; gate:fast/lint/format/tsc/knip (exit 0 — o erro de load do payload.config segue o ledger P3)/cycles/build verdes; e2e local: describe serial 9/9 + smoke admin 1/1 (rodando os projetos em sequência — em paralelo com `--no-deps` o `seedTestUser` dos dois beforeAll colide no mesmo usuário e quebra o run, artefato da invocação local, não do produto; o CI roda na cadeia de dependências). Detalhe do debug: o clique via CDP do MCP de browser não dispara eventos de input (ambiente do MCP, não do app — `el.click()` e o runner real funcionam).
|
||||
|
||||
**S2** — seção "Acompanhe de perto" da home de campanha (jorgesolla1313.com.br) passa a incluir os vídeos do canal oficial do YouTube (`@JorgeSollaDep`), estendendo o board da S1. Fonte automática servidor-side: global `SocialFeedSettings` nova (grupo `Configurações`, read/update admin-only — a chave da API nunca vaza pelo REST), com kill switch `enabled` ("Pausar feed"), toggle `youtubeEnabled`, chave + channel ID, `youtubeMaxItems` (1–5, default 3) e `excludedItems` (platform+id+motivo opcional — mesmo mecanismo/lugar da S3), migration `20260818_111748_add_social_feed_settings` não destrutiva (editada para conter só o DDL — o `migrate:create` regenerava drift de migrations hand-written sem sidecar JSON de 20260811). Loader `src/utilities/youtubeFeed.ts` (irmão de `posts.ts`, pinado no spec de convenções): parsers/format puros (`formatYouTubeViews` pt-BR "12,4 mil"/"1,2 mi", thumbnails maxres??high??medium), fetch Data API v3 em dois passos (search → statistics, `fetchImpl`/`YOUTUBE_API_BASE_URL` injetáveis), `unstable_cache` tag `social-feed` + `revalidate: 300` (TTL da entrada, não da página — o bug do S1 era page-level); **fail-closed com snapshot persistido por SQL direto** (`payload.db.drizzle`, sem hooks — `revalidateTag` dentro de `unstable_cache` LANÇA no Next 15.4, verificado no source; o snapshot cru pré-exclusão sobrevive a edições de settings por merge do update), exclusões aplicadas na leitura. Card generalizado `CampaignArticleCard` → `CampaignContentCard` (git mv; `external?: boolean` → `<a target="_blank" rel="noopener noreferrer">`); a seção mescla artigos visíveis + vídeos elegíveis por recência (grande = mais recente — aceite S2; degrade para o comportamento S1 sem config), meta "há X · N visualizações" (relativa < 48h, absoluta além), link "YouTube →" derivado do channelId; carrossel mobile renomeado para "Conteúdos recentes". next.config ganha `i.ytimg.com` + host local do stub no remotePatterns. Testes: 15 units novas (formatters, parsers, eligibility, fetch com fetchImpl fake); e2e com stub HTTP node (`tests/e2e/youtube-stub.mjs`, webServer do Playwright vira array, porta = dev+1000; `POST /__stub/state` alterna ok/fail; thumbnails servidas localmente) — 4 testes novos no describe serial da seção (full-state com exclusão + link externo + carrossel, fail-closed sem snapshot, snapshot persistente com maxItems mudando na API down, kill switch) + reset de settings no beforeAll; conventions specs atualizados (pin do youtubeFeed, settle após about:blank). Verificação: unit 2306 + int 791 verdes; e2e frontend 14/14 dev e 6/6 prod-mode (`E2E_PROD=1` — valida TTL+tags+snapshot no build real); lint/format/tsc/knip/cycles/build verdes. `/simplify` 3 reviewers (sem P0): P1 corrigido — label do kill switch invertido ("Pausar feed" marcado = ativo → "Feed ativo"); P2 absorvidos (className do link do header extraída, `Promise.all` posts+feed no cache frio, comentário do sentinela 0 do merge, helper único `tests/helpers/youtubeStub.ts` para a porta do stub, `waitForHomeSectionState` fundido no `waitForHomeHTML` com orçamento 12s do S1, precondição do persist documentada); débito REGISTRADO (#40, prio:P2, depends #18): o `validate` do `itemId` lê `data.platform` que é sempre undefined (Payload passa o doc inteiro; verificado no source) — produção sem validação de ID de vídeo, e o e2e depende dessa mortandade (ids do stub de 20 chars); correção em dupla documentada na issue. Descartados com justificativa: ramo maxres do search (pinned por teste), tag `social-feed` fora da convenção `global_*` (precedente election-tse/municipality-catalog), remotePattern `localhost/thumbs` incondicional (decisão deliberada dos 3 modos e2e), persist não-atômico (comentado); achados visuais do design-vision mapeados para a Issue #33 (separação visual branco-sobre-branco, fora de escopo por decisão do gate).
|
||||
|
||||
**S5** — separação visual da seção "Acompanhe de perto" da prova social na home de campanha: o `<section>` sai de `bg-white` (idêntico à faixa de números, seções coladas) para `border-y border-(--campaign-line) bg-(--campaign-band)` — banda `#ebe9e9` da paleta + hairlines de 12%, o desenho aprovado no wireframe (seção 7: zona de cor própria com `border-top/bottom: 1px solid var(--line)`) e no rascunho UI do gate (Cena 1/3 recomendadas). Zero mudança de conteúdo/dados/comportamento: cards continuam brancos e saltam na banda; fail-closed intacto (sem posts visíveis a seção some e Prova → Problema voltam contíguos — teste e2e existente segue verde); nenhum token novo; sem migration/Consent/access. Única mudança de runtime: 1 linha de className; + asserções `toHaveCSS` no e2e full-state da seção (fundo `#ebe9e9` exato — pega token quebrado em vez de só "≠ branco", hairline top `1px` com cor `rgb(0 0 0 / 12%)` exata, cards brancos) — sem screenshot diff (repo não usa regressão visual). Verificação: tsc/lint/format/unit 2318 verdes; e2e frontend: empty-state (contiguidade) e bento passaram com as asserções novas (2/2 runs que as alcançaram); o teste full-state falhou localmente no clique pós-minhas-asserções por cold-compile dev sob load ~68 — baseline com as mudanças stashed falha idêntico (falha pré-existente do ambiente, não regressão); build verde (migrations ok). knip local = limitação P3 conhecida (não carrega `payload.config.ts`); CI valida.
|
||||
|
||||
**S5** — separação visual da seção "Acompanhe de perto" da prova social na home de campanha: o `<section>` sai de `bg-white` (idêntico à faixa de números, seções coladas) para `border-y border-(--campaign-line) bg-(--campaign-band)` — banda `#ebe9e9` da paleta + hairlines de 12%, o desenho aprovado no wireframe (seção 7: zona de cor própria com `border-top/bottom: 1px solid var(--line)`) e no rascunho UI do gate (Cena 1/3 recomendadas). Zero mudança de conteúdo/dados/comportamento: cards continuam brancos e saltam na banda; fail-closed intacto (sem posts visíveis a seção some e Prova → Problema voltam contíguos — teste e2e existente segue verde); nenhum token novo; sem migration/Consent/access. Única mudança de runtime: 1 linha de className; + asserções de estilo computado no e2e full-state da seção (fundo ≠ branco ≠ prova, hairline top presente, cards brancos) — sem screenshot diff (repo não usa regressão visual). Verificação: tsc/lint/format/unit 2318 verdes; e2e frontend: empty-state (contiguidade) e bento passaram com as asserções novas (2/2 runs que as alcançaram); o teste full-state falhou localmente no clique pós-minhas-asserções por cold-compile dev sob load ~68 — baseline com as mudanças stashed falha idêntico (falha pré-existente do ambiente, não regressão); build verde (migrations ok). knip local = limitação P3 conhecida (não carrega `payload.config.ts`); CI valida.
|
||||
|
||||
**S1** — seção "Acompanhe de perto" na home de campanha (jorgesolla1313.com.br), na 3ª posição entre a prova social e "Por que essa eleição importa": bento de 5 artigos do `post` collection (1 card grande + 4 pequenos) no desktop e carrossel de 1 conteúdo por tela com dots e contador "N de M" no mobile (~390px), badge de tipo (Notícia/Campanha/Artigo/Evento), capa, título e meta "há X · categoria" (relativa < 48h, data absoluta além — nunca mente congelada na ISR); card inteiro é link para a rota canônica do artigo. Fail-closed: sem posts visíveis (`isPostVisible` — tag `hidden` eleitoral nunca vaza) a seção some inteira e Prova → Problema voltam a ser contíguos. Zero schema/migration/Consent; leitura via `getVisiblePosts` sob a tag `posts` (home estática revalidada pelo mecanismo `posts` existente). Primeira fatia do board aprovado (S2/S3 = YouTube/Instagram). Verificação: unit 2282 + int 791 verdes, e2e frontend 10/10 em dev e prod (`E2E_PROD=1`, `.next-e2e`), suíte completa prod 169-170 passed com flakes ambientais de campanha (máquina compartilhada; testes diferentes a cada run, nenhum no código tocado); criação via REST com hooks reais; polling positivo do estado da home pós-revalidate (ISR serve stale uma vez sob carga) e navegação para about:blank antes do cleanup (prefetch de links apagados); lint/format/typecheck/cycles/knip/build verdes. `/simplify` 3 reviewers: 2 P1 de a11y (foco+setas no track, `inert` em slides inativos) corrigidos; o `revalidate = 300` proposto contra o congelamento da label relativa foi TESTADO e REVERTIDO — no Next 15.4 em prod ele trava o bust por tag (`revalidateTag('posts')` deixava de regenerar a home; medido em probe manual) — a label relativa ganhou threshold de 48h com fallback absoluto; P2 absorvidos (dots ≥3:1, aria-live no contador, motion-reduce na capa, fidelidade de gaps, secret do e2e espelhado da env); débitos deferidos documentados no impl plan (refusal do `formatRelativeAge` por contrato, gutter flow-centered, link `/artigos` sancionado pela intenção).
|
||||
|
||||
**S1** — seção "Acompanhe de perto" na home de campanha (jorgesolla1313.com.br), na 3ª posição entre a prova social e "Por que essa eleição importa": bento de 5 artigos do `post` collection (1 card grande + 4 pequenos) no desktop e carrossel de 1 conteúdo por tela com dots e contador "N de M" no mobile (~390px), badge de tipo (Notícia/Campanha/Artigo/Evento), capa, título e meta relativa "há X · categoria"; card inteiro é link para a rota canônica do artigo. Fail-closed: sem posts visíveis (`isPostVisible` — tag `hidden` eleitoral nunca vaza) a seção some inteira e Prova → Problema voltam a ser contíguos. Zero schema/migration/Consent; leitura via `getVisiblePosts` sob a tag `posts`; a home ganhou `revalidate = 300` para a label relativa não congelar na ISR. Primeira fatia do board aprovado (S2/S3 = YouTube/Instagram). Verificação: unit 2282 + int 791 verdes, e2e frontend 10/10 (empty-state + full-state desktop/mobile, fail-closed com tag oculta, click-through, teclado/setas, swipe, dots; criação via REST com hooks reais; cleanup convergindo a cache de dev), lint/format/typecheck/cycles/knip/build verdes. `/simplify` 3 reviewers: 2 P1 de a11y (foco+setas no track, `inert` em slides inativos) e o congelamento da data relativa na ISR corrigidos; P2 absorvidos (dots ≥3:1, aria-live no contador, motion-reduce na capa, fidelidade de gaps, secret do e2e espelhado da env); débitos deferidos documentados no impl plan (refusal do `formatRelativeAge` por contrato, gutter flow-centered, link `/artigos` sancionado pela intenção).
|
||||
|
||||
**PUB5** — slogan da campanha "Mais saúde mais futuro" no hero e no rodapé da home (jorgesolla1313.com.br), no lugar de "Um mandato do tamanho da Bahia" (discurso do mandato vigente; o novo é da campanha 2026 — saúde é o tema-âncora do candidato). Troca de copy cirúrgica, sem redesenho nem migration: h1 do hero em duas linhas uppercase "MAIS SAÚDE / MAIS FUTURO" com `aria-label` acompanhando ("MAIS SAÚDE MAIS FUTURO"), segunda linha já em `font-black` (anatomia existente), e o rodapé em sentence case com ponto ("Mais saúde mais futuro."). Testes que pinavam a string antiga atualizados (`campaignHero.unit`, `campaignHome.unit`, `frontend.e2e`). Fora de escopo decidido no gate: cabeçalho de artigos ("O mandato do tamanho da Bahia") e docs históricos. Verificação: unit 2269 + int 791 verdes, e2e frontend 8/8 em prod mode (`E2E_PROD=1`, `.next-e2e`), lint/format/typecheck/cycles/knip/build verdes; render conferido por screenshot + DOM (hero duas linhas, rodapé com ponto). `/simplify` 3 reviewers: sem P0/P1; P2 corrigidos no impl plan (redação + line refs); débitos descartados por serem convenções pré-existentes ou decisões do gate.
|
||||
|
||||
**Recently resolved (2026-08-20):** **OPS71-FLIP** — flip pós-merge falhou/silencioso no fluxo GitHub, validado e endurecido (Issue #119, achado ao vivo no merge do PR #746). **Investigação (GitHub API pública):** o `closed` do PR #746 (merge por `github-actions[bot]`, auto-merge armado com o `GITHUB_TOKEN` nativo) **não criou run nenhum** — anti-recursão documentada do GitHub (eventos acionados por ações do `GITHUB_TOKEN` não geram runs; o `closed` fica no timeline com 0 runs; comparação com o #742, mergeado por `fsolla`, cujos runs nasceram 3s após o merge). O 410 do run do #742 é falha separada (API do Forgejo respondeu 410 Gone nginx, transitório). **Endurecimento:** (1) **fix de raiz** — o arm do auto-merge (`agent-pr-ready-automerge.yml` → `scripts/github-pr-automerge.mjs`) agora usa o secret novo **`AUTOMERGE_PAT`** (PAT real do fsolla; decisão pura `automergeArmingToken` em `github-pr-flow.mjs` com fail-closed — sem o secret o job sai 1, nunca fallback pro bot token; o merge passa a ser atribuído a usuário real e o `closed` volta a gerar runs); (2) **recuperação manual** — `workflow_dispatch` com input `pr` nos dois flips (`issue-done-on-main-merge.yml`, `plan-issue-ready-on-main-merge.yml`): o job busca o PR via `gh` com o mesmo contrato do evento (merged + base main + same-repo, senão skip) e repassa o body como `PR_BODY`; (3) **close da Issue** — `forgejo-issue-transition.mjs` agora `PATCH state=closed` via novo `closeIssue` do `forgejo-api.mjs` (era-Forgejo o merge fechava nativo; era-GitHub o PR não toca o tracker — antes, toda Issue mergeada ficava `OPEN` com `done`+`in-prod`); (4) **410 no retry** — `forgejo-api.mjs` retenta **410 em qualquer método** (proxy respondeu antes da origem — sem efeito duplicado; 5xx segue fail-closed em escrita) e corpo nginx vira marker compacto `corpo não-JSON (proxy/nginx…)` no erro. **Testes:** 8 specs novas (retry 410 GET/write, marker após 410 esgotado, `closeIssue` PATCH pin, `automergeArmingToken` ok/fail-closed/vazio) — 50 unit verdes nas specs tocadas. **Docs:** `AGENTS.md` (frase do auto-merge com o PAT + porquê), `docs/AGENT-OPS.md` (tabelas de workflows/secrets + runbook de recuperação manual com fallback local `PR_BODY`). Sem migration/access/Consent/UI. Impl: [`ops71-flip-pos-merge-falhou-silencioso-impl.md`](plans/ops71-flip-pos-merge-falhou-silencioso-impl.md).
|
||||
|
||||
**Recently resolved (2026-08-19):** **OPS66** — deadlock de migrations em caminho estático: o deploy buildava o runner **antes** do migrate e, se uma migration criasse tabela lida em geração estática (caso S2, 2026-08-18: `social_feed_settings` na home), o `next build` falhava para sempre (`relation ... does not exist`) e o migrate nunca rodava — 4 deploys seguidos vermelhos até o migrator manual. Fix em `scripts/deploy-homeserver.sh` (reordenação de fases, sem custo novo — migrate e build do migrator já rodavam em todo deploy): **build do migrator** (o estágio não roda `next build` — builda contra o schema antigo) → push/tag do migrator → swap do compose (backup + trap na mesma posição) → **migrate** via `docker compose run teqo-1313-migrate` (caminho canônico do serviço de maintenance, agora com a imagem do SHA novo) → **build do runner** contra o banco JÁ migrado → push/tag → rollout → healthcheck → smoke. Alternativas rejeitadas: `payload migrate` dentro do build do runner (efeito colateral de escrita em prod a partir de um processo de build; contraria o desenho deliberado do Dockerfile) e `docker run` direto do migrator com env-file (duplicaria a definição do serviço de maintenance — caminho paralelo ao canônico). Caveat do rollback ajustado no runbook: migrate antes do build do runner deixa o banco à frente do código se o build falhar depois (mesma classe do caveat atual — migrations append-only e revisadas). Testes: `tests/unit/deployScript.unit.spec.ts` com pin de ordem completo (migrator build < swap < migrate < runner build < rollout) + invariante novo do Dockerfile (estágio migrator nunca roda `next build`). Runbook `docs/ops/teqo-1313-deploy.md` atualizado (fluxo, caveat, falha conhecida nova). Dockerfile/AGENTS.md com a precisão build-after-migrate.
|
||||
|
||||
**Recently resolved (2026-08-19):** **OPS68** — fechamento da parte 2 do OPS50: reescrita dos docs canônicos operacionais que ainda descreviam a era GitHub/Vercel. **12 arquivos, 78+/82−, edições linha-a-linha:** `docs/AGENT-OPS.md` (norte "só Forgejo + Cursor"; fonte canônica = Issues do Forgejo; fluxo do agente e Contrato de PR sem `gh` — criação via API/MCP + merge pelo safety net `agent-pr-ready-automerge.yml`, o único que espera o rollup `CI (PR) / checks`; "Só humano" = envs de `~/stack/teqo-1313.env` + branch protection + runbook; tabela de secrets sem `POOL_GITHUB_TOKEN` (legado — o script o ignora) e com nota de pool dormente; linha `archive-cursor-agent.yml` na tabela CI; "smoke Neon stage" → sem smoke stage), `README.md` (cheatsheet de 5 linhas reescrito: claim → push → PR via API/MCP → safety net mergea → ci.yml em janela de 30 min + deploy homeserver; Neon → homeserver Postgres; `PROD_DATABASE_URL` = `DATABASE_URL` de `~/stack/teqo-1313.env`; migrações no deploy via `teqo-1313-migrate`; "GitHub mirror" → Forgejo Actions; papéis `coordinator/advisor/leader/candidate`; "electoral nuclei" → municípios; Issues → link Forgejo), `docs/roadmap.md` (só o ponteiro da fonte canônica — o stub continua congelado), `AGENTS.md` (refs Neon → homeserver `teqo_1313`; job do gate `check`→`gate`, key real do YAML; "Vercel preview" → host genérico na rationale do WebAuthn), `.agents/rules/agent-pr-workflow.mdc` (comandos `gh pr create/merge/checks` morrem — `gh` não existe em nenhuma máquina (OPS50); regra descreve API/MCP + safety net; "There is no deploy from CI" corrigido — OPS53 colocou o deploy no `ci.yml`), e as skills `work-issue` (SKILL + `execution-pipeline`), `plan-issue` (frontmatter, gate "nada no Forgejo", dedup via `pnpm issue all`), `project-status`, `capture-review-debts`, `engineering-audit` (`gh issue list` → `pnpm issue all`/API; `issue-done-on-stage-merge.yml`→`on-main`; merge `stage`→`main`), `worktree-next-issue` ("resto do Forgejo") e `agent-pool` (descrição Forgejo Actions; workflow marcado removido OPS65; estado em `pool-state.json`, não mais variables). **Mantido como registro histórico** (exclusões explícitas do plano): menções de Vercel/Neon/POOL_GITHUB que documentam remoções (AGENTS.md L15/L105/L167, AGENT-OPS L94/L98, "Vercel/Git-integration deploy gate" como guardrail no rule). Fonte canônica única: `git.solla.dev/fsolla/teqo`. Sem migration/access/Consent/UI. Gate: grep de saída zero nas classes `gh pr`/`gh issue`/`github.com/fsolla`/`GitHub Issues`/`GitHub mirror`/`POOL_GITHUB`/Neon-em-uso; docs-only → CI roda os docs-guards.
|
||||
|
||||
**Recently resolved (2026-08-19):** **OPS71** — CI/PR/deploy do Forgejo Actions de volta para o **GitHub Actions**, com o **tracker de Issues permanecendo no Forgejo** (labels, claims, `pnpm issue`/`agent:*` e flips pós-merge continuam via API do Forgejo; o GitHub é só o host do Actions). O PR CI (`ci-pr.yml`, job único `checks` — OPS62) roda no GitHub com a mesma cascata (guards → lint → format → typecheck → knip → cycles → unit → int → build → e2e selected/afetado via `ci-scope.mjs`), com `concurrency` nativo cancelando o run em push novo e `if:` same-repo (repo público — fork não queima minutes). Seu check-run `CI (PR) / checks` é o required check do `main` e o gate do **auto-merge nativo** (`agent-pr-ready-automerge.yml` → `scripts/github-pr-automerge.mjs`: draft `cursor/*` → Ready via PATCH, arm `enablePullRequestAutoMerge` GraphQL com REBASE — o servidor espera o required check verde; sem poll; o pin OPS64 "rollup pode mentir" é estrutural aqui: job único posta um check-run honesto). **`ci.yml` (verificador de main) eliminado** — publicar é **dispatch manual** (`deploy.yml`): `verify` (hosted, suíte full incl. e2e full) → `deploy` (`needs`, `runs-on: [self-hosted, homeserver]`) executa `scripts/deploy-homeserver.sh <sha>` **localmente** no runner do GitHub instalado no homeserver (sem SSH; outbound-only; o hosted nunca toca o homeserver); o script re-aponta o workspace `~/teqo-deploy` para `TEQO_REPO_URL` (default `https://github.com/fsolla/teqo.git`, público). `pnpm push` passa a empurrar para `origin` = **GitHub** (cutover manual de remote documentado; o repo Forgejo congela em main, tracker vivo por API). Novos helpers plain-Node zero-dep: `scripts/lib/github-api.mjs` (REST+GraphQL, retry OPS67), `scripts/lib/github-pr-flow.mjs` (decisão draft/veto/skip, pura), `scripts/lib/github-branch-protection.mjs` (regra desejada + drift, `enforce_admins: true`), `scripts/github-pr.mjs` (criação de PR Ready — nunca draft), `scripts/configure-branch-protection.mjs` reescrito para a REST do GitHub; `forgejo-issue-transition.mjs` aceita `PR_BODY` (body do PR via evento GitHub; flip continua no Forgejo por `FORGEJO_API_TOKEN`). **Cutover em duas fases (gate):** nada do Forgejo foi apagado (`.forgejo/workflows/`, `forgejo-pr-automerge.mjs`, `forgejo-dispatch.mjs`, `ci-classify-production.mjs` preservados como rollback) — a remoção é entrega sucessora pós-validação. Testes: `githubApi` (10), `githubBranchProtection` (10), `githubPrFlow` (9) unit novos. Docs: `AGENTS.md`, `docs/AGENT-OPS.md`, `.agents/rules/agent-pr-workflow.mdc`, `work-issue/execution-pipeline.md`, `agent-work-issue`, `capture-review-debts`, runbook `docs/ops/teqo-1313-deploy.md` (dispatch manual, runner no homeserver, cutover com ordem, rollback).
|
||||
|
||||
**Recently resolved (2026-08-19):** **OPS71 Fase 2** — remoção dos arquivos do fluxo Forgejo após validação ao vivo do GitHub Actions (PRs #742/#743: CI verde, auto-merge nativo, flips via API). **14 arquivos deletados:** `.forgejo/workflows/` (6 — ci-pr.yml, ci.yml, agent-pr-ready-automerge.yml, issue-done-on-main-merge.yml, plan-issue-ready-on-main-merge.yml, archive-cursor-agent.yml), `scripts/forgejo-pr-automerge.mjs`, `scripts/forgejo-dispatch.mjs`, `scripts/ci-classify-production.mjs`, `scripts/lib/dockerignore.mjs`, `scripts/lib/branch-protection.mjs` e as specs órfãs `ciClassifyProduction`/`dockerignoreMatch`/`branchProtection` `.unit.spec.ts`. **Consumidores funcionais achados pela varredura (fora do escopo literal do body) e ajustados na mesma mudança:** `scripts/lib/test-affected-core.mjs` perdeu as 2 entries de `HIGH_RISK_EXACT` dos scripts mortos; `tests/unit/ciSkipInvariants.unit.spec.ts` tirou os 2 `.forgejo/workflows/*` do loop `.next-e2e`, os 2 paths do pin de high-risk e **reescreveu o teste da janela de 30 min (OPS65) para o invariante vivo da era GitHub**: `deploy.yml` é `workflow_dispatch`-only (sem `push:`/`schedule:` — deploy nunca automático) e `ci-pr.yml` não referencia o gate morto. **Docs:** `AGENTS.md` (frase "preservados até Fase 2" → removidos; rollback = revert do PR ou clone do main congelado do Forgejo), runbook `docs/ops/teqo-1313-deploy.md` (Rollback do cutover idem), `README.md` (cheatsheet de 5 linhas era-Forgejo com janela de 30 min + deploy automático → era-GitHub: auto-merge nativo + deploy manual dispatch), comentários com referência morta em `scripts/gate-ci.mjs`, `scripts/github-pr-automerge.mjs` e `.github/workflows/agent-pr-ready-automerge.yml`. **Intocados (anti-goal tracker):** `forgejo-api.mjs`, `agent-*.mjs`, `issue.mjs`, `forgejo-issue-transition.mjs`, `agent-promote-related-on-merge.mjs`, `.forgejo/worktree.env` (consumido pelo provisionamento de worktree), `.dockerignore` (o arquivo — `docker build` do deploy o consome; só o lib de parse morreu). `docs/AGENT-OPS.md` já era era-GitHub (zero referências). Histórico (plans/changelogs antigos) congelado. Sem migration/access/Consent/UI. Gate: tsc/lint/format/knip/cycles/unit+int/build verdes; grep de saneamento `forgejo-pr-automerge|forgejo-dispatch|ci-classify|dockerignore.mjs|branch-protection.mjs` → só histórico.
|
||||
|
||||
**Recently resolved (2026-08-18):** **OPS52** — a media saiu do Vercel Blob para o Garage S3 do homeserver (bucket `teqo-media` + key `teqo-key`, já existentes no Garage v2.1.0). O plugin `@payloadcms/storage-vercel-blob` (e o `@vercel/blob` do seed) foi trocado por `@payloadcms/storage-s3` na config, no padrão R2 da doc do Payload (`endpoint` + `forcePathStyle: true`, region `garage`). **Decisão de arquitetura (revisão A→B durante a execução, gate com o humano):** o Garage v2.1.0 **não suporta acesso anônimo na API S3** (sem `--public-read`; source: "when we support anonymous access in the future"; testado: `anonymous`/`anon` → `NoSuchAccessKey`), então o bucket fica **privado** e a app **serve a media por proxy** — sem `disablePayloadAccessControl`, o `staticHandler` do plugin streama o objeto do Garage em `/api/media/file/<filename>` (range + etag/304 nativos) — o **mesmo contrato de URL relativa que as 40 rows pré-existentes já têm** (a recuperação dos arquivos é a Issue #10/OPS52-media). **Fail-closed:** `resolveS3StorageEnv` (`src/utilities/mediaStorage.ts`, unit-testado) — envs `S3_*` parciais → throw no boot com a lista exata de faltantes; nenhuma → storage local (dev/test). Envs novas: `S3_BUCKET`, `S3_ENDPOINT`, `S3_REGION` (default `garage`), `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY` (`.env.example`, `.env.test`, worktree.mjs com cópia all-or-nothing). Seed de posts: pre-delete do Blob removido (o S3 sobrescreve o key determinístico nativamente — idempotência intacta). Prova de contrato real: PUT/GET/DELETE via SDK contra o Garage (tailnet `http://100.119.220.31:3900`), depois media criada pelo Payload local com S3 ativo → `media.url` relativa + `GET /api/media/file/...` → 200 image/png; bucket devolvido a 0 objects e `.env.local` do worktree limpo (sem credenciais de prod). Sem migration (config-only; importMap regenerado sem `VercelBlobClientUploadHandler`). Runbook de cutover registrado no impl plan: setar as 5 `S3_*` no `~/stack/teqo-1313.env` (endpoint tailnet) antes do próximo deploy (OPS53). Repo: `docs/plans/ops52-blob-vercel-para-garage-impl.md`; infra-solla `STATE.md` atualizado à parte.
|
||||
|
||||
**Recently resolved (2026-08-18):** **OPS52-media** — os objetos de media de produção (as ~40 capas dos posts, que existiam como rows com URL relativa `/api/media/file/<filename>` mas sem arquivo em lugar nenhum) são recuperáveis do WordPress de origem com `pnpm media:recover` (`scripts/recover-media.mjs`, novo): lê as rows de `media` + `post.coverImage` (mapeamento exato row→slug; fallback pelo filename determinístico `<slug>.<ext>`), resolve a capa com a MESMA lógica do seed (featured media do REST `_embed`, senão 1ª imagem inline do corpo — só 7/43 posts têm featured), baixa e faz `PutObject` no bucket das envs `S3_*` com key = `filename` da row, **sem nunca escrever no DB** (rows e URLs intocadas; o staticHandler do Payload passa a servir). Extraído para `scripts/lib/wpArticles.mjs` o fetch ao vivo do WordPress (seed-posts agora importa de lá — sem twin; unit-testado). Fail-closed: exige as 4 `S3_*` completas (`resolveS3StorageEnv` reusado; recuperação nunca vai pro storage local), `ALLOW_REMOTE_DB=true` explícito para DB remoto + echo do alvo (DB/bucket/endpoint), e timeouts curtos no SDK (`@smithy/node-http-handler`, 5s/15s) — endpoint inalcançável falha em segundos em vez de pendurar (a rota pública de media de prod pendurava >25s; evidência de possível falta de rota container→Garage, diagnosticada no runbook do impl plan). Modos: reconcile (padrão, upload idempotente por overwrite), `--dry-run` (plano + `headObject`), `--verify` (HEAD da URL pública por filename — o aceite da Issue, sem credencial). Runbook de execução no homeserver: `docs/plans/ops52-media-recuperar-arquivos-impl.md`.
|
||||
|
||||
**Recently resolved (2026-08-18):** **OPS52-media** — os objetos de media de produção (as ~40 capas dos posts, que existiam como rows com URL relativa `/api/media/file/<filename>` mas sem arquivo em lugar nenhum) são recuperáveis do WordPress de origem com `pnpm media:recover` (`scripts/recover-media.mjs`, novo): lê as rows de `media` + `post.coverImage` (mapeamento exato row→slug; fallback pelo filename determinístico `<slug>.<ext>`), resolve a capa com a MESMA lógica do seed (featured media do REST `_embed`, senão 1ª imagem inline do corpo — só 7/43 posts têm featured), baixa e faz `PutObject` no bucket das envs `S3_*` com key = `filename` da row, **sem nunca escrever no DB** (rows e URLs intocadas; o staticHandler do Payload passa a servir). Extraído para `scripts/lib/wpArticles.mjs` o fetch ao vivo do WordPress (seed-posts agora importa de lá — sem twin; unit-testado). Fail-closed: exige as 4 `S3_*` completas (`resolveS3StorageEnv` reusado; recuperação nunca vai pro storage local; `--verify` dispensa S3 — só HEAD de URLs públicas), `ALLOW_REMOTE_DB=true` explícito para DB remoto + echo do alvo (DB/bucket/endpoint/origin), timeouts curtos no SDK (`@smithy/node-http-handler`, 5s/15s) e nos fetches (30s/60s) — endpoint inalcançável falha em segundos em vez de pendurar (a rota pública de media de prod pendurava >25s; evidência de possível falta de rota container→Garage, diagnosticada no runbook do impl plan), e covers constrangidas a http(s) no host do WordPress com só `image/*` armazenado (sem SSRF/objeto não-imagem no proxy). Modos: reconcile (padrão, upload idempotente por overwrite), `--dry-run` (plano + `headObject`; exit 1 se qualquer checagem falhar), `--verify` (HEAD da URL pública por filename — o aceite da Issue, sem credencial). Runbook de execução no homeserver: `docs/plans/ops52-media-recuperar-arquivos-impl.md`.
|
||||
|
||||
**Recently resolved (2026-08-18):** **OPS54** — fim do warning do pnpm sobre o campo `pnpm` ignorado em `package.json`. A allowlist `onlyBuiltDependencies` (`sharp`, `esbuild`, `unrs-resolver`) migrou para o home canônico do pnpm 10: novo `pnpm-workspace.yaml` na raiz (sem `packages` — repo single-package, default `['.']`), e o bloco `"pnpm"` saiu do `package.json` (pnpm 10 não o lê mais; sem a remoção o warning persiste). `Dockerfile` estágio `deps` agora copia `pnpm-workspace.yaml` junto (`COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./`) — sem isso a allowlist não chegaria ao install da imagem e local×Docker divergiriam nos build scripts. Validado com a versão pinada (10.11.0) e no estágio deps real (`docker build --target deps`): nenhum `[WARN]` do campo `pnpm` em install/CI/Docker, `pnpm config get onlyBuiltDependencies` → os 3 pacotes, `--frozen-lockfile` limpo (lockfile e grafo intocados). Guardrails respeitados: `.npmrc` (`legacy-peer-deps`, `include`) e `packageManager` intocados; nada além do que o warning citava foi migrado (o aviso informativo de `msw`/`onnxruntime-node` é comportamento padrão do pnpm 10 — fora de escopo por anti-goal). Sem migration, sem UI, sem testes de domínio (config de ferramenta; gates completos verdes). Repo: `docs/plans/ops54-pnpm-warning-onlybuiltdependencies-impl.md`.
|
||||
|
||||
**Recently resolved (2026-08-18):** **OPS56**: o `forgejo-pr-automerge` nunca mais reporta falso positivo "já mergeado" com PR aberto. Raiz confirmada no source do servidor (Forgejo 9.0.3, tag v9.0.3): o endpoint `POST /pulls/{n}/merge` responde **200 com corpo vazio** no sucesso (falhas são 404/405/409/500), e o `request()` do `forgejo-api.mjs` convertia isso em `null` — o `autoMerge()` devolvia `null` cru e o CLI imprimia "já mergeado" (exit 0) independente do estado real do PR; o incidente (PR #27 da OPS52, aberto das 01:59 às 02:45) deixou o merge falhar silenciosamente com o PR aberto. Correção: o veredito de `autoMerge` agora é **re-read do PR** após a tentativa (`{ attempted, merged, pr }`) — 200-vazio → confirma no `GET /pulls/{n}`; POST falhou e o PR segue aberto → **re-throw** (falha ruidosa, exit 1); raça perdida (outro ator mergeou) → "já mergeado" só com o PR realmente mergeado; PR fechado sem merge → "fechado sem merge — skip" honesto. `waitForChecks` passou a esperar o `mergeable` amadurecer (false → throw imediato de conflito; null → segue no poll), mesmo timeout de 30 min. CLI sem shadowing de `pr` (const redeclarada). Testes: 6 specs novas em `forgejoApi.unit.spec.ts` (veredito por re-read, POST 405 + PR aberto → reject, raça concorrente, no-op de já-mergeado, poll de mergeable null, fail-fast de mergeable false). Gates: 2318 unit + 791 int + tsc/lint/prettier/knip/check:cycles verdes. Débito deferido: alargar o gatilho do `agent-pr-ready-automerge.yml` para branches não-`cursor/*` (decisão de ops separada).
|
||||
|
||||
**Recently resolved (2026-08-18):** **OPS52-media-guard** — o modo reconcile (escrita) do `pnpm media:recover` ganhou guard de intenção explícita (achado do /simplify da #10): exige `MEDIA_RECOVER_CONFIRM=1` para armar a escrita no bucket — antes, bastava o conjunto completo de `S3_*` (cópia all-or-nothing de credenciais de prod num worktree + rows locais via db:pull → escrevia covers no bucket de prod; o echo do alvo mostrava, não impedia). `--dry-run`/`--verify` seguem sem flag (não escrevem). Sem a flag, reconcile recusa com exit 1 e a mensagem exibe o comando correto. Implementação no dono dos guards: `isTruthyEnv` novo em `scripts/lib/cli.mjs` (semântica exata `true|1` do `ALLOW_REMOTE_DB` — sem trim/case; `isRemoteDbOverrideSet` passou a delegar, sem twin), unit-testado com pin do comportamento preservado (`TRUE`/`yes` continuam recusados). Runbook do homeserver atualizado com a flag; nota no AGENTS.md.
|
||||
|
||||
**Recently resolved (2026-08-18):** **OPS57** — o safety net do `forgejo-pr-automerge` agora cobre **todas as PRs same-repo para `main`**, não só `cursor/*`. Motivação (débito deferido na OPS56): dos 25 PRs mergeados no repo, **zero** eram `cursor/*` — 9 de `plans/*`, 11 de worktrees humanos, 5 de `refs/pull/N/head` (era da migração) — o filtro antigo nunca cobriu nenhum PR real, e o incidente OPS52 (PR #27, humano, merge falhou silenciosamente) era exatamente a classe excluída. Mudanças: `if:` do job em `agent-pr-ready-automerge.yml` → `state == 'open' && head.repo.full_name == github.repository` (guarda de fork — o repo é público e uma PR de fork mergeada dispararia o deploy de prod; campos já usados no payload, sem dependência de `is_draft` do webhook); política de draft movida para o CLI `forgejo-pr-automerge.mjs` com o head ref do **re-read da API** — draft `cursor/*` → Ready (pool inalterado); draft não-`cursor/*` → skip exit 0 (**draft = veto do ator, PR humana nunca é forçada**); PRs ready não-`cursor/*` (humanas/plans) passam a mergear com CI verde — redundante no caminho feliz (o fluxo humano já arma `--auto --rebase`) e é o que pega a classe OPS52; `ready_for_review` em PR draft re-dispara o job e mergea. Docs sincronizadas (`docs/AGENT-OPS.md` tabela CI por alvo, `.agents/rules/agent-pr-workflow.mdc` seção Safety net). Rejeitado: `!is_draft` no YAML (campo do payload não verificado, falha fail-open) e allowlist de prefixos (códigos humanos sem prefixo comum — OPS/S1/PUB5). A própria PR desta entrega ainda usa o workflow de `main` (antigo); a próxima PR exercita o gatilho alargado. Sem migration/access/Consent/UI. Testes: nenhum novo (lógica de veredito já coberta pelos specs da OPS56; branch de draft é 3 linhas no CLI); gates unit/int verdes.
|
||||
|
||||
**Recently resolved (2026-08-18):** **OPS58** — o sync de posts de produção ficou pronto para executar com runbook documentado: `scripts/seed-posts.mjs` ganhou o modo **`--dry-run`** (plan-only: fetch ao vivo + echo do alvo + relatório do que SERIA criado/saltado — zero escrita, pre-flight padrão OPS52-media antes de tocar o DB de prod; argumento desconhecido morre) e a **taxonomy `CLASSIFICATION` foi atualizada** com os 4 slugs novos do jorgesolla.com.br (43 posts no WP vs 39 mapeados): os 3 de campanha/pré-campanha (convenção estadual do PT, plenária "PT tem que disputar o programa de governo", caravana da Chapada) entraram com `tags: ['eleitoral']` para ficarem sob o controle de visibilidade eleitoral do repo (`isPostVisible` fail-closed — critério do tag documentado no seed), e o embate com Bruno Reis em `politica`. A execução em prod (pós-merge, homeserver, env do stack, `ALLOW_REMOTE_DB=true`) segue o runbook: dry-run → sync (create-only, idempotente por slug) → bust do cache `posts` via `/api/revalidate` → verify (`media:recover --verify` para capas + curl nas páginas dos novos). Runbook para as próximas sincronizações: `docs/plans/ops58-sincronizar-posts-prod-impl.md`.
|
||||
|
||||
**Recently resolved (2026-08-18):** **OPS57** — o safety net do `forgejo-pr-automerge` agora cobre **todas as PRs same-repo para `main`**, não só `cursor/*`. Motivação (débito deferido na OPS56): dos 25 PRs mergeados no repo, **zero** eram `cursor/*` — 9 de `plans/*`, 11 de worktrees humanos, 5 de `refs/pull/N/head` (era da migração) — o filtro antigo nunca cobriu nenhum PR real, e o incidente OPS52 (PR #27, humano, merge falhou silenciosamente) era exatamente a classe excluída. Mudanças: `if:` do job em `agent-pr-ready-automerge.yml` → `state == 'open' && head.repo.full_name == github.repository` (guarda de fork — o repo é público e uma PR de fork mergeada dispararia o deploy de prod; campos já usados no payload, sem dependência de `is_draft` do webhook); política de draft movida para o CLI `forgejo-pr-automerge.mjs` com o head ref do **re-read da API** — draft `cursor/*` → Ready (pool inalterado); draft não-`cursor/*` → skip exit 0 (**draft = veto do ator, PR humana nunca é forçada**); PRs ready não-`cursor/*` (humanas/plans) passam a mergear com CI verde — redundante no caminho feliz (o fluxo humano já arma `--auto --rebase`) e é o que pega a classe OPS52; `ready_for_review` em PR draft re-dispara o job e mergea. Docs sincronizadas (`docs/AGENT-OPS.md` tabela CI por alvo, `.agents/rules/agent-pr-workflow.mdc` seção Safety net). Rejeitado: `!is_draft` no YAML (campo do payload não verificado, falha fail-open) e allowlist de prefixos (códigos humanos sem prefixo comum — OPS/S1/PUB5). A própria PR desta entrega ainda usa o workflow de `main` (antigo); a próxima PR exercita o gatilho alargado. Sem migration/access/Consent/UI. Testes: spec nova em `forgejoApi.unit.spec.ts` (waitForChecks para quando o PR vira draft no meio do wait — veto garantido mesmo em conversão mid-run; autoMerge idempotente); gates unit/int verdes. **Validação ao vivo na própria entrega:** o Forgejo roda o workflow do head da PR (não o de main), então a PR #48 foi mergeada pelo safety net alargado no primeiro uso real — o `if:` com `head.repo.full_name` avaliou true em PR real e o script mergeou por rebase. Fixes pós-review do /simplify: `waitForChecks`/`autoMerge` param em PR draft (antes: 405 ruidoso na conversão mid-run, contradizendo o "veto nunca tocado"); CLI valida `base.ref === 'main'` (invocação manual); comentário do fork guard documenta por que ele não pode descer pro CLI (checkout de fork roda o script do fork = RCE com o token do Actions); docs sem o mecanismo fantasma `gh pr` (era GitHub, OPS50 trocou por CLI plain-Node).
|
||||
|
||||
**Recently resolved (2026-08-18):** **OPS59** — o pre-push local não roda mais e2e: ele roda **uma vez, no CI** (PR e main — gateia merge e deploy). O espelho `scripts/gate-ci.mjs` cortou o bloco e2e da cascata (playwright install, build `.next-e2e`, `test:e2e`) e o preflight de DB passou a depender só de test/build; o readout do classificador mantém `e2e=<mode>` e imprime o que o CI rodará ("e2e runs in CI, not in this gate — OPS59"), com o check `unmapped` duplicado removido (`ci-scope.mjs` já o imprime no stderr). `package.json`, `.husky/pre-push`, `git-push.mjs` e os workflows `.forgejo/*` intocados (CI verificado, não alterado: `ci-pr.yml` e2e → `checks`; `ci.yml` e2e → `checks` → `deploy`). `tests/unit/ciSkipInvariants.unit.spec.ts` tirou `gate-ci.mjs` da lista de arquivos que devem conter `.next-e2e` (o gate local não builda mais e2e; `run-e2e-affected.mjs`/`playwright.config.ts` seguem cobertos — e2e local opcional intacto). Docs sincronizadas: `AGENTS.md` (checklist local item 3 — `test:e2e`/`test:all` saem da obrigatória; "e2e = CI, local opcional `pnpm test:e2e:affected`"), `.agents/rules/engineering-standards.mdc` e `agent-pr-workflow.mdc` (gate descrito como "espelho do ci-pr.yml sem e2e"). Verificado por grep: `AGENT-OPS.md` e skills (`work-issue`, `agent-work-issue`, `worktree-next-issue`, `local-database`) não citavam e2e no pre-push — sem mudança. Anti-goals respeitados: sem flag de "pular e2e" (bypass continua só via `--no-verify` documentado), política "Dono do PR, dono do CI" inalterada. Sem migration/access/Consent/UI. Verificação: `pnpm gate:ci` local (cascata nova full — lint/format/typecheck/knip/cycles/unit/int/build em `teqo_test`; e2e vira print), unit 2286+ verdes; o e2e desta PR roda full no CI por HIGH_RISK (`gate-ci.mjs`).
|
||||
|
||||
**Recently resolved (2026-08-18):** **OPS60** — o sync do `pnpm db:seed:posts` ganhou guard de intenção explícita para escrita no bucket (achado do /simplify da #37): com as envs `S3_*` ativas, o modo sync exige `SEED_MEDIA_CONFIRM=1` (mesmo padrão `isTruthyEnv` do `MEDIA_RECOVER_CONFIRM` da OPS52-media-guard) e recusa com exit 1 antes de qualquer escrita sem a flag — o risco fechado: um worktree com cópia de credenciais `S3_*` de prod (all-or-nothing do `worktree-env.mjs`) rodando sync contra DB local sobrescrevia covers no bucket de **prod** (o seed é idempotente por slug e sobrescreve o key determinístico). Storage local (sem `S3_*` → disk) e `--dry-run` seguem sem flag (não escrevem); echo do alvo ganhou `Bucket`/`Endpoint` quando S3 ativo (paridade com o `media:recover`). Implementação no dono: `isTruthyEnv` (já existente da #37) + `resolveS3StorageEnv` — sem twin. Runbook do OPS58 atualizado com o comando real; nota no AGENTS.md.
|
||||
|
||||
**Recently resolved (2026-08-18):** **OPS61** — contratos de merge/flip do Forgejo consertados. **(S1)** Os workflows pós-merge (`issue-done-on-main-merge.yml`, `plan-issue-ready-on-main-merge.yml`) rodavam com o `GITHUB_TOKEN` nativo, que 403 em escrita de Issues — o flip `Closes #N → done+in-prod` (PRs #42/#48) quebrava em silêncio (o script engolia o 403 e o job terminava success; flip manual necessário). Agora injetam `FORGEJO_API_TOKEN` (o PAT que o pool já usa para o mesmo tipo de escrita) via `env:` no job, e o `forgejo-issue-transition.mjs` sai **exit 1** se qualquer flip falhar — o job vermelha em vez de mentir. **Fork guard** nos dois workflows (`head.repo.full_name == github.repository`, a mesma classe do `agent-pr-ready-automerge`): PR de fork mergeada faria checkout do script do fork rodando com o PAT do repo (RCE). **(S3)** A branch protection de `main` **não existia no servidor** (0 regras no `GET /branch_protections` — o `pnpm configure:branch-protection` era passo humano pendente do OPS50 e nunca rodou; as docs diziam "aplicado" e mentiam), e o valor `checks` do script **nunca casaria** os contextos reais do Forgejo (casamento por glob contra `CI (PR) / checks (pull_request)` — o sufixo ` (evento)` faz parte do contexto; literal `checks` = todo merge bloqueado). Script corrigido: contexto `CI (PR) / checks*`, idempotente (GET → POST/PATCH/no-op, verifica após aplicar, dry-run honesto) com decisão pura em `scripts/lib/branch-protection.mjs` + spec nova. **(2b — achado ao vivo na própria entrega, PR #67)** O safety net tinha **dois** bloqueios independentes. (1) O gate antigo do `waitForChecks` (`pending.length === 0`) era **insatisfazível**: o Forgejo posta o status do próprio job do safety net como `pending` durante o run inteiro. (2) O merge POST faz um push do branch rebaseado para `main` e o hook pre-receive do Forgejo rejeita esse push quando o usuário do token não tem write no repo — o `GITHUB_TOKEN` nativo age como o usuário sistema do Actions (o mesmo que 403 em Issues), então o merge do CLI morria com 409 "User permission denied for writing" (confirmado na fonte 9.0.3: o hook cai no `AssertCanWriteCode` antes do caminho de merge). Resultado: o CLI **nunca auto-mergeou nenhuma PR** — todo merge Forgejo até aqui foi manual (`merged_by` do dono), inclusive o da PR #52 com o CI vermelho (a narrativa da "raça do snapshot" da intenção estava errada: o merge vermelho foi manual; e runs que "exitaram 0" eram leituras de PR já mergeada). Gate novo: espera o rollup `CI (PR) / checks` presente+verde (único contexto que só existe quando a cascata inteira do ci-pr assentou) e **ignora o próprio contexto** do safety net; `mergeable === false` só conta como conflito após a cascata assentar; timeouts sobem (CLI 30→45 min, workflow 40→55 min); e o workflow do safety net agora injeta `FORGEJO_API_TOKEN` (mesmo PAT dos pós-merge) — o merge com o PAT (admin) passa no hook. A regra no servidor é o gate real para todo mundo: o merge POST é bloqueado com 405 enquanto o contexto exigido não estiver verde — inclusive merges manuais via API (a PR #52 não teria entrado vermelha). **Validação ao vivo na própria entrega:** a regra foi criada e verificada no servidor (re-run = no-op), e a PR #67 (`Closes #53`) mergeou pelo novo gate do rollup com a regra aplicada; o `issue-done` flipou a #53 para `done`+`in-prod` com o PAT. Sem migration/access/Consent/UI. Testes: `forgejoApi.unit.spec.ts` (rollup não-posto continua poll, rollup failure lança, próprio status do safety net não bloqueia, mergeable pré-settle — fixtures dos waitForChecks/autoMerge existentes atualizados para o contexto real), `branchProtection.unit.spec.ts` nova (create/update/noop, glob casa o contexto observado).
|
||||
|
||||
**Recently resolved (2026-08-18):** **OPS62** — fail-fast estrutural do CI: `ci-pr.yml` e `ci.yml` deixaram de ter jobs irmãos. Cada workflow agora tem **um único job `checks` sequencial** (ci-pr: guards de conteúdo → lint → format → typecheck → knip → cycles → unit → int → build → e2e, com skips do `ci-scope.mjs` agora como step com outputs; ci.yml: mesma mecânica full + deploy OPS53 intacto), então **step vermelho interrompe o job, o run falha e o slot do runner é liberado imediatamente** — sem API de cancel (o Forgejo 9.0.3 não tem), sem watchdog, sem upgrade do Forgejo no homeserver (decisão X1 do gate de execução substitui o watchdog+upgrade da intenção; o aceite "API de cancel/upgrade" e "mesmos jobs, mesma cascata" foi revisado pelo humano — o que o CI _verifica_ permanece idêntico, só o envelope muda). Diagnóstico preservado: steps têm logs próprios. O job id `checks` mantém o contexto `CI (PR) / checks` que o automerger/`waitForChecks` e a branch protection esperam (OPS61). Paralelismo do e2e recuperado por `PLAYWRIGHT_WORKERS=4` num único processo (substitui os 2 shards × 2 workers; mesma carga); **2 services Postgres por nome na rede do job** (`postgres-int`/`postgres-build`, **sem publish host** — o job único seguraria portas fixas pelo run inteiro e runs concorrentes colidiriam; o Forgejo 9 não expande expressões em `services.ports`, medido ao vivo no run 730 falhando em 1s com a string literal; o DNS da rede do job mata a colisão de raiz — os guards fail-closed `LOCAL_HOSTS`/`assertTestDatabase` admitem os nomes dos services, ainda rejeitando hosts remotos) preservam a topologia de hoje (e2e nunca herda resíduo de fixtures do int); 1 checkout + 1 `pnpm install` por run (antes ~10). Plumbing morto removido: `e2eShardConfig` e o output `e2e_shards` do classifier (sem consumidor). Guards de conteúdo movidos para o início do pipeline (falham rápido e são PR-scoped). `/simplify` (3 reviewers): P1 aplicados — `set -euo pipefail` no step `scope` (crash do classifier com outputs vazios silenciaria skips → false green) e services por nome (acima); P2 aplicados — pins de invariantes ancorados em chaves YAML (comentários não satisfazem `needs: [checks]`/`matrix:`/`fail-fast:`), pin do wiring crítico `id: scope` + `if: e2e_mode == 'full'` (perdê-lo pula a suíte em silêncio), comentário morto do sharding em `playwright.config.ts` e `docs/TESTING.md` corrigidos (ainda citavam `.github/workflows` e jobs paralelos); P3 — JSDoc do `ci-scope.mjs` e redação do impl plan; o P2 do `waitForChecks` (timeout 30 min vs job 50 min) fica como débito de CI anotado no impl. Docs: tabela CI do AGENT-OPS reescrita + linha morta do `styfle/cancel-workflow-action` (removida no OPS50) apagada. Sem migration/access/Consent/UI. Testes: invariantes novos em `ciSkipInvariants.unit.spec.ts` (um único job `checks`, sem `matrix:`/`fail-fast:`/`runs-on: host` no ci-pr, `PLAYWRIGHT_WORKERS: 4`, guards antes do lint, deploy gated em `needs: [checks]`, services por nome sem `ports:`); `localHosts.unit.spec.ts` com os nomes dos services; `testAffected.unit.spec.ts` sem o describe do shard config. **Validação ao vivo na própria entrega:** o ci-pr deste PR já é o envelope novo — o primeiro run (730) pegou a expressão de porta não suportada (achado → corrigido para DNS de service), o run verde prova a suíte inteira no job único; a medição do tempo de parede fica registrada no run. Custo aceito (X1): run verde ~21 min esperados (vs ~15–18 com jobs paralelos) e e2e vermelho é o caso mais lento de veredito; débito registrado no impl plan (matrix fail-fast Y) se a fila do runner apertar. Impl: `docs/plans/ops62-ci-fail-fast-forgejo-impl.md` (a intenção fica como histórico do gate).
|
||||
|
||||
**Recently resolved (2026-08-18):** **OPS63** — os `docs-guards` (OPS44) rodam também no pre-push local: o espelho `scripts/gate-ci.mjs` ganhou os três checks na fase 1 (changelog append-only, agregado sync `--check`, marcadores de conflito em markdown) como chamadas diretas dos mesmos scripts do CI — labels espelhando os steps do job `docs-guards`; header do arquivo e linha final atualizados. `package.json`, `.husky/pre-push`, `git-push.mjs` e workflows `.forgejo/*` intocados: `gate:push` = `gate:ci` continua a cadeia única, o job `docs-guards` segue dono do escape `changelog-rewrite:` (body do PR — CI-only; restauração legítima D8 falha local com a mesma mensagem do CI e usa o bypass documentado `git push --no-verify`, com o CI como via do escape). Docs sincronizadas: `.agents/rules/engineering-standards.mdc` e `agent-pr-workflow.mdc` (gate descrito como "espelho do ci-pr.yml sem e2e, com docs-guards locais"), `docs/AGENT-OPS.md` (contrato item 4 — OPS44: checks no pre-push + nota do escape CI-only). Verificado por grep: `AGENTS.md` e skills não descreviam o gate sem os checks de docs — sem mudança. Anti-goals respeitados: sem bypass novo (falha local honesta), sem `plans-only-closes` local (contrato de PR body). Sem migration/access/Consent/UI/schema. Verificação: três checks green na branch; caminho de falha validado (commit descartável removendo linha do agregado → append-only falha com mensagem acionável); `pnpm gate:ci` local full; CI roda full nesta PR por HIGH_RISK (`gate-ci.mjs`).
|
||||
|
||||
**Recently resolved (2026-08-18):** **OPS65** — CI do `main` em **janela fixa de 30 min** + **agent-pool morto**. O `ci.yml` não dispara mais a cada push: roda por `schedule */30` + `workflow_dispatch`, e um gate barato (`gate`, runs-on `host`, main-only, sem pnpm — `scripts/ci-classify-production.mjs` + matcher puro `scripts/lib/dockerignore.mjs`) lê a **revision do container rodando** no homeserver (label `org.opencontainers.image.revision` — o compose pode mentir após rollback falho), diffa contra o HEAD de `main` e classifica "mudança de produção" usando o `.dockerignore` como fonte da verdade (o que não entra no build context não justifica build novo; `docs/`/`.agents`/`.env*` viram skip; `scripts/`/`tests`/`.forgejo`/`AGENTS.md` contam como produção — conservador, qualquer dúvida roda a suite). Deployed == HEAD → suite e deploy skipped, run verde (o rollup `checks` já aceita skipped); qualquer outra coisa → suite full + deploy na mesma janela; dispatch sempre roda tudo; o `deploy-homeserver.sh` ganhou guard de idempotência "already deployed" (revision do container já no SHA → skip verde). Fail-open: revision desconhecida → suite roda (main sempre verificada); gate incapaz de classificar → suite roda e o run fica **vermelho** (nunca blackout silencioso); deploy duplo-gated (`needs: [checks, gate]` + `if` explícito — nunca deploy às cegas nem com suite vermelha). `agent-pool.yml` removido (o tick `*/10` custava 144 `pnpm install`/dia na workstation; scripts/skills do pool ficam dormentes). Testes: `dockerignoreMatch` + `ciClassifyProduction` unit novos (matcher contra o `.dockerignore` real, fail-open, dispatch, caminho git com repo scratch, exit codes), pins do `ciSkipInvariants` (janela 30, sem `push`, ref guard, ifs fail-open) e do `deployScript` (guard), `HIGH_RISK_EXACT` += classificador. Docs: runbook `docs/ops/teqo-1313-deploy.md` (janela/gate/"already deployed"/falhas), `AGENTS.md`, `docs/AGENT-OPS.md`.
|
||||
|
||||
**Recently resolved (2026-08-18):** **OPS64** — o safety net `ready-automerge` deixa de se auto-bloquear e o gate do merge fica estruturalmente honesto. O sintoma primário da Issue (deadlock: o `waitForChecks` contava o próprio status `pending` do job → timeout 30 min → merge nunca acontecia) **já tinha sido entregue pelo OPS61** (filtro do próprio contexto + gate do rollup + PAT; provado vivo: runs 710/712 mergearam os PRs #67/#70). O secundário — o rollup `checks` reportava SUCCESS com um shard de e2e vermelho porque o Forgejo 9.0.3 reporta o resultado do **último** shard como o resultado do parent de matrix no contexto `needs` (runs 604/607/610: e2e (1) vermelho + e2e (2) verde → `checks` verde; o `needs.<job>.results` do GitHub não existe neste runner) — **foi eliminado estruturalmente pelo OPS62** (mergeado em main durante a execução deste item): o ci-pr.yml e o ci.yml viraram **jobs únicos sequenciais** (sem matrix, sem shards), então o status do job `checks` já é o veredito honesto da cascata inteira. Este item entregou o que restava de valor: (a) **teste-pino no `waitForChecks`** (`forgejoApi.unit.spec.ts`, cenário run 607) — o CLI **nunca confia no rollup sozinho**: varre os statuses individuais e lança "Checks falharam" com rollup verde + shard vermelho (invariante de defesa que continua válido e pegaria qualquer matrix que volte); (b) **docs honestas** (`agent-pr-workflow.mdc`, `AGENT-OPS.md`) registrando o veredito do rollup, o pin do CLI e a eliminação estrutural do bug pelo OPS62. O script `ci-rollup-check.mjs` e os edits de workflow planejados foram **descartados na execução** (redundantes pós-OPS62 — decisão do gate com o humano; os workflows em main são os do OPS62, intocados). Sem migration/access/Consent/UI/schema. Verificação: unit 2360 verdes; lint/format/typecheck/knip/cycles no gate local; a própria PR prova o ciclo (o `ready-automerge` mergea no fim). Impl plan: `docs/plans/ops64-forgejo-pr-automerge-waitforchecks-impl.md`.
|
||||
|
||||
**Recently resolved (2026-08-17):** **OPS51** — o banco de produção saiu do Neon para o Postgres do homeserver (`teqo_1313`, container `teqo-1313`), sem mudança de config: o `~/stack/teqo-1313.env` já apontava para o banco preparado (criado 16/08 com role própria + cadeia completa de 56 migrations + zero conteúdo — verificado), então restaurar = a produção "apareceu" no container. **Divergência deliberada da intenção (questão A/B validada como B):** rejeitado criar `teqo_prod` novo — o argumento "o banco do 1313 tem vida própria" não se sustenta (o site 1313 É a produção deste repo) e o OPS53 já trata `teqo_1313` como produção. Execução 100% no homeserver (PII nunca tocou a workstation): URL do Neon puxada do Vercel (`vercel env pull`, projeto `jorgesolla`, `DATABASE_URL_UNPOOLED` → `NEON_DATABASE_URL` em `~/stack/.env` chmod 600), `pg_dump -Fc` (Neon PG 17.10) → `/srv/hdd/backups/teqo-neon-pre-migracao/teqo-neon-full-20260817-204800.dump` (5,8 MB, sha256 `f12196f2…`), `pg_restore --no-owner --no-privileges` como a role `teqo_1313` (drop schema + grant, padrão do `db-pull`), verificado: contagens por tabela idênticas ao Neon (1919 contatos, 39 posts, 1487 assinaturas, 1484 subscrições, 35 campaign_users, 392 lideranças, 2 pledges, 435 municípios, 6 atividades; supporter segue 0 — blocker LGPD ainda de pé), sequences no topo (`last_value == max(id)`), objetos com dono `teqo_1313`, `payload migrate` no-op (56 = 56, serviço de maintenance `teqo-1313-migrate`), 3 credenciais WebAuthn + 34 sessions + 5 chaves Consent restauradas (RP ID não mudou — biometria segue valendo). Smoke sem deploy: `/`, artigo com conteúdo restaurado, `/campanha/login`, `/admin`, `POST /api/revalidate` (`posts` + `global_privacy-policy`), WebAuthn `login-options`, barreira 307 sem sessão — verdes; smoke de browser (login, biometria, convites) validado manualmente. **Rollback documentado:** Neon nunca foi escrito (só lido); voltar = re-apontar `DATABASE_URL` do `teqo-1313.env` para o Neon + `compose up -d`. **Débito registrado (→ OPS52):** as 40 rows de `media` têm URLs relativas (`/api/media/file/…`) sem arquivos em lugar nenhum — capas dão 500 no homeserver e **404 no próprio Vercel/produção atual** (estado pré-existente; migração manteve paridade). Repo: plano da intenção concluído, `AGENTS.md` e help de `db:pull`/`check-push-chain` sem a referência ao Neon; infra-solla `STATE.md` atualizado à parte.
|
||||
|
||||
**Recently resolved (2026-08-17):** **OPS53** — o deploy do site 1313 virou etapa do CI: mergir em `main` com a suite verde == site atualizado em `jorgesolla1313.com.br`, sem passo manual. O job `deploy` em `.forgejo/workflows/ci.yml` roda no host do runner (workstation, `runs-on: host`) e streama `scripts/deploy-homeserver.sh` para o homeserver via SSH (`ssh homeserver "bash -s -- <sha>"`); o script remoto (bash puro, homeserver sem dependências novas) faz: guard de HEAD (`git ls-remote` de `main` == SHA do job, senão skip "stale"), `flock` de serialização, workspace `~/teqo-deploy` (clone do Forgejo local), `docker login localhost:5000` (REGISTRY_USER/PASSWORD de `~/stack/.env`), build dos stages `runner`+`migrator` no homeserver (`--network stack_default` para a geração estática alcançar o `postgres:5432` do compose; secrets de `~/stack/teqo-1313.env` via BuildKit, sem camadas/logs), push no registry local, swap dos tags de imagem no compose (com backup `docker-compose.yml.pre-<sha>`), **migrate pelo serviço de maintenance `teqo-1313-migrate` antes do rollout**, `docker compose up -d`, espera do healthcheck e smoke (`/`, `/campanha/login`, `/admin`, barreira 307, WebAuthn `login-options` com body JSON, `POST /api/revalidate` com o secret real — rehearsal validado contra o site vivo). Falha pós-swap → rollback automático best-effort (restore do backup + `up -d` da imagem anterior) + job vermelho. **Divergência deliberada da intenção (fato novo):** a intenção assumia o runner no homeserver; verificado (`infra-solla/STATE.md` + ao vivo) que o runner é a `workstation-runner` na workstation e o stack de produção vive no homeserver — a abordagem A foi corrigida para "job `runs-on: host` na workstation + todo o deploy no homeserver via SSH", o que manteve **zero secrets novos no Forgejo** e zero mudança de infra no compose. Nenhum secret no repo; envs seguem chmod-600 no homeserver. Runbook (rollback manual, falhas conhecidas, decisão de segurança do host label): `docs/ops/teqo-1313-deploy.md` (novo). Guarda estrutural nova: `tests/unit/deployScript.unit.spec.ts` (bash -n + invariantes do pipeline: HEAD guard, BuildKit secrets, ordem migrate→rollout, smoke). AGENTS.md/AGENT-OPS.md atualizados. Primeiro deploy real = o merge deste próprio item (tracer bullet); infra-solla `STATE.md` anotado à parte.
|
||||
|
||||
**Recently resolved (2026-08-16):** **CAMPAIGN-HOME-RESPONSIVE**: a primeira dobra da home pública agora acompanha as referências do Penpot em 393, 768, 1024, 1280, 1366 e 1920 px. Retrato, aliados, marca, texto, CTAs e prova parlamentar interpolam entre os tamanhos com `clamp()`, `vw` e `dvh`, sem saltos por proporção de tela nem overflow horizontal; texto, ações e condecorações permanecem no fluxo e crescem com os ajustes de espaçamento da WCAG 1.4.12. No desktop, a escala e a posição horizontal das fotos dependem somente da largura: Solla, Lula e Wagner ficam ancorados ao rodapé real do hero até quando o conteúdo aumenta a seção, e um palco centralizado de 1920 px preserva a proximidade entre retrato e texto em monitores ultrawide. O E2E fixa a geometria das seis referências, larguras intermediárias, transições de proporção, desktops baixos, ultrawide e os overrides de acessibilidade; o caso de regressão também foi executado no Firefox. Sem migration, schema ou mudança de conteúdo.
|
||||
|
||||
**Recently resolved (2026-08-16):** **OPS50** (parte 1): CI e scripts de agentes portados do GitHub para o Forgejo (`git.solla.dev`, tracker já migrado nas Issues #1–#3), sem nenhuma dependência do GitHub Actions. Os 10 workflows de `.github/workflows/` viraram 8 nativos em `.forgejo/workflows/` (`ci.yml` com deploy gated + requeue por `forgejo-dispatch.mjs`, `ci-pr.yml` com cascade de jobs e checks agregador, `agent-pool.yml`, `agent-pr-ready-automerge.yml`, `issue-done-on-main-merge.yml`, `plan-issue-ready-on-main-merge.yml`, `vercel-promote.yml`, `archive-cursor-agent.yml`) e `.github/workflows/*` + `.github/actions/` foram removidos (cancel-workflow-on-failure não existe no Forgejo). Novo runner: Forgejo runner v13 na workstation (capacity 4), jobs paralelos em serviços Postgres isolados por porta (int 5433, build 5434, e2e shards 5435/5436) com `DB_HOST` calculado do gateway do bridge Docker (byte order corrigida via `strtonum`). Libs de agente: `agent-github.mjs`/`agent-pool-github.mjs` substituídas por `agent-forgejo.mjs` + `agent-pool-forgejo.mjs` sobre o novo cliente zero-dependência `forgejo-api.mjs` (token `FORGEJO_API_TOKEN`/`GITHUB_TOKEN`, base URL `FORGEJO_API_URL`/`GITHUB_SERVER_URL`/default, shapes normalizados ao contrato gh-flavored; novos `isDraft`/`markPullRequestReady`); helpers `forgejo-dispatch.mjs`, `forgejo-pr-automerge.mjs` (poll de checks + ready de draft), `forgejo-issue-transition.mjs`; auto-merge do PR de planos já validado em produção (PR #4). Bug de port corrigido em `pnpm issue` (await em função sync) e validado ao vivo contra o Forgejo. Testes: `forgejoApi.unit.spec.ts` novo (10 testes), pin do matrix do `ci.yml` movido para `.forgejo/workflows/`; tsc/lint/prettier/knip/check:cycles/2283 unit verdes. Pendente (parte 2): migrar as Issues abertas do GitHub, reescrever `.agents/`, `AGENTS.md`, `docs/AGENT-OPS.md` e `docs/roadmap.md`, e passos humanos (secrets no Forgejo, `pnpm configure:branch-protection`, arquivar o repo GitHub).
|
||||
|
||||
**Recently resolved (2026-08-15):** **IMG-RESIZE** — otimizador local de imagens para o draft do site de campanha (fotos pesadas travavam o PenPot). `pnpm images:resize` (`scripts/resize-images.mjs`) aceita múltiplos paths (arquivos ou pastas, varridas recursivamente), redimensiona para no máximo 3840px — 2× a tela desktop de 1920 — sem upscaling (`fit: 'inside'`, `withoutEnlargement`), aplica EXIF-rotation, remove metadados e converte para **AVIF** por padrão (formato leve moderno; WebP/JPEG via `--format`, qualidade por formato via `--quality`, `--out` para outra pasta — default `public/`). Saída mantém o basename (`foto.jpg` → `foto.avif`) e recusa colidir com a fonte. Derivação pura em `scripts/lib/imageResize.mjs` (parse fail-closed, alias jpg→jpeg, default de qualidade por formato), unit-testada em `tests/unit/imageResize.unit.spec.ts`. Sem migration/access/Consent/UI/schema.
|
||||
|
||||
**Recently resolved (2026-08-15):** **IMG-RESIZE** — otimizador local de imagens para o draft do site de campanha (fotos pesadas travavam o PenPot). `pnpm images:resize` (`scripts/resize-images.mjs`) aceita múltiplos paths (arquivos ou pastas, varridas recursivamente), redimensiona para no máximo 3840px — 2× a tela desktop de 1920 — sem upscaling (`fit: 'inside'`, `withoutEnlargement`), aplica EXIF-rotation, remove metadados e gera **DUAS versões por padrão**: `.avif` (formato leve moderno, para o site) e `.webp` (aceito em mais lugares, incluindo PenPot e navegadores antigos); o conjunto muda via `--format avif,webp` (lista separada por vírgula), qualidade por formato via `--quality`, pasta via `--out` (default `public/`). Saída mantém o basename (`foto.jpg` → `foto.avif` + `foto.webp`) e recusa colidir com a fonte. Derivação pura em `scripts/lib/imageResize.mjs` (parse fail-closed, alias jpg→jpeg, default de qualidade por formato), unit-testada em `tests/unit/imageResize.unit.spec.ts`. Sem migration/access/Consent/UI/schema.
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
Completed 2026-08-01. Branch `stage` and GitHub Environment `stage` are deleted. Production deploys only from `ci.yml` after the full verifier is green.
|
||||
|
||||
- [x] Code PR opened (main-only cutover) — merged to `main`
|
||||
- [x] `pnpm configure:branch-protection` applied on `main` (required: `checks` (+ `migration-lock` until 2026-08-12), `strict=false`, 0 reviews) — **GitHub-era; reaplicado no Forgejo em 2026-08-18 (OPS61) com o contexto real `CI (PR) / checks` (glob `CI (PR) / checks*`)**
|
||||
- [x] `pnpm configure:branch-protection` applied on `main` (required: `checks` (+ `migration-lock` until 2026-08-12), `strict=false`, 0 reviews)
|
||||
- [x] No open PRs targeting `stage` at cutover time
|
||||
- [x] Repo secrets present (`VERCEL_*`, `POOL_GITHUB_TOKEN`)
|
||||
- [x] `VERCEL_ORG_ID` / `VERCEL_PROJECT_ID` synced from local `.vercel/project.json` (`vercel link`)
|
||||
|
|
|
|||
|
|
@ -8,9 +8,9 @@ Three layers, all local-database-only (see `local-database` skill; tests refuse
|
|||
- **Integration** (`tests/int/**/*.int.spec.ts`, 71 specs): real Payload + Postgres (`teqo_test`); campaign fixtures allocate seeded municipalities via a Postgres sequence so parallel spec files never share one, and purge residue on claim. Since the miss #73 guardrail (Pass 4), specs that mutate municipalities must use the allocator (no pinned slugs) — enforced by `testMunicipalityAllocatorConventions`. Shared consent rows with stable keys are leased (`tests/helpers/testDatabaseLease.ts`), never owned.
|
||||
- **E2E** (`tests/e2e`, Playwright, 17 specs / 51 cases (Pass 5, 2026-08-03; was 48 at Pass 4)): smoke-level; most campaign behavior is pinned at the int layer.
|
||||
|
||||
CI (`ci-pr.yml`) is a **single sequential job `checks`** (OPS62 X1): guards → lint (zero warnings) → format → typecheck → knip → cycles → unit/int (**affected by the PR diff**: `vitest --changed` against the PR base via `scripts/test-affected.mjs`, full-suite fallback on high-risk paths) → build → e2e (manifest path→spec via `scripts/e2e-affected.mjs` + `scripts/lib/e2e-affected-manifest.mjs`, blocking; **only in mode `selected` — OPS72: the PR never runs full e2e**; production build + `list`/`github` reporters in CI) — against two isolated Postgres 17 services (int / build+e2e) with **`db:seed:minimal`**. Fail-fast is structural: a failed step stops the run; e2e runs with `PLAYWRIGHT_WORKERS=4`. There is no main verifier (`ci.yml` eliminated in OPS71): the full net including e2e runs in the deploy `verify` job before publishing (manual dispatch).
|
||||
CI (`.github/workflows/ci-pr.yml`) runs parallel jobs — lint (zero warnings), format, typecheck, knip, cycles, unit/int (**affected by the PR diff**: `vitest --changed` against the PR base via `scripts/test-affected.mjs`, full-suite fallback on high-risk paths), e2e (manifest path→spec via `scripts/e2e-affected.mjs` + `scripts/lib/e2e-affected-manifest.mjs`, blocking; production build + `list`/`github` reporters in CI) and build — against a Postgres 17 service with **`db:seed:minimal`**. Push to `main` (`ci.yml`) always runs the full net including e2e.
|
||||
|
||||
**Local e2e (OPS72 — skill step, discretionary):** before push, run the e2e specs you created + the same affected surface — `pnpm test:e2e:affected` runs `e2e-affected.mjs` against `origin/main`, migrates/seeds, then `pnpm test:e2e` with the selected specs (full suite on high-risk diffs). High-risk diffs diverge from PR CI by design: the PR runs no e2e (selected only, never full) and the local mirror runs the full suite. Forward extra Playwright args after `--` for a narrowed repro. Opt into production mode with `E2E_PROD=1 pnpm test:e2e:affected` (CI and prod mode serve the production build via `pnpm start`; plain local runs use `pnpm dev` — dev-mode flakes like cold-compile timeouts and dev-only console warnings do not reproduce in prod mode).
|
||||
**Local e2e before push (same blast radius as PR CI):** `pnpm test:e2e:affected` — runs `e2e-affected.mjs` against `origin/stage`, migrates/seeds, then `pnpm test:e2e` with the selected specs (or the full suite on high-risk diffs). Forward extra Playwright args after `--` for a narrowed repro. Opt into production mode (after fixing the known municipalities-list prod client bug) with `E2E_PROD=1 pnpm test:e2e:affected`.
|
||||
|
||||
**Navigation discipline in e2e (miss #54, 2026-07-30):** `page.goto` is for cold loads and URL contracts (deep links, redirects, legacy params); when the scenario is _continuity inside the authenticated shell_ — especially after a heavy list route — navigate in-shell instead (sidebar links, buttons, form submits), the way the user does. Never fire two `page.goto` back-to-back with nothing settling the first navigation (an `await expect(...)`, `waitForURL`, or interaction): the second aborts the first's in-flight RSC fetch (`net::ERR_ABORTED`, measured in `campaignSavedFilters`). The `e2eNavigationConventions` unit guard fails the build on unsettled goto pairs. Two sibling rules of the same family: wait for **interactive state** before clicking dynamic controls — `toBeVisible` proves SSR paint, not hydration (pre-hydration clicks no-op silently; `checkRadixWhenHydrated` is the precedent, P3-C class) — and gate WebAuthn ceremonies on `expectCampaignBiometricsReady` before the island's one-shot probe runs (miss #53). **Dev-mode caveat (closed 2026-08-10, P4-L/OPS30):** `campaignLeaderships`' chip-cell spec was failing deterministically under `pnpm dev` before B34 rewrote it; with the OPS30 prewarm/login hardening it ran ×3 green in dev + ×1 `E2E_PROD=1` (evidence in TECH-DEBT).
|
||||
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
**Recently resolved (2026-08-16):** **OPS50** (parte 1): CI e scripts de agentes portados do GitHub para o Forgejo (`git.solla.dev`, tracker já migrado nas Issues #1–#3), sem nenhuma dependência do GitHub Actions. Os 10 workflows de `.github/workflows/` viraram 8 nativos em `.forgejo/workflows/` (`ci.yml` com deploy gated + requeue por `forgejo-dispatch.mjs`, `ci-pr.yml` com cascade de jobs e checks agregador, `agent-pool.yml`, `agent-pr-ready-automerge.yml`, `issue-done-on-main-merge.yml`, `plan-issue-ready-on-main-merge.yml`, `vercel-promote.yml`, `archive-cursor-agent.yml`) e `.github/workflows/*` + `.github/actions/` foram removidos (cancel-workflow-on-failure não existe no Forgejo). Novo runner: Forgejo runner v13 na workstation (capacity 4), jobs paralelos em serviços Postgres isolados por porta (int 5433, build 5434, e2e shards 5435/5436) com `DB_HOST` calculado do gateway do bridge Docker (byte order corrigida via `strtonum`). Libs de agente: `agent-github.mjs`/`agent-pool-github.mjs` substituídas por `agent-forgejo.mjs` + `agent-pool-forgejo.mjs` sobre o novo cliente zero-dependência `forgejo-api.mjs` (token `FORGEJO_API_TOKEN`/`GITHUB_TOKEN`, base URL `FORGEJO_API_URL`/`GITHUB_SERVER_URL`/default, shapes normalizados ao contrato gh-flavored; novos `isDraft`/`markPullRequestReady`); helpers `forgejo-dispatch.mjs`, `forgejo-pr-automerge.mjs` (poll de checks + ready de draft), `forgejo-issue-transition.mjs`; auto-merge do PR de planos já validado em produção (PR #4). Bug de port corrigido em `pnpm issue` (await em função sync) e validado ao vivo contra o Forgejo. Testes: `forgejoApi.unit.spec.ts` novo (10 testes), pin do matrix do `ci.yml` movido para `.forgejo/workflows/`; tsc/lint/prettier/knip/check:cycles/2283 unit verdes. Pendente (parte 2): migrar as Issues abertas do GitHub, reescrever `.agents/`, `AGENTS.md`, `docs/AGENT-OPS.md` e `docs/roadmap.md`, e passos humanos (secrets no Forgejo, `pnpm configure:branch-protection`, arquivar o repo GitHub).
|
||||
|
|
@ -1 +0,0 @@
|
|||
**Recently resolved (2026-08-17):** **OPS51** — o banco de produção saiu do Neon para o Postgres do homeserver (`teqo_1313`, container `teqo-1313`), sem mudança de config: o `~/stack/teqo-1313.env` já apontava para o banco preparado (criado 16/08 com role própria + cadeia completa de 56 migrations + zero conteúdo — verificado), então restaurar = a produção "apareceu" no container. **Divergência deliberada da intenção (questão A/B validada como B):** rejeitado criar `teqo_prod` novo — o argumento "o banco do 1313 tem vida própria" não se sustenta (o site 1313 É a produção deste repo) e o OPS53 já trata `teqo_1313` como produção. Execução 100% no homeserver (PII nunca tocou a workstation): URL do Neon puxada do Vercel (`vercel env pull`, projeto `jorgesolla`, `DATABASE_URL_UNPOOLED` → `NEON_DATABASE_URL` em `~/stack/.env` chmod 600), `pg_dump -Fc` (Neon PG 17.10) → `/srv/hdd/backups/teqo-neon-pre-migracao/teqo-neon-full-20260817-204800.dump` (5,8 MB, sha256 `f12196f2…`), `pg_restore --no-owner --no-privileges` como a role `teqo_1313` (drop schema + grant, padrão do `db-pull`), verificado: contagens por tabela idênticas ao Neon (1919 contatos, 39 posts, 1487 assinaturas, 1484 subscrições, 35 campaign_users, 392 lideranças, 2 pledges, 435 municípios, 6 atividades; supporter segue 0 — blocker LGPD ainda de pé), sequences no topo (`last_value == max(id)`), objetos com dono `teqo_1313`, `payload migrate` no-op (56 = 56, serviço de maintenance `teqo-1313-migrate`), 3 credenciais WebAuthn + 34 sessions + 5 chaves Consent restauradas (RP ID não mudou — biometria segue valendo). Smoke sem deploy: `/`, artigo com conteúdo restaurado, `/campanha/login`, `/admin`, `POST /api/revalidate` (`posts` + `global_privacy-policy`), WebAuthn `login-options`, barreira 307 sem sessão — verdes; smoke de browser (login, biometria, convites) validado manualmente. **Rollback documentado:** Neon nunca foi escrito (só lido); voltar = re-apontar `DATABASE_URL` do `teqo-1313.env` para o Neon + `compose up -d`. **Débito registrado (→ OPS52):** as 40 rows de `media` têm URLs relativas (`/api/media/file/…`) sem arquivos em lugar nenhum — capas dão 500 no homeserver e **404 no próprio Vercel/produção atual** (estado pré-existente; migração manteve paridade). Repo: plano da intenção concluído, `AGENTS.md` e help de `db:pull`/`check-push-chain` sem a referência ao Neon; infra-solla `STATE.md` atualizado à parte.
|
||||
|
|
@ -1 +0,0 @@
|
|||
**Recently resolved (2026-08-17):** **OPS53** — o deploy do site 1313 virou etapa do CI: mergir em `main` com a suite verde == site atualizado em `jorgesolla1313.com.br`, sem passo manual. O job `deploy` em `.forgejo/workflows/ci.yml` roda no host do runner (workstation, `runs-on: host`) e streama `scripts/deploy-homeserver.sh` para o homeserver via SSH (`ssh homeserver "bash -s -- <sha>"`); o script remoto (bash puro, homeserver sem dependências novas) faz: guard de HEAD (`git ls-remote` de `main` == SHA do job, senão skip "stale"), `flock` de serialização, workspace `~/teqo-deploy` (clone do Forgejo local), `docker login localhost:5000` (REGISTRY_USER/PASSWORD de `~/stack/.env`), build dos stages `runner`+`migrator` no homeserver (`--network stack_default` para a geração estática alcançar o `postgres:5432` do compose; secrets de `~/stack/teqo-1313.env` via BuildKit, sem camadas/logs), push no registry local, swap dos tags de imagem no compose (com backup `docker-compose.yml.pre-<sha>`), **migrate pelo serviço de maintenance `teqo-1313-migrate` antes do rollout**, `docker compose up -d`, espera do healthcheck e smoke (`/`, `/campanha/login`, `/admin`, barreira 307, WebAuthn `login-options` com body JSON, `POST /api/revalidate` com o secret real — rehearsal validado contra o site vivo). Falha pós-swap → rollback automático best-effort (restore do backup + `up -d` da imagem anterior) + job vermelho. **Divergência deliberada da intenção (fato novo):** a intenção assumia o runner no homeserver; verificado (`infra-solla/STATE.md` + ao vivo) que o runner é a `workstation-runner` na workstation e o stack de produção vive no homeserver — a abordagem A foi corrigida para "job `runs-on: host` na workstation + todo o deploy no homeserver via SSH", o que manteve **zero secrets novos no Forgejo** e zero mudança de infra no compose. Nenhum secret no repo; envs seguem chmod-600 no homeserver. Runbook (rollback manual, falhas conhecidas, decisão de segurança do host label): `docs/ops/teqo-1313-deploy.md` (novo). Guarda estrutural nova: `tests/unit/deployScript.unit.spec.ts` (bash -n + invariantes do pipeline: HEAD guard, BuildKit secrets, ordem migrate→rollout, smoke). AGENTS.md/AGENT-OPS.md atualizados. Primeiro deploy real = o merge deste próprio item (tracer bullet); infra-solla `STATE.md` anotado à parte.
|
||||
|
|
@ -1 +0,0 @@
|
|||
**PUB5** — slogan da campanha "Mais saúde mais futuro" no hero e no rodapé da home (jorgesolla1313.com.br), no lugar de "Um mandato do tamanho da Bahia" (discurso do mandato vigente; o novo é da campanha 2026 — saúde é o tema-âncora do candidato). Troca de copy cirúrgica, sem redesenho nem migration: h1 do hero em duas linhas uppercase "MAIS SAÚDE / MAIS FUTURO" com `aria-label` acompanhando ("MAIS SAÚDE MAIS FUTURO"), segunda linha já em `font-black` (anatomia existente), e o rodapé em sentence case com ponto ("Mais saúde mais futuro."). Testes que pinavam a string antiga atualizados (`campaignHero.unit`, `campaignHome.unit`, `frontend.e2e`). Fora de escopo decidido no gate: cabeçalho de artigos ("O mandato do tamanho da Bahia") e docs históricos. Verificação: unit 2269 + int 791 verdes, e2e frontend 8/8 em prod mode (`E2E_PROD=1`, `.next-e2e`), lint/format/typecheck/cycles/knip/build verdes; render conferido por screenshot + DOM (hero duas linhas, rodapé com ponto). `/simplify` 3 reviewers: sem P0/P1; P2 corrigidos no impl plan (redação + line refs); débitos descartados por serem convenções pré-existentes ou decisões do gate.
|
||||
|
|
@ -1 +0,0 @@
|
|||
**Recently resolved (2026-08-18):** **OPS52-media-guard** — o modo reconcile (escrita) do `pnpm media:recover` ganhou guard de intenção explícita (achado do /simplify da #10): exige `MEDIA_RECOVER_CONFIRM=1` para armar a escrita no bucket — antes, bastava o conjunto completo de `S3_*` (cópia all-or-nothing de credenciais de prod num worktree + rows locais via db:pull → escrevia covers no bucket de prod; o echo do alvo mostrava, não impedia). `--dry-run`/`--verify` seguem sem flag (não escrevem). Sem a flag, reconcile recusa com exit 1 e a mensagem exibe o comando correto. Implementação no dono dos guards: `isTruthyEnv` novo em `scripts/lib/cli.mjs` (semântica exata `true|1` do `ALLOW_REMOTE_DB` — sem trim/case; `isRemoteDbOverrideSet` passou a delegar, sem twin), unit-testado com pin do comportamento preservado (`TRUE`/`yes` continuam recusados). Runbook do homeserver atualizado com a flag; nota no AGENTS.md.
|
||||
|
|
@ -1 +0,0 @@
|
|||
**Recently resolved (2026-08-18):** **OPS52-media** — os objetos de media de produção (as ~40 capas dos posts, que existiam como rows com URL relativa `/api/media/file/<filename>` mas sem arquivo em lugar nenhum) são recuperáveis do WordPress de origem com `pnpm media:recover` (`scripts/recover-media.mjs`, novo): lê as rows de `media` + `post.coverImage` (mapeamento exato row→slug; fallback pelo filename determinístico `<slug>.<ext>`), resolve a capa com a MESMA lógica do seed (featured media do REST `_embed`, senão 1ª imagem inline do corpo — só 7/43 posts têm featured), baixa e faz `PutObject` no bucket das envs `S3_*` com key = `filename` da row, **sem nunca escrever no DB** (rows e URLs intocadas; o staticHandler do Payload passa a servir). Extraído para `scripts/lib/wpArticles.mjs` o fetch ao vivo do WordPress (seed-posts agora importa de lá — sem twin; unit-testado). Fail-closed: exige as 4 `S3_*` completas (`resolveS3StorageEnv` reusado; recuperação nunca vai pro storage local; `--verify` dispensa S3 — só HEAD de URLs públicas), `ALLOW_REMOTE_DB=true` explícito para DB remoto + echo do alvo (DB/bucket/endpoint/origin), timeouts curtos no SDK (`@smithy/node-http-handler`, 5s/15s) e nos fetches (30s/60s) — endpoint inalcançável falha em segundos em vez de pendurar (a rota pública de media de prod pendurava >25s; evidência de possível falta de rota container→Garage, diagnosticada no runbook do impl plan), e covers constrangidas a http(s) no host do WordPress com só `image/*` armazenado (sem SSRF/objeto não-imagem no proxy). Modos: reconcile (padrão, upload idempotente por overwrite), `--dry-run` (plano + `headObject`; exit 1 se qualquer checagem falhar), `--verify` (HEAD da URL pública por filename — o aceite da Issue, sem credencial). Runbook de execução no homeserver: `docs/plans/ops52-media-recuperar-arquivos-impl.md`.
|
||||
|
|
@ -1 +0,0 @@
|
|||
**Recently resolved (2026-08-18):** **OPS52** — a media saiu do Vercel Blob para o Garage S3 do homeserver (bucket `teqo-media` + key `teqo-key`, já existentes no Garage v2.1.0). O plugin `@payloadcms/storage-vercel-blob` (e o `@vercel/blob` do seed) foi trocado por `@payloadcms/storage-s3` na config, no padrão R2 da doc do Payload (`endpoint` + `forcePathStyle: true`, region `garage`). **Decisão de arquitetura (revisão A→B durante a execução, gate com o humano):** o Garage v2.1.0 **não suporta acesso anônimo na API S3** (sem `--public-read`; source: "when we support anonymous access in the future"; testado: `anonymous`/`anon` → `NoSuchAccessKey`), então o bucket fica **privado** e a app **serve a media por proxy** — sem `disablePayloadAccessControl`, o `staticHandler` do plugin streama o objeto do Garage em `/api/media/file/<filename>` (range + etag/304 nativos) — o **mesmo contrato de URL relativa que as 40 rows pré-existentes já têm** (a recuperação dos arquivos é a Issue #10/OPS52-media). **Fail-closed:** `resolveS3StorageEnv` (`src/utilities/mediaStorage.ts`, unit-testado) — envs `S3_*` parciais → throw no boot com a lista exata de faltantes; nenhuma → storage local (dev/test). Envs novas: `S3_BUCKET`, `S3_ENDPOINT`, `S3_REGION` (default `garage`), `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY` (`.env.example`, `.env.test`, worktree.mjs com cópia all-or-nothing). Seed de posts: pre-delete do Blob removido (o S3 sobrescreve o key determinístico nativamente — idempotência intacta). Prova de contrato real: PUT/GET/DELETE via SDK contra o Garage (tailnet `http://100.119.220.31:3900`), depois media criada pelo Payload local com S3 ativo → `media.url` relativa + `GET /api/media/file/...` → 200 image/png; bucket devolvido a 0 objects e `.env.local` do worktree limpo (sem credenciais de prod). Sem migration (config-only; importMap regenerado sem `VercelBlobClientUploadHandler`). Runbook de cutover registrado no impl plan: setar as 5 `S3_*` no `~/stack/teqo-1313.env` (endpoint tailnet) antes do próximo deploy (OPS53). Repo: `docs/plans/ops52-blob-vercel-para-garage-impl.md`; infra-solla `STATE.md` atualizado à parte.
|
||||
|
|
@ -1 +0,0 @@
|
|||
**Recently resolved (2026-08-18):** **OPS54** — fim do warning do pnpm sobre o campo `pnpm` ignorado em `package.json`. A allowlist `onlyBuiltDependencies` (`sharp`, `esbuild`, `unrs-resolver`) migrou para o home canônico do pnpm 10: novo `pnpm-workspace.yaml` na raiz (sem `packages` — repo single-package, default `['.']`), e o bloco `"pnpm"` saiu do `package.json` (pnpm 10 não o lê mais; sem a remoção o warning persiste). `Dockerfile` estágio `deps` agora copia `pnpm-workspace.yaml` junto (`COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./`) — sem isso a allowlist não chegaria ao install da imagem e local×Docker divergiriam nos build scripts. Validado com a versão pinada (10.11.0) e no estágio deps real (`docker build --target deps`): nenhum `[WARN]` do campo `pnpm` em install/CI/Docker, `pnpm config get onlyBuiltDependencies` → os 3 pacotes, `--frozen-lockfile` limpo (lockfile e grafo intocados). Guardrails respeitados: `.npmrc` (`legacy-peer-deps`, `include`) e `packageManager` intocados; nada além do que o warning citava foi migrado (o aviso informativo de `msw`/`onnxruntime-node` é comportamento padrão do pnpm 10 — fora de escopo por anti-goal). Sem migration, sem UI, sem testes de domínio (config de ferramenta; gates completos verdes). Repo: `docs/plans/ops54-pnpm-warning-onlybuiltdependencies-impl.md`.
|
||||
|
|
@ -1 +0,0 @@
|
|||
**Recently resolved (2026-08-18):** **OPS56**: o `forgejo-pr-automerge` nunca mais reporta falso positivo "já mergeado" com PR aberto. Raiz confirmada no source do servidor (Forgejo 9.0.3, tag v9.0.3): o endpoint `POST /pulls/{n}/merge` responde **200 com corpo vazio** no sucesso (falhas são 404/405/409/500), e o `request()` do `forgejo-api.mjs` convertia isso em `null` — o `autoMerge()` devolvia `null` cru e o CLI imprimia "já mergeado" (exit 0) independente do estado real do PR; o incidente (PR #27 da OPS52, aberto das 01:59 às 02:45) deixou o merge falhar silenciosamente com o PR aberto. Correção: o veredito de `autoMerge` agora é **re-read do PR** após a tentativa (`{ attempted, merged, pr }`) — 200-vazio → confirma no `GET /pulls/{n}`; POST falhou e o PR segue aberto → **re-throw** (falha ruidosa, exit 1); raça perdida (outro ator mergeou) → "já mergeado" só com o PR realmente mergeado; PR fechado sem merge → "fechado sem merge — skip" honesto. `waitForChecks` passou a esperar o `mergeable` amadurecer (false → throw imediato de conflito; null → segue no poll), mesmo timeout de 30 min. CLI sem shadowing de `pr` (const redeclarada). Testes: 6 specs novas em `forgejoApi.unit.spec.ts` (veredito por re-read, POST 405 + PR aberto → reject, raça concorrente, no-op de já-mergeado, poll de mergeable null, fail-fast de mergeable false). Gates: 2318 unit + 791 int + tsc/lint/prettier/knip/check:cycles verdes. Débito deferido: alargar o gatilho do `agent-pr-ready-automerge.yml` para branches não-`cursor/*` (decisão de ops separada).
|
||||
|
|
@ -1 +0,0 @@
|
|||
**Recently resolved (2026-08-18):** **OPS57** — o safety net do `forgejo-pr-automerge` agora cobre **todas as PRs same-repo para `main`**, não só `cursor/*`. Motivação (débito deferido na OPS56): dos 25 PRs mergeados no repo, **zero** eram `cursor/*` — 9 de `plans/*`, 11 de worktrees humanos, 5 de `refs/pull/N/head` (era da migração) — o filtro antigo nunca cobriu nenhum PR real, e o incidente OPS52 (PR #27, humano, merge falhou silenciosamente) era exatamente a classe excluída. Mudanças: `if:` do job em `agent-pr-ready-automerge.yml` → `state == 'open' && head.repo.full_name == github.repository` (guarda de fork — o repo é público e uma PR de fork mergeada dispararia o deploy de prod; campos já usados no payload, sem dependência de `is_draft` do webhook); política de draft movida para o CLI `forgejo-pr-automerge.mjs` com o head ref do **re-read da API** — draft `cursor/*` → Ready (pool inalterado); draft não-`cursor/*` → skip exit 0 (**draft = veto do ator, PR humana nunca é forçada**); PRs ready não-`cursor/*` (humanas/plans) passam a mergear com CI verde — redundante no caminho feliz (o fluxo humano já arma `--auto --rebase`) e é o que pega a classe OPS52; `ready_for_review` em PR draft re-dispara o job e mergea. Docs sincronizadas (`docs/AGENT-OPS.md` tabela CI por alvo, `.agents/rules/agent-pr-workflow.mdc` seção Safety net). Rejeitado: `!is_draft` no YAML (campo do payload não verificado, falha fail-open) e allowlist de prefixos (códigos humanos sem prefixo comum — OPS/S1/PUB5). A própria PR desta entrega ainda usa o workflow de `main` (antigo); a próxima PR exercita o gatilho alargado. Sem migration/access/Consent/UI. Testes: spec nova em `forgejoApi.unit.spec.ts` (waitForChecks para quando o PR vira draft no meio do wait — veto garantido mesmo em conversão mid-run; autoMerge idempotente); gates unit/int verdes. **Validação ao vivo na própria entrega:** o Forgejo roda o workflow do head da PR (não o de main), então a PR #48 foi mergeada pelo safety net alargado no primeiro uso real — o `if:` com `head.repo.full_name` avaliou true em PR real e o script mergeou por rebase. Fixes pós-review do /simplify: `waitForChecks`/`autoMerge` param em PR draft (antes: 405 ruidoso na conversão mid-run, contradizendo o "veto nunca tocado"); CLI valida `base.ref === 'main'` (invocação manual); comentário do fork guard documenta por que ele não pode descer pro CLI (checkout de fork roda o script do fork = RCE com o token do Actions); docs sem o mecanismo fantasma `gh pr` (era GitHub, OPS50 trocou por CLI plain-Node).
|
||||
|
|
@ -1 +0,0 @@
|
|||
**Recently resolved (2026-08-18):** **OPS58** — o sync de posts de produção ficou pronto para executar com runbook documentado: `scripts/seed-posts.mjs` ganhou o modo **`--dry-run`** (plan-only: fetch ao vivo + echo do alvo + relatório do que SERIA criado/saltado — zero escrita, pre-flight padrão OPS52-media antes de tocar o DB de prod; argumento desconhecido morre) e a **taxonomy `CLASSIFICATION` foi atualizada** com os 4 slugs novos do jorgesolla.com.br (43 posts no WP vs 39 mapeados): os 3 de campanha/pré-campanha (convenção estadual do PT, plenária "PT tem que disputar o programa de governo", caravana da Chapada) entraram com `tags: ['eleitoral']` para ficarem sob o controle de visibilidade eleitoral do repo (`isPostVisible` fail-closed — critério do tag documentado no seed), e o embate com Bruno Reis em `politica`. A execução em prod (pós-merge, homeserver, env do stack, `ALLOW_REMOTE_DB=true`) segue o runbook: dry-run → sync (create-only, idempotente por slug) → bust do cache `posts` via `/api/revalidate` → verify (`media:recover --verify` para capas + curl nas páginas dos novos). Runbook para as próximas sincronizações: `docs/plans/ops58-sincronizar-posts-prod-impl.md`.
|
||||
|
|
@ -1 +0,0 @@
|
|||
**Recently resolved (2026-08-18):** **OPS59** — o pre-push local não roda mais e2e: ele roda **uma vez, no CI** (PR e main — gateia merge e deploy). O espelho `scripts/gate-ci.mjs` cortou o bloco e2e da cascata (playwright install, build `.next-e2e`, `test:e2e`) e o preflight de DB passou a depender só de test/build; o readout do classificador mantém `e2e=<mode>` e imprime o que o CI rodará ("e2e runs in CI, not in this gate — OPS59"), com o check `unmapped` duplicado removido (`ci-scope.mjs` já o imprime no stderr). `package.json`, `.husky/pre-push`, `git-push.mjs` e os workflows `.forgejo/*` intocados (CI verificado, não alterado: `ci-pr.yml` e2e → `checks`; `ci.yml` e2e → `checks` → `deploy`). `tests/unit/ciSkipInvariants.unit.spec.ts` tirou `gate-ci.mjs` da lista de arquivos que devem conter `.next-e2e` (o gate local não builda mais e2e; `run-e2e-affected.mjs`/`playwright.config.ts` seguem cobertos — e2e local opcional intacto). Docs sincronizadas: `AGENTS.md` (checklist local item 3 — `test:e2e`/`test:all` saem da obrigatória; "e2e = CI, local opcional `pnpm test:e2e:affected`"), `.agents/rules/engineering-standards.mdc` e `agent-pr-workflow.mdc` (gate descrito como "espelho do ci-pr.yml sem e2e"). Verificado por grep: `AGENT-OPS.md` e skills (`work-issue`, `agent-work-issue`, `worktree-next-issue`, `local-database`) não citavam e2e no pre-push — sem mudança. Anti-goals respeitados: sem flag de "pular e2e" (bypass continua só via `--no-verify` documentado), política "Dono do PR, dono do CI" inalterada. Sem migration/access/Consent/UI. Verificação: `pnpm gate:ci` local (cascata nova full — lint/format/typecheck/knip/cycles/unit/int/build em `teqo_test`; e2e vira print), unit 2286+ verdes; o e2e desta PR roda full no CI por HIGH_RISK (`gate-ci.mjs`).
|
||||
|
|
@ -1 +0,0 @@
|
|||
**Recently resolved (2026-08-18):** **OPS60** — o sync do `pnpm db:seed:posts` ganhou guard de intenção explícita para escrita no bucket (achado do /simplify da #37): com as envs `S3_*` ativas, o modo sync exige `SEED_MEDIA_CONFIRM=1` (mesmo padrão `isTruthyEnv` do `MEDIA_RECOVER_CONFIRM` da OPS52-media-guard) e recusa com exit 1 antes de qualquer escrita sem a flag — o risco fechado: um worktree com cópia de credenciais `S3_*` de prod (all-or-nothing do `worktree-env.mjs`) rodando sync contra DB local sobrescrevia covers no bucket de **prod** (o seed é idempotente por slug e sobrescreve o key determinístico). Storage local (sem `S3_*` → disk) e `--dry-run` seguem sem flag (não escrevem); echo do alvo ganhou `Bucket`/`Endpoint` quando S3 ativo (paridade com o `media:recover`). Implementação no dono: `isTruthyEnv` (já existente da #37) + `resolveS3StorageEnv` — sem twin. Runbook do OPS58 atualizado com o comando real; nota no AGENTS.md.
|
||||
|
|
@ -1 +0,0 @@
|
|||
**Recently resolved (2026-08-18):** **OPS61** — contratos de merge/flip do Forgejo consertados. **(S1)** Os workflows pós-merge (`issue-done-on-main-merge.yml`, `plan-issue-ready-on-main-merge.yml`) rodavam com o `GITHUB_TOKEN` nativo, que 403 em escrita de Issues — o flip `Closes #N → done+in-prod` (PRs #42/#48) quebrava em silêncio (o script engolia o 403 e o job terminava success; flip manual necessário). Agora injetam `FORGEJO_API_TOKEN` (o PAT que o pool já usa para o mesmo tipo de escrita) via `env:` no job, e o `forgejo-issue-transition.mjs` sai **exit 1** se qualquer flip falhar — o job vermelha em vez de mentir. **Fork guard** nos dois workflows (`head.repo.full_name == github.repository`, a mesma classe do `agent-pr-ready-automerge`): PR de fork mergeada faria checkout do script do fork rodando com o PAT do repo (RCE). **(S3)** A branch protection de `main` **não existia no servidor** (0 regras no `GET /branch_protections` — o `pnpm configure:branch-protection` era passo humano pendente do OPS50 e nunca rodou; as docs diziam "aplicado" e mentiam), e o valor `checks` do script **nunca casaria** os contextos reais do Forgejo (casamento por glob contra `CI (PR) / checks (pull_request)` — o sufixo ` (evento)` faz parte do contexto; literal `checks` = todo merge bloqueado). Script corrigido: contexto `CI (PR) / checks*`, idempotente (GET → POST/PATCH/no-op, verifica após aplicar, dry-run honesto) com decisão pura em `scripts/lib/branch-protection.mjs` + spec nova. **(2b — achado ao vivo na própria entrega, PR #67)** O safety net tinha **dois** bloqueios independentes. (1) O gate antigo do `waitForChecks` (`pending.length === 0`) era **insatisfazível**: o Forgejo posta o status do próprio job do safety net como `pending` durante o run inteiro. (2) O merge POST faz um push do branch rebaseado para `main` e o hook pre-receive do Forgejo rejeita esse push quando o usuário do token não tem write no repo — o `GITHUB_TOKEN` nativo age como o usuário sistema do Actions (o mesmo que 403 em Issues), então o merge do CLI morria com 409 "User permission denied for writing" (confirmado na fonte 9.0.3: o hook cai no `AssertCanWriteCode` antes do caminho de merge). Resultado: o CLI **nunca auto-mergeou nenhuma PR** — todo merge Forgejo até aqui foi manual (`merged_by` do dono), inclusive o da PR #52 com o CI vermelho (a narrativa da "raça do snapshot" da intenção estava errada: o merge vermelho foi manual; e runs que "exitaram 0" eram leituras de PR já mergeada). Gate novo: espera o rollup `CI (PR) / checks` presente+verde (único contexto que só existe quando a cascata inteira do ci-pr assentou) e **ignora o próprio contexto** do safety net; `mergeable === false` só conta como conflito após a cascata assentar; timeouts sobem (CLI 30→45 min, workflow 40→55 min); e o workflow do safety net agora injeta `FORGEJO_API_TOKEN` (mesmo PAT dos pós-merge) — o merge com o PAT (admin) passa no hook. A regra no servidor é o gate real para todo mundo: o merge POST é bloqueado com 405 enquanto o contexto exigido não estiver verde — inclusive merges manuais via API (a PR #52 não teria entrado vermelha). **Validação ao vivo na própria entrega:** a regra foi criada e verificada no servidor (re-run = no-op), e a PR #67 (`Closes #53`) mergeou pelo novo gate do rollup com a regra aplicada; o `issue-done` flipou a #53 para `done`+`in-prod` com o PAT. Sem migration/access/Consent/UI. Testes: `forgejoApi.unit.spec.ts` (rollup não-posto continua poll, rollup failure lança, próprio status do safety net não bloqueia, mergeable pré-settle — fixtures dos waitForChecks/autoMerge existentes atualizados para o contexto real), `branchProtection.unit.spec.ts` nova (create/update/noop, glob casa o contexto observado).
|
||||
|
|
@ -1 +0,0 @@
|
|||
**Recently resolved (2026-08-18):** **OPS62** — fail-fast estrutural do CI: `ci-pr.yml` e `ci.yml` deixaram de ter jobs irmãos. Cada workflow agora tem **um único job `checks` sequencial** (ci-pr: guards de conteúdo → lint → format → typecheck → knip → cycles → unit → int → build → e2e, com skips do `ci-scope.mjs` agora como step com outputs; ci.yml: mesma mecânica full + deploy OPS53 intacto), então **step vermelho interrompe o job, o run falha e o slot do runner é liberado imediatamente** — sem API de cancel (o Forgejo 9.0.3 não tem), sem watchdog, sem upgrade do Forgejo no homeserver (decisão X1 do gate de execução substitui o watchdog+upgrade da intenção; o aceite "API de cancel/upgrade" e "mesmos jobs, mesma cascata" foi revisado pelo humano — o que o CI _verifica_ permanece idêntico, só o envelope muda). Diagnóstico preservado: steps têm logs próprios. O job id `checks` mantém o contexto `CI (PR) / checks` que o automerger/`waitForChecks` e a branch protection esperam (OPS61). Paralelismo do e2e recuperado por `PLAYWRIGHT_WORKERS=4` num único processo (substitui os 2 shards × 2 workers; mesma carga); **2 services Postgres por nome na rede do job** (`postgres-int`/`postgres-build`, **sem publish host** — o job único seguraria portas fixas pelo run inteiro e runs concorrentes colidiriam; o Forgejo 9 não expande expressões em `services.ports`, medido ao vivo no run 730 falhando em 1s com a string literal; o DNS da rede do job mata a colisão de raiz — os guards fail-closed `LOCAL_HOSTS`/`assertTestDatabase` admitem os nomes dos services, ainda rejeitando hosts remotos) preservam a topologia de hoje (e2e nunca herda resíduo de fixtures do int); 1 checkout + 1 `pnpm install` por run (antes ~10). Plumbing morto removido: `e2eShardConfig` e o output `e2e_shards` do classifier (sem consumidor). Guards de conteúdo movidos para o início do pipeline (falham rápido e são PR-scoped). `/simplify` (3 reviewers): P1 aplicados — `set -euo pipefail` no step `scope` (crash do classifier com outputs vazios silenciaria skips → false green) e services por nome (acima); P2 aplicados — pins de invariantes ancorados em chaves YAML (comentários não satisfazem `needs: [checks]`/`matrix:`/`fail-fast:`), pin do wiring crítico `id: scope` + `if: e2e_mode == 'full'` (perdê-lo pula a suíte em silêncio), comentário morto do sharding em `playwright.config.ts` e `docs/TESTING.md` corrigidos (ainda citavam `.github/workflows` e jobs paralelos); P3 — JSDoc do `ci-scope.mjs` e redação do impl plan; o P2 do `waitForChecks` (timeout 30 min vs job 50 min) fica como débito de CI anotado no impl. Docs: tabela CI do AGENT-OPS reescrita + linha morta do `styfle/cancel-workflow-action` (removida no OPS50) apagada. Sem migration/access/Consent/UI. Testes: invariantes novos em `ciSkipInvariants.unit.spec.ts` (um único job `checks`, sem `matrix:`/`fail-fast:`/`runs-on: host` no ci-pr, `PLAYWRIGHT_WORKERS: 4`, guards antes do lint, deploy gated em `needs: [checks]`, services por nome sem `ports:`); `localHosts.unit.spec.ts` com os nomes dos services; `testAffected.unit.spec.ts` sem o describe do shard config. **Validação ao vivo na própria entrega:** o ci-pr deste PR já é o envelope novo — o primeiro run (730) pegou a expressão de porta não suportada (achado → corrigido para DNS de service), o run verde prova a suíte inteira no job único; a medição do tempo de parede fica registrada no run. Custo aceito (X1): run verde ~21 min esperados (vs ~15–18 com jobs paralelos) e e2e vermelho é o caso mais lento de veredito; débito registrado no impl plan (matrix fail-fast Y) se a fila do runner apertar. Impl: `docs/plans/ops62-ci-fail-fast-forgejo-impl.md` (a intenção fica como histórico do gate).
|
||||
|
|
@ -1 +0,0 @@
|
|||
**Recently resolved (2026-08-18):** **OPS63** — os `docs-guards` (OPS44) rodam também no pre-push local: o espelho `scripts/gate-ci.mjs` ganhou os três checks na fase 1 (changelog append-only, agregado sync `--check`, marcadores de conflito em markdown) como chamadas diretas dos mesmos scripts do CI — labels espelhando os steps do job `docs-guards`; header do arquivo e linha final atualizados. `package.json`, `.husky/pre-push`, `git-push.mjs` e workflows `.forgejo/*` intocados: `gate:push` = `gate:ci` continua a cadeia única, o job `docs-guards` segue dono do escape `changelog-rewrite:` (body do PR — CI-only; restauração legítima D8 falha local com a mesma mensagem do CI e usa o bypass documentado `git push --no-verify`, com o CI como via do escape). Docs sincronizadas: `.agents/rules/engineering-standards.mdc` e `agent-pr-workflow.mdc` (gate descrito como "espelho do ci-pr.yml sem e2e, com docs-guards locais"), `docs/AGENT-OPS.md` (contrato item 4 — OPS44: checks no pre-push + nota do escape CI-only). Verificado por grep: `AGENTS.md` e skills não descreviam o gate sem os checks de docs — sem mudança. Anti-goals respeitados: sem bypass novo (falha local honesta), sem `plans-only-closes` local (contrato de PR body). Sem migration/access/Consent/UI/schema. Verificação: três checks green na branch; caminho de falha validado (commit descartável removendo linha do agregado → append-only falha com mensagem acionável); `pnpm gate:ci` local full; CI roda full nesta PR por HIGH_RISK (`gate-ci.mjs`).
|
||||
|
|
@ -1 +0,0 @@
|
|||
**Recently resolved (2026-08-18):** **OPS64** — o safety net `ready-automerge` deixa de se auto-bloquear e o gate do merge fica estruturalmente honesto. O sintoma primário da Issue (deadlock: o `waitForChecks` contava o próprio status `pending` do job → timeout 30 min → merge nunca acontecia) **já tinha sido entregue pelo OPS61** (filtro do próprio contexto + gate do rollup + PAT; provado vivo: runs 710/712 mergearam os PRs #67/#70). O secundário — o rollup `checks` reportava SUCCESS com um shard de e2e vermelho porque o Forgejo 9.0.3 reporta o resultado do **último** shard como o resultado do parent de matrix no contexto `needs` (runs 604/607/610: e2e (1) vermelho + e2e (2) verde → `checks` verde; o `needs.<job>.results` do GitHub não existe neste runner) — **foi eliminado estruturalmente pelo OPS62** (mergeado em main durante a execução deste item): o ci-pr.yml e o ci.yml viraram **jobs únicos sequenciais** (sem matrix, sem shards), então o status do job `checks` já é o veredito honesto da cascata inteira. Este item entregou o que restava de valor: (a) **teste-pino no `waitForChecks`** (`forgejoApi.unit.spec.ts`, cenário run 607) — o CLI **nunca confia no rollup sozinho**: varre os statuses individuais e lança "Checks falharam" com rollup verde + shard vermelho (invariante de defesa que continua válido e pegaria qualquer matrix que volte); (b) **docs honestas** (`agent-pr-workflow.mdc`, `AGENT-OPS.md`) registrando o veredito do rollup, o pin do CLI e a eliminação estrutural do bug pelo OPS62. O script `ci-rollup-check.mjs` e os edits de workflow planejados foram **descartados na execução** (redundantes pós-OPS62 — decisão do gate com o humano; os workflows em main são os do OPS62, intocados). Sem migration/access/Consent/UI/schema. Verificação: unit 2360 verdes; lint/format/typecheck/knip/cycles no gate local; a própria PR prova o ciclo (o `ready-automerge` mergea no fim). Impl plan: `docs/plans/ops64-forgejo-pr-automerge-waitforchecks-impl.md`.
|
||||
|
|
@ -1 +0,0 @@
|
|||
**Recently resolved (2026-08-18):** **OPS65** — CI do `main` em **janela fixa de 30 min** + **agent-pool morto**. O `ci.yml` não dispara mais a cada push: roda por `schedule */30` + `workflow_dispatch`, e um gate barato (`gate`, runs-on `host`, main-only, sem pnpm — `scripts/ci-classify-production.mjs` + matcher puro `scripts/lib/dockerignore.mjs`) lê a **revision do container rodando** no homeserver (label `org.opencontainers.image.revision` — o compose pode mentir após rollback falho), diffa contra o HEAD de `main` e classifica "mudança de produção" usando o `.dockerignore` como fonte da verdade (o que não entra no build context não justifica build novo; `docs/`/`.agents`/`.env*` viram skip; `scripts/`/`tests`/`.forgejo`/`AGENTS.md` contam como produção — conservador, qualquer dúvida roda a suite). Deployed == HEAD → suite e deploy skipped, run verde (o rollup `checks` já aceita skipped); qualquer outra coisa → suite full + deploy na mesma janela; dispatch sempre roda tudo; o `deploy-homeserver.sh` ganhou guard de idempotência "already deployed" (revision do container já no SHA → skip verde). Fail-open: revision desconhecida → suite roda (main sempre verificada); gate incapaz de classificar → suite roda e o run fica **vermelho** (nunca blackout silencioso); deploy duplo-gated (`needs: [checks, gate]` + `if` explícito — nunca deploy às cegas nem com suite vermelha). `agent-pool.yml` removido (o tick `*/10` custava 144 `pnpm install`/dia na workstation; scripts/skills do pool ficam dormentes). Testes: `dockerignoreMatch` + `ciClassifyProduction` unit novos (matcher contra o `.dockerignore` real, fail-open, dispatch, caminho git com repo scratch, exit codes), pins do `ciSkipInvariants` (janela 30, sem `push`, ref guard, ifs fail-open) e do `deployScript` (guard), `HIGH_RISK_EXACT` += classificador. Docs: runbook `docs/ops/teqo-1313-deploy.md` (janela/gate/"already deployed"/falhas), `AGENTS.md`, `docs/AGENT-OPS.md`.
|
||||
|
|
@ -1 +0,0 @@
|
|||
**S1** — seção "Acompanhe de perto" na home de campanha (jorgesolla1313.com.br), na 3ª posição entre a prova social e "Por que essa eleição importa": bento de 5 artigos do `post` collection (1 card grande + 4 pequenos) no desktop e carrossel de 1 conteúdo por tela com dots e contador "N de M" no mobile (~390px), badge de tipo (Notícia/Campanha/Artigo/Evento), capa, título e meta "há X · categoria" (relativa < 48h, data absoluta além — nunca mente congelada na ISR); card inteiro é link para a rota canônica do artigo. Fail-closed: sem posts visíveis (`isPostVisible` — tag `hidden` eleitoral nunca vaza) a seção some inteira e Prova → Problema voltam a ser contíguos. Zero schema/migration/Consent; leitura via `getVisiblePosts` sob a tag `posts` (home estática revalidada pelo mecanismo `posts` existente). Primeira fatia do board aprovado (S2/S3 = YouTube/Instagram). Verificação: unit 2282 + int 791 verdes, e2e frontend 10/10 em dev e prod (`E2E_PROD=1`, `.next-e2e`), suíte completa prod 169-170 passed com flakes ambientais de campanha (máquina compartilhada; testes diferentes a cada run, nenhum no código tocado); criação via REST com hooks reais; polling positivo do estado da home pós-revalidate (ISR serve stale uma vez sob carga) e navegação para about:blank antes do cleanup (prefetch de links apagados); lint/format/typecheck/cycles/knip/build verdes. `/simplify` 3 reviewers: 2 P1 de a11y (foco+setas no track, `inert` em slides inativos) corrigidos; o `revalidate = 300` proposto contra o congelamento da label relativa foi TESTADO e REVERTIDO — no Next 15.4 em prod ele trava o bust por tag (`revalidateTag('posts')` deixava de regenerar a home; medido em probe manual) — a label relativa ganhou threshold de 48h com fallback absoluto; P2 absorvidos (dots ≥3:1, aria-live no contador, motion-reduce na capa, fidelidade de gaps, secret do e2e espelhado da env); débitos deferidos documentados no impl plan (refusal do `formatRelativeAge` por contrato, gutter flow-centered, link `/artigos` sancionado pela intenção).
|
||||
|
|
@ -1 +0,0 @@
|
|||
**S2** — seção "Acompanhe de perto" da home de campanha (jorgesolla1313.com.br) passa a incluir os vídeos do canal oficial do YouTube (`@JorgeSollaDep`), estendendo o board da S1. Fonte automática servidor-side: global `SocialFeedSettings` nova (grupo `Configurações`, read/update admin-only — a chave da API nunca vaza pelo REST), com kill switch `enabled` ("Pausar feed"), toggle `youtubeEnabled`, chave + channel ID, `youtubeMaxItems` (1–5, default 3) e `excludedItems` (platform+id+motivo opcional — mesmo mecanismo/lugar da S3), migration `20260818_111748_add_social_feed_settings` não destrutiva (editada para conter só o DDL — o `migrate:create` regenerava drift de migrations hand-written sem sidecar JSON de 20260811). Loader `src/utilities/youtubeFeed.ts` (irmão de `posts.ts`, pinado no spec de convenções): parsers/format puros (`formatYouTubeViews` pt-BR "12,4 mil"/"1,2 mi", thumbnails maxres??high??medium), fetch Data API v3 em dois passos (search → statistics, `fetchImpl`/`YOUTUBE_API_BASE_URL` injetáveis), `unstable_cache` tag `social-feed` + `revalidate: 300` (TTL da entrada, não da página — o bug do S1 era page-level); **fail-closed com snapshot persistido por SQL direto** (`payload.db.drizzle`, sem hooks — `revalidateTag` dentro de `unstable_cache` LANÇA no Next 15.4, verificado no source; o snapshot cru pré-exclusão sobrevive a edições de settings por merge do update), exclusões aplicadas na leitura. Card generalizado `CampaignArticleCard` → `CampaignContentCard` (git mv; `external?: boolean` → `<a target="_blank" rel="noopener noreferrer">`); a seção mescla artigos visíveis + vídeos elegíveis por recência (grande = mais recente — aceite S2; degrade para o comportamento S1 sem config), meta "há X · N visualizações" (relativa < 48h, absoluta além), link "YouTube →" derivado do channelId; carrossel mobile renomeado para "Conteúdos recentes". next.config ganha `i.ytimg.com` + host local do stub no remotePatterns. Testes: 15 units novas (formatters, parsers, eligibility, fetch com fetchImpl fake); e2e com stub HTTP node (`tests/e2e/youtube-stub.mjs`, webServer do Playwright vira array, porta = dev+1000; `POST /__stub/state` alterna ok/fail; thumbnails servidas localmente) — 4 testes novos no describe serial da seção (full-state com exclusão + link externo + carrossel, fail-closed sem snapshot, snapshot persistente com maxItems mudando na API down, kill switch) + reset de settings no beforeAll; conventions specs atualizados (pin do youtubeFeed, settle após about:blank). Verificação: unit 2306 + int 791 verdes; e2e frontend 14/14 dev e 6/6 prod-mode (`E2E_PROD=1` — valida TTL+tags+snapshot no build real); lint/format/tsc/knip/cycles/build verdes. `/simplify` 3 reviewers (sem P0): P1 corrigido — label do kill switch invertido ("Pausar feed" marcado = ativo → "Feed ativo"); P2 absorvidos (className do link do header extraída, `Promise.all` posts+feed no cache frio, comentário do sentinela 0 do merge, helper único `tests/helpers/youtubeStub.ts` para a porta do stub, `waitForHomeSectionState` fundido no `waitForHomeHTML` com orçamento 12s do S1, precondição do persist documentada); débito REGISTRADO (#40, prio:P2, depends #18): o `validate` do `itemId` lê `data.platform` que é sempre undefined (Payload passa o doc inteiro; verificado no source) — produção sem validação de ID de vídeo, e o e2e depende dessa mortandade (ids do stub de 20 chars); correção em dupla documentada na issue. Descartados com justificativa: ramo maxres do search (pinned por teste), tag `social-feed` fora da convenção `global_*` (precedente election-tse/municipality-catalog), remotePattern `localhost/thumbs` incondicional (decisão deliberada dos 3 modos e2e), persist não-atômico (comentado); achados visuais do design-vision mapeados para a Issue #33 (separação visual branco-sobre-branco, fora de escopo por decisão do gate).
|
||||
|
|
@ -1 +0,0 @@
|
|||
**S3** — seção "Acompanhe de perto" da home de campanha (jorgesolla1313.com.br) passa a incluir os posts do Instagram oficial, completando o board do plano-site §4.2 (artigos S1 + YouTube S2 + Instagram). Fonte automática servidor-side: loader irmão `src/utilities/socialFeed/instagramFeed.ts` (os feeds externos ganharam o subfolder `socialFeed/` — o pin da S2 predizia exatamente isso quando a segunda plataforma entrasse; `git mv` do youtubeFeed + imports atualizados): Graph API (`GET /{ig-user-id}/media` com `children{media_url,thumbnail_url}` p/ carrossel + `GET /{ig-user-id}?fields=username`), `unstable_cache` tag `social-feed` + `revalidate: 300`, snapshot cru (`{ username, posts }`, pré-exclusão) persistido via SQL direto (padrão anti-`revalidateTag`-no-cache da S2), exclusões aplicadas na leitura, **refresh de token automático** (refresh-on-failure + retry 1x, token renovado persistido por SQL direto; nota honesta no admin: o refresh endpoint só renova tokens de Instagram Login — page tokens business caem no fail-closed + rotação manual). Global `SocialFeedSettings` ganha `instagramEnabled/AccessToken/UserId/MaxItems` + `instagramFeedSnapshot` (hidden) — `excludedItems` já cobria `platform: 'instagram'` desde a S2, sem migration no mecanismo; migration `20260818_192948_add_instagram_social_feed_fields` não destrutiva (DDL revisado, sem o drift do `migrate:create` da S2). **Picker de exclusão no admin** (requisito nº 1 da intenção — posts de grade): primeiro componente custom do repo (`src/components/admin/InstagramPostExclusionPicker.tsx`, campo `ui` na global, path string + importmap regenerado — o import direto de `@payloadcms/ui` na config quebra o `generate:importmap` no tsx por causa do `.css` do react-image-crop; com path string o scanner resolve sem carregar o módulo); lista os posts do snapshot (thumbnail + legenda + data + ID) com toggle "Não exibir"/"Reexibir" + motivo opcional que escreve em `excludedItems` via `useField`. UI: `CampaignContentCardData.coverAspect` (`'video'` default — S1/S2 intactos; `'square'` 1:1 do rascunho aprovado para IG), badge "Instagram", título = legenda (fallback "Publicação no Instagram" para post sem legenda — entra no automático para exclusão manual), meta = data relativa, clique no `permalink` (nova aba, `noopener`), link do header "Seguir no Instagram →" (copy do rascunho aprovado; username derivado da API — com API fora e sem snapshot o link some junto com os cards, divergência documentada do "YouTube →" que sobrevive por ter o channelId configurado). next.config ganha `https://*.cdninstagram.com/**`. Testes: 16 units novas (parsers com caption null/carrossel/item sem permalink, thumbnail por media_type, eligibility, fetch + refresh-retry com `fetchImpl`); e2e: stub irmão `tests/e2e/instagram-stub.mjs` (porta dev+2000; a derivação da porta virou `tests/helpers/socialStub.ts` — rename do youtubeStub.ts mantendo UMA spelling), 3 testes novos no describe serial (full-state com exclusão + fallback de legenda + aspect 1:1 + link do perfil + carrossel, fail-closed sem snapshot, snapshot persistente com maxItems mudando na API down) + kill switch estendido com IG configurado + smoke do picker no admin (seed do snapshot via REST, toggle, Salvar, verificação do `excludedItems`). Verificação: unit 2351 + int 791 verdes; gate:fast/lint/format/tsc/knip (exit 0 — o erro de load do payload.config segue o ledger P3)/cycles/build verdes; e2e local: describe serial 9/9 + smoke admin 1/1 (rodando os projetos em sequência — em paralelo com `--no-deps` o `seedTestUser` dos dois beforeAll colide no mesmo usuário e quebra o run, artefato da invocação local, não do produto; o CI roda na cadeia de dependências). Detalhe do debug: o clique via CDP do MCP de browser não dispara eventos de input (ambiente do MCP, não do app — `el.click()` e o runner real funcionam).
|
||||
|
|
@ -1 +0,0 @@
|
|||
**S5** — separação visual da seção "Acompanhe de perto" da prova social na home de campanha: o `<section>` sai de `bg-white` (idêntico à faixa de números, seções coladas) para `border-y border-(--campaign-line) bg-(--campaign-band)` — banda `#ebe9e9` da paleta + hairlines de 12%, o desenho aprovado no wireframe (seção 7: zona de cor própria com `border-top/bottom: 1px solid var(--line)`) e no rascunho UI do gate (Cena 1/3 recomendadas). Zero mudança de conteúdo/dados/comportamento: cards continuam brancos e saltam na banda; fail-closed intacto (sem posts visíveis a seção some e Prova → Problema voltam contíguos — teste e2e existente segue verde); nenhum token novo; sem migration/Consent/access. Única mudança de runtime: 1 linha de className; + asserções `toHaveCSS` no e2e full-state da seção (fundo `#ebe9e9` exato — pega token quebrado em vez de só "≠ branco", hairline top `1px` com cor `rgb(0 0 0 / 12%)` exata, cards brancos) — sem screenshot diff (repo não usa regressão visual). Verificação: tsc/lint/format/unit 2318 verdes; e2e frontend: empty-state (contiguidade) e bento passaram com as asserções novas (2/2 runs que as alcançaram); o teste full-state falhou localmente no clique pós-minhas-asserções por cold-compile dev sob load ~68 — baseline com as mudanças stashed falha idêntico (falha pré-existente do ambiente, não regressão); build verde (migrations ok). knip local = limitação P3 conhecida (não carrega `payload.config.ts`); CI valida.
|
||||
|
|
@ -1 +0,0 @@
|
|||
**S6-FOLLOWUP** — CLI Playwright 1.58.2 e filtros e2e locais: `pnpm test:e2e:affected` rodava a suíte completa silenciosamente em runs selecionados. Causa raiz (medida com o binário do repo, config real): o commander parseia posicionais e `-g`/`--list` funcionam — quem arrastava a suíte era o closure de dependências dos projects no dev mode (`setup ← campaign ← frontend ← admin` em `playwright.config.ts`): `collectProjectsAndTestFiles` filtra os arquivos por project, mas `buildProjectsClosure` repõe todos os arquivos de cada project-dependência — filtrar um spec do `frontend` puxava `campaign` (160) + `setup` (2), 177 no total com o próprio arquivo (15); `admin` puxava a suíte inteira. Em prod mode (`CI=1`) os projects não têm deps e o filtro sempre funcionou (por isso o CI selecionado estava correto). Fix: `run-e2e-affected.mjs` agora gera `test:e2e <passthrough verbatim> --no-deps -- <paths>` via helpers puros novos em `scripts/lib/playwright-e2e-args.mjs` (unit-testados, `tests/unit/playwrightE2eArgs.unit.spec.ts`) — `--no-deps` desliga o closure em runs filtrados (suíte completa mantém a cadeia OPS34 de prewarm); de quebra, o passthrough do script foi corrigido (`pnpm test:e2e:affected -- tests/x.spec.ts` descartava o path — pnpm consome o `--` e o argv nunca o continha; agora `parsePassthroughArgs` só remove um `--` líder e repassa o resto verbatim, preservando `-g grade`). Hardening: `scripts/run-e2e-affected.mjs` entrou em `HIGH_RISK_EXACT` (era o espelho local do job e2e sem gate — um PR só de runner não rodaria e2e). Docs: receitas de filtro no `playwright.config.ts` (posicional exige `--no-deps`; `--project=<família> --no-deps`; `-g`/`--list` funcionam), header do script e nota no AGENTS.md. Sem mudança de specs, CI (`--shard`) ou `gate:ci`. Verificação: unit 2343+ verdes; contagem por `--list` do binário com a forma final (18); CI do PR roda o e2e completo (o diff toca `playwright.config.ts` e `run-e2e-affected.mjs`, high-risk). Débito absorvido: hang do `pnpm test:e2e` via pnpm neste worktree (pré-existente, ambiente — binário direto funciona; não escopo). Impl plan: `docs/plans/s6-followup-playwright-filtros-cli-ignorados-impl.md`.
|
||||
|
|
@ -1 +0,0 @@
|
|||
**S6** — home de campanha (jorgesolla1313.com.br) sem carrossel no desktop: as seções "Por que essa eleição importa" (3 cards) e "Nossa caminhada" (6 cards) mostram agora todo o conteúdo de uma vez em ≥1024px — grade estática 3 colunas (problem: 3×437px, bandeiras: 3×2 com altura por conteúdo, padding `p-4`), sem auto-avanço, sem chips/indicadores e sem `aria-current`. Mobile (<1024px) 100% preservado: carrossel com auto-avanço ~4s, chips sincronizados, swipe — componente e geometria mobile intocados. Padrão S1 (renderização-irmã com toggle CSS `hidden lg:grid`/`lg:hidden`): grade server-rendered (zero JS no desktop) + carrossel client embrulhado; os dois cards extraídos do `CampaignCarousel` para `CampaignProblemCard`/`CampaignFlagCard` (presentacionais, fonte única do markup, `size='spacious'` só na grade) e o contrato da grade (data-grid + aria-label + 3 colunas) vive num `CampaignCardGrid` local do page. O carrossel ficou byte-equivalente (só re-roteia os cards; unit tests intocados) e ganhou guard de `display:none` no auto-avanço — o timer nunca roda no desktop (chain morto numa subárvore oculta). Geometria: a seção flags recalibrada no desktop (`height: clamp(418px, calc(807.7px - 28.13vw), 520px)`, âncoras medidas em 1024/1280/1440+, respiro ≥30px, `overflow: hidden` para zoom de texto ≥125% não pintar sobre o footer); a seção problem manteve a geometria (respiro ~31px). Zero schema/migration/Consent; mesma copy/imagens/ordem; S1 e o resto da home intocados. Verificação: unit 2318 + int 791 verdes, e2e frontend 11/11 (novo teste de grade desktop com invariantes 3×1/3×2, ausência de regiões/chips/`aria-current`, respiro ≥24 e `scrollHeight ≤ clientHeight` em 1024/1280/1440/1920; teste de carrossel migrado para viewport mobile 390px com auto-avanço + chips; swipe/mobileSpacing intactos), lint/format/typecheck/knip/cycles/build verdes. `/simplify` 3 reviewers: `lg:` órfãos removidos do carrossel, grade extraída em `CampaignCardGrid`, `overflow: hidden` (zoom ≥125%), guard do timer, `document.fonts.ready` + eixo x + pino de clip no e2e, âncoras no comentário do clamp. Débitos: Issue #58 registrada (S6-FOLLOWUP — CLI do Playwright 1.58.2 ignora `--grep`/posicionais e o `test:e2e:affected` roda a suíte completa; `depends: [S6]`); defer com gatilho para os literais de gap da grade (recalibração Penpot do carrossel); descartados contratos de tipo dos cards (armadilha knip), aria-labels duplicados (copy pinada no e2e) e pino de tablet 768–1023. Impl plan: `docs/plans/home-campanha-sem-carrossel-desktop-impl.md`.
|
||||
|
|
@ -1 +0,0 @@
|
|||
**C141 — perfis de permissão por assessor: Visão × Edição configuráveis (2026-08-19):** o coordenador/candidato define por assessor, em `/campanha/assessores` (coluna "Permissão" com badge + popover, seção no detalhe e no draft de novo assessor), o que ele **vê** (`carteira|tudo`) e o que ele **edita** (`carteira|tudo|somente_leitura`) — contas novas e existentes seguem no padrão Carteira·Edita carteira até configurarem. Enforcement fail-closed no servidor: a leitura alarga com Visão "Tudo" via prólogo novo `resolveProfileScopedRead` (municípios, lideranças, updates, pledges, atividades, contatos derivados), a escrita segue o eixo de Edição (`advisorEditingAccess`) nos `canUpdate*`/`canCreate*` de todos os domínios, no check do giro e no create individual de atividade (ambos contra `getWritableMunicipalityIds`), e a ficha de contatos passou a ser autorizada pelo escopo de escrita (`assertPersonContactWritable` — antes era "você edita o que vê", que a Visão "Tudo" alargaria em escrita de PII). Caps mantidos: demandas seguem a regra própria (C143), apoiadores (PII) e telefones de contas ficam na carteira mesmo com Tudo, e escritas de coordenação (contas, `municipality.advisors`, nível de envolvimento E14, `allocationDecision`) continuam restritas. Combinação incoerente (Edição "Tudo" sem Visão "Tudo") é rejeitada no zod, na action e no hook `enforceCoherentAdvisorProfile` (REST/admin incluídos). Migration `20260819_214755_add_campaign_user_advisor_permission_profile` (duas colunas select com default `'carteira'`, sem backfill).
|
||||
|
|
@ -1 +0,0 @@
|
|||
**C143** — demandas visíveis apenas para responsáveis explícitos + candidato/coordenador. Novo campo `campaignDemand.responsibles` (hasMany → `campaignUser`, staff elegível via `eligibleCampaignStaffWhere` com hook `beforeValidate` que descarta ids não-staff, fail-closed) e regra de leitura/atualização por membership: assessor só vê demandas onde é responsável (`{ responsibles: { contains } }`), candidato/coordenador/admin sempre, leader nunca — a regra substitui o escopo municipal de demandas em `src/utilities/access/demands.ts` e todas as superfícies (lista, URL, busca global, demandas de atividade, C90) herdam via `overrideAccess: false`; criação sem responsáveis fica visível só a unrestricted (fail-closed). Criador entra automaticamente como responsável no hook `enforceDemandWorkflow` (cobre form, wizard e rascunhos C90); migration `20260819_233503_add_campaign_demand_responsibles` adiciona `campaign_user_id` a `campaign_demand_rels` (FK cascade) e faz backfill de `created_by_id` → responsável para demandas pré-C143. UI: picker multi-select `DemandResponsibleMultiSelect` (chips com avatar, chip do criador rotulado "(criador)" e travado na criação, sugestões = assessores do município) na criação (`/demandas/nova` em 2 colunas + wizard "Registrar pedido" empilhado) e card de gestão `DemandResponsiblesCard` no detalhe (add/remove com save full-replace + advisory lock, rótulo "Visível para: Responsáveis · Candidato · Coordenador"); busca por `searchDemandResponsibleOptions` (query curta → assessores do município; senão → staff por nome). Testes: int reescritos para a regra nova (workflow/homeSearch) + 4 casos novos (membership, fail-closed sem responsáveis, gestão add/remove/self-removal, responsável fora da carteira, descarte de id não-staff); e2e novo `campaignDemandVisibility.e2e.spec.ts` (assessor do mesmo município não vê lista/URL; hand-off de acesso add/revoke pela UI); fixture e2e ganhou `createCampaignDemand`. Verificação: unit 2444 + int 795 verdes, e2e 5/5 local (visibility + wizard + demanda do municipalidades), lint/format/knip/cycles/tsc/build limpos; importMap regenerado com `S3_*` dummy (OPS69). Nota de deploy: conferir na primeira aplicação em prod `SELECT count(*) FROM campaign_demand_rels WHERE path='responsibles'` = nº de demandas com `created_by_id`. Plano de implementação: `docs/plans/demandas-responsaveis-impl.md`.
|
||||
|
|
@ -1 +0,0 @@
|
|||
**OPS62-FOLLOWUP** — flake do `municipalitiesWithoutUpdateTool.unit.spec.ts` (2 ocorrências 2026-08-18: gate local 19:22 e CI run 779 23:53) eliminado pela raiz, com a hipótese original da Issue refutada por reprodução. Causa raiz: o fixture do teste (não o ambiente nem locale/ICU): `daysAgo()` chamava `Date.now()` **por chamada**; as duas construções `daysAgo(40)` de `Beta`/`Alpha` podiam cair em milissegundos diferentes sob carga (GC/preempção do fork do vitest na máquina compartilhada), deixando o timestamp de `Alpha` estritamente mais novo — em `sortStale` (`getMunicipalitiesWithoutUpdate.ts`) o sort por timestamp vencia o tie-break por nome e o teste recebia `[Beta, Alpha]` no lugar de `[Alpha, Beta]` (só o par trocado, resto correto — "sort não atuou" estava errado; o sort atuou sobre timestamps acidentalmente desiguais). Repro mecânica (sort de produção verbatim + fixture): fixture atual 0.08% de falhas em 200k runs sem pausa e **100%** com pausa de 1 ms injetada; fixture ancorado 0 em 2000 runs. Fix (test-only, nada em produção — o sort real é determinístico sobre timestamps fixos do DB): âncora `fixtureNowMs = Date.now()` no load do módulo (padrão do `prioritiesTool.unit.spec.ts`) + hoist do timestamp empatado (`const stale40 = daysAgo(40)` compartilhado por `Beta`/`Alpha` — igualdade estrutural, imune até a regressão da âncora) + comentário explicando a classe de flake. Scan da mesma classe: `prioritiesTool` (âncora no load) e `suggestionCatalog` (now fixo) já seguros; nenhum outro spec combina `Date.now()` por chamada com assunção de igualdade. Verificação: 30/30 runs do spec, unit full (249 arquivos/2405 testes), int (89/791), tsc, lint, format, check:cycles verdes (knip só com o P3 ledgered de sempre). /simplify: 3 reviewers aprovaram; débito absorvido no próprio teste (comentário do porquê). Impl plan: `docs/plans/ops62-followup-ci-flake-fixture-timestamps-impl.md`.
|
||||
|
|
@ -1 +0,0 @@
|
|||
**Recently resolved (2026-08-19):** **OPS66** — deadlock de migrations em caminho estático: o deploy buildava o runner **antes** do migrate e, se uma migration criasse tabela lida em geração estática (caso S2, 2026-08-18: `social_feed_settings` na home), o `next build` falhava para sempre (`relation ... does not exist`) e o migrate nunca rodava — 4 deploys seguidos vermelhos até o migrator manual. Fix em `scripts/deploy-homeserver.sh` (reordenação de fases, sem custo novo — migrate e build do migrator já rodavam em todo deploy): **build do migrator** (o estágio não roda `next build` — builda contra o schema antigo) → push/tag do migrator → swap do compose (backup + trap na mesma posição) → **migrate** via `docker compose run teqo-1313-migrate` (caminho canônico do serviço de maintenance, agora com a imagem do SHA novo) → **build do runner** contra o banco JÁ migrado → push/tag → rollout → healthcheck → smoke. Alternativas rejeitadas: `payload migrate` dentro do build do runner (efeito colateral de escrita em prod a partir de um processo de build; contraria o desenho deliberado do Dockerfile) e `docker run` direto do migrator com env-file (duplicaria a definição do serviço de maintenance — caminho paralelo ao canônico). Caveat do rollback ajustado no runbook: migrate antes do build do runner deixa o banco à frente do código se o build falhar depois (mesma classe do caveat atual — migrations append-only e revisadas). Testes: `tests/unit/deployScript.unit.spec.ts` com pin de ordem completo (migrator build < swap < migrate < runner build < rollout) + invariante novo do Dockerfile (estágio migrator nunca roda `next build`). Runbook `docs/ops/teqo-1313-deploy.md` atualizado (fluxo, caveat, falha conhecida nova). Dockerfile/AGENTS.md com a precisão build-after-migrate.
|
||||
|
|
@ -1 +0,0 @@
|
|||
**OPS67** — o cliente `scripts/lib/forgejo-api.mjs` ganhou **retry com backoff** nos fetchs: um `fetch failed` transitório (rede/DNS/reset — o sintoma que matava o run do safety net `agent-pr-ready-automerge.yml` no meio da espera de checks) agora retenta em **qualquer método**, e a leitura de body interrompida no meio (mesma classe de falha de rede) retenta **em GET**; 5xx do Cloudflare (502/503/504) retenta **só em GET** (protege o poll de 45 min do `waitForChecks` sem arriscar duplicar efeitos colaterais de escrita — body do 5xx cancelado antes do retry para liberar o socket); 4xx e body-drop em escrita continuam falhando na hora, fail-closed intacto. Política: 3 retries, base 300 ms ×2, jitter ±20%, `console.warn` pt-BR em cada retry (`tentativa N/4`), validação `retries >= 0`/`backoffMs >= 0`. Opções flat no `createApi` (`retries`, `backoffMs`, `jitter`, `sleepImpl`) — defaults preservam o comportamento de todos os 19 call sites (safety net, claim/status, issue-transition, pool, worktree) sem mudança de chamador; zero-dep mantido. Testes: 12 specs novas em `forgejoApi.unit.spec.ts` (rede GET/POST → sucesso, body-drop GET retenta + POST fail-closed, exaustão de rede/5xx, `retries: 0`, backoff exponencial [100, 200, 400], jitter ±20% com `Math.random` pinado, 4xx sem retry, 5xx-GET retry/exaustão, 5xx-POST sem retry) + pin no spec 403 existente (1 fetch por invocação). Verificação: unit 2403 verdes; lint/format/typecheck/knip/cycles verdes. Impl plan: `docs/plans/ops67-forgejo-api-retry-backoff-impl.md` (aprovado no gate humano; refinamentos do /simplify absorvidos na execução).
|
||||
|
|
@ -1 +0,0 @@
|
|||
**Recently resolved (2026-08-19):** **OPS68** — fechamento da parte 2 do OPS50: reescrita dos docs canônicos operacionais que ainda descreviam a era GitHub/Vercel. **12 arquivos, 78+/82−, edições linha-a-linha:** `docs/AGENT-OPS.md` (norte "só Forgejo + Cursor"; fonte canônica = Issues do Forgejo; fluxo do agente e Contrato de PR sem `gh` — criação via API/MCP + merge pelo safety net `agent-pr-ready-automerge.yml`, o único que espera o rollup `CI (PR) / checks`; "Só humano" = envs de `~/stack/teqo-1313.env` + branch protection + runbook; tabela de secrets sem `POOL_GITHUB_TOKEN` (legado — o script o ignora) e com nota de pool dormente; linha `archive-cursor-agent.yml` na tabela CI; "smoke Neon stage" → sem smoke stage), `README.md` (cheatsheet de 5 linhas reescrito: claim → push → PR via API/MCP → safety net mergea → ci.yml em janela de 30 min + deploy homeserver; Neon → homeserver Postgres; `PROD_DATABASE_URL` = `DATABASE_URL` de `~/stack/teqo-1313.env`; migrações no deploy via `teqo-1313-migrate`; "GitHub mirror" → Forgejo Actions; papéis `coordinator/advisor/leader/candidate`; "electoral nuclei" → municípios; Issues → link Forgejo), `docs/roadmap.md` (só o ponteiro da fonte canônica — o stub continua congelado), `AGENTS.md` (refs Neon → homeserver `teqo_1313`; job do gate `check`→`gate`, key real do YAML; "Vercel preview" → host genérico na rationale do WebAuthn), `.agents/rules/agent-pr-workflow.mdc` (comandos `gh pr create/merge/checks` morrem — `gh` não existe em nenhuma máquina (OPS50); regra descreve API/MCP + safety net; "There is no deploy from CI" corrigido — OPS53 colocou o deploy no `ci.yml`), e as skills `work-issue` (SKILL + `execution-pipeline`), `plan-issue` (frontmatter, gate "nada no Forgejo", dedup via `pnpm issue all`), `project-status`, `capture-review-debts`, `engineering-audit` (`gh issue list` → `pnpm issue all`/API; `issue-done-on-stage-merge.yml`→`on-main`; merge `stage`→`main`), `worktree-next-issue` ("resto do Forgejo") e `agent-pool` (descrição Forgejo Actions; workflow marcado removido OPS65; estado em `pool-state.json`, não mais variables). **Mantido como registro histórico** (exclusões explícitas do plano): menções de Vercel/Neon/POOL_GITHUB que documentam remoções (AGENTS.md L15/L105/L167, AGENT-OPS L94/L98, "Vercel/Git-integration deploy gate" como guardrail no rule). Fonte canônica única: `git.solla.dev/fsolla/teqo`. Sem migration/access/Consent/UI. Gate: grep de saída zero nas classes `gh pr`/`gh issue`/`github.com/fsolla`/`GitHub Issues`/`GitHub mirror`/`POOL_GITHUB`/Neon-em-uso; docs-only → CI roda os docs-guards.
|
||||
|
|
@ -1 +0,0 @@
|
|||
**OPS69** — o admin do Payload abria **em branco em produção** (P0, 2026-08-19): o `importMap` commitado (`src/app/(payload)/admin/importMap.js`) ficou sem a entrada `@payloadcms/storage-s3/client#S3ClientUploadHandler` — o commit OPS52 (`dabc00f4`) removeu o handler do Vercel Blob e **não** adicionou o do S3, porque o `pnpm generate:importmap` rodou **sem** as envs `S3_*`: com o plugin `s3Storage` condicional, sem as envs o importMap sai consistente (dev/test, storage local) e com elas (produção, Garage) o admin precisa do handler e o montava → `getFromImportMap: PayloadComponent not found in importMap` → login nunca renderizava. Fix mínimo (opção A do plano, aprovado no gate): regenerar o importMap com as 4 `S3_*` dummy inline (a mecânica do CLI: `iterateConfig` sem DB; `loadEnvConfig` do `@next/env` não sobrescreve envs já no processo) → diff de **exatamente +2 linhas** (import + entrada do `S3ClientUploadHandler`), nenhum outro drift. Verificação: smoke headless com S3 ativo no dev local — `/admin/login` renderiza (6 inputs, formulário visível, **zero erros de console**) vs. o branco reproduzido no diagnóstico; gates completos verdes (tsc/lint/format/knip/cycles, unit+int 791, build). Sem migration/access/Consent/UI. Docs: linha no AGENTS.md (checklist local: `generate:importmap` precisa das `S3_*` setadas — valores dummy ok — enquanto o plugin for condicional; sem elas o importMap fica órfão, classe OPS69) + item sucessor registrado (guard de CI que regenera com envs dummy e falha o gate se o arquivo commitado divergir — `depends` #77). Verificação pós-deploy: `/admin` abre o login e o log do container não re-emite o `getFromImportMap` para o handler (runbook `docs/ops/teqo-1313-deploy.md`).
|
||||
|
|
@ -1 +0,0 @@
|
|||
**Recently resolved (2026-08-19):** **OPS71 Fase 2** — remoção dos arquivos do fluxo Forgejo após validação ao vivo do GitHub Actions (PRs #742/#743: CI verde, auto-merge nativo, flips via API). **14 arquivos deletados:** `.forgejo/workflows/` (6 — ci-pr.yml, ci.yml, agent-pr-ready-automerge.yml, issue-done-on-main-merge.yml, plan-issue-ready-on-main-merge.yml, archive-cursor-agent.yml), `scripts/forgejo-pr-automerge.mjs`, `scripts/forgejo-dispatch.mjs`, `scripts/ci-classify-production.mjs`, `scripts/lib/dockerignore.mjs`, `scripts/lib/branch-protection.mjs` e as specs órfãs `ciClassifyProduction`/`dockerignoreMatch`/`branchProtection` `.unit.spec.ts`. **Consumidores funcionais achados pela varredura (fora do escopo literal do body) e ajustados na mesma mudança:** `scripts/lib/test-affected-core.mjs` perdeu as 2 entries de `HIGH_RISK_EXACT` dos scripts mortos; `tests/unit/ciSkipInvariants.unit.spec.ts` tirou os 2 `.forgejo/workflows/*` do loop `.next-e2e`, os 2 paths do pin de high-risk e **reescreveu o teste da janela de 30 min (OPS65) para o invariante vivo da era GitHub**: `deploy.yml` é `workflow_dispatch`-only (sem `push:`/`schedule:` — deploy nunca automático) e `ci-pr.yml` não referencia o gate morto. **Docs:** `AGENTS.md` (frase "preservados até Fase 2" → removidos; rollback = revert do PR ou clone do main congelado do Forgejo), runbook `docs/ops/teqo-1313-deploy.md` (Rollback do cutover idem), `README.md` (cheatsheet de 5 linhas era-Forgejo com janela de 30 min + deploy automático → era-GitHub: auto-merge nativo + deploy manual dispatch), comentários com referência morta em `scripts/gate-ci.mjs`, `scripts/github-pr-automerge.mjs` e `.github/workflows/agent-pr-ready-automerge.yml`. **Intocados (anti-goal tracker):** `forgejo-api.mjs`, `agent-*.mjs`, `issue.mjs`, `forgejo-issue-transition.mjs`, `agent-promote-related-on-merge.mjs`, `.forgejo/worktree.env` (consumido pelo provisionamento de worktree), `.dockerignore` (o arquivo — `docker build` do deploy o consome; só o lib de parse morreu). `docs/AGENT-OPS.md` já era era-GitHub (zero referências). Histórico (plans/changelogs antigos) congelado. Sem migration/access/Consent/UI. Gate: tsc/lint/format/knip/cycles/unit+int/build verdes; grep de saneamento `forgejo-pr-automerge|forgejo-dispatch|ci-classify|dockerignore.mjs|branch-protection.mjs` → só histórico.
|
||||
|
|
@ -1 +0,0 @@
|
|||
**Recently resolved (2026-08-19):** **OPS71** — CI/PR/deploy do Forgejo Actions de volta para o **GitHub Actions**, com o **tracker de Issues permanecendo no Forgejo** (labels, claims, `pnpm issue`/`agent:*` e flips pós-merge continuam via API do Forgejo; o GitHub é só o host do Actions). O PR CI (`ci-pr.yml`, job único `checks` — OPS62) roda no GitHub com a mesma cascata (guards → lint → format → typecheck → knip → cycles → unit → int → build → e2e selected/afetado via `ci-scope.mjs`), com `concurrency` nativo cancelando o run em push novo e `if:` same-repo (repo público — fork não queima minutes). Seu check-run `CI (PR) / checks` é o required check do `main` e o gate do **auto-merge nativo** (`agent-pr-ready-automerge.yml` → `scripts/github-pr-automerge.mjs`: draft `cursor/*` → Ready via PATCH, arm `enablePullRequestAutoMerge` GraphQL com REBASE — o servidor espera o required check verde; sem poll; o pin OPS64 "rollup pode mentir" é estrutural aqui: job único posta um check-run honesto). **`ci.yml` (verificador de main) eliminado** — publicar é **dispatch manual** (`deploy.yml`): `verify` (hosted, suíte full incl. e2e full) → `deploy` (`needs`, `runs-on: [self-hosted, homeserver]`) executa `scripts/deploy-homeserver.sh <sha>` **localmente** no runner do GitHub instalado no homeserver (sem SSH; outbound-only; o hosted nunca toca o homeserver); o script re-aponta o workspace `~/teqo-deploy` para `TEQO_REPO_URL` (default `https://github.com/fsolla/teqo.git`, público). `pnpm push` passa a empurrar para `origin` = **GitHub** (cutover manual de remote documentado; o repo Forgejo congela em main, tracker vivo por API). Novos helpers plain-Node zero-dep: `scripts/lib/github-api.mjs` (REST+GraphQL, retry OPS67), `scripts/lib/github-pr-flow.mjs` (decisão draft/veto/skip, pura), `scripts/lib/github-branch-protection.mjs` (regra desejada + drift, `enforce_admins: true`), `scripts/github-pr.mjs` (criação de PR Ready — nunca draft), `scripts/configure-branch-protection.mjs` reescrito para a REST do GitHub; `forgejo-issue-transition.mjs` aceita `PR_BODY` (body do PR via evento GitHub; flip continua no Forgejo por `FORGEJO_API_TOKEN`). **Cutover em duas fases (gate):** nada do Forgejo foi apagado (`.forgejo/workflows/`, `forgejo-pr-automerge.mjs`, `forgejo-dispatch.mjs`, `ci-classify-production.mjs` preservados como rollback) — a remoção é entrega sucessora pós-validação. Testes: `githubApi` (10), `githubBranchProtection` (10), `githubPrFlow` (9) unit novos. Docs: `AGENTS.md`, `docs/AGENT-OPS.md`, `.agents/rules/agent-pr-workflow.mdc`, `work-issue/execution-pipeline.md`, `agent-work-issue`, `capture-review-debts`, runbook `docs/ops/teqo-1313-deploy.md` (dispatch manual, runner no homeserver, cutover com ordem, rollback).
|
||||
|
|
@ -1 +0,0 @@
|
|||
**OPS72** — política de e2e: **CI de PR sem e2e full; local afetado como passo da skill; full só antes do deploy**. Decisão do gate (2026-08-19): (1) **local = discricionário** — o agente roda os e2e que criou + os da mesma superfície afetada, decide quais (`pnpm test:e2e:affected` é a ferramenta, não um gate mecânico; não está no `gate:push`); (2) **CI de PR = blast radius** — `ci-pr.yml` perdeu o step `E2E tests (full suite)`; o e2e roda só em modo `selected` (specs que o classifier detectou como tocadas); diff high-risk (schema/lockfile/harness) classifica `full` e o PR simplesmente não roda e2e — deliberado: o espelho local roda full nesse mesmo diff e o `verify` do deploy roda full antes de publicar; (3) **main = full no deploy** — inegociável, já existia no `deploy.yml` (nada mudou lá). Docs: `work-issue` e `agent-work-issue` (checklist + Passo 4), `execution-pipeline.md` (nova seção "E2E local afetado" com o critério + a **limitação da #72**: `--no-deps` + projetos paralelos colide no `seedTestUser` → `--workers=1` ou a cadeia padrão de projetos), `AGENTS.md` (parágrafo OPS59 → política OPS72), `docs/AGENT-OPS.md` (fluxo do agente + linha do `ci-pr.yml` na tabela), `.agents/rules/agent-pr-workflow.mdc` (descrição do `gate:push`), mensagens informativas do `gate-ci.mjs` (texto-only — e2e continua fora do gate) e comentários do workflow. Mecânica intocada por escopo (`ci-scope.mjs`, manifest, `run-e2e-affected.mjs`); o fix da #72 fica na fila (só documentado). Verificação: format/lint/tsc verdes, unit 2444 + int 791 verdes; o e2e local afetado rodou a suíte **full** (o diff toca `scripts/gate-ci.mjs`, high-risk → modo `full` — a política em ação): 154 passed em dev mode; 8 falhas de ambiente (timeouts/cold-compile sob carga) triadas — 5/8 passaram em re-run e as 3 restantes passaram em `E2E_PROD=1` (o modo do CI/verify; 41 passed + 2 flaky que passaram isoladas), zero regressão de produto; o CI deste PR não roda e2e (modo `full` por design desta mesma política). Impl plan: `docs/plans/ops72-e2e-local-afetado-ci-sem-full-impl.md`. Nota: `E2E_PROD=1` local regenera o `importMap.js` **sem** as envs de S3 (classe OPS69 — o guard do OPS70/#87 cobre; restaurado na entrega com envs dummy).
|
||||
|
|
@ -1 +0,0 @@
|
|||
**S10 — Pixel do Meta (Facebook) na página pública de campanha (2026-08-19):** o time de campanha paga pode configurar o ID numérico do Pixel (Dataset do Events Manager) no admin — global `SiteSettings` → group "Rastreamento / Ads" → "ID do Pixel do Meta (Facebook)", mesma validação/normalização do precedente dos abaixo-assinados (5–20 dígitos, sem HTML/JS; regras agora vivem num único dono em `facebookPixel.ts`, com o `Petition` refatorado para o helper compartilhado). Com o ID configurado, a home pública de campanha (`/`) carrega o base code + `PageView` do Meta (injeção no layout do grupo `(home)` via `next/script`, escopada só à página de campanha — nada de PageView vazando para notícias/etc.); sem ID, nada é carregado (fail-closed). A conclusão da captura de novidades (S9) dispara exatamente um `Lead` (`content_name` "novidades-da-campanha", `eventID` UUID) no padrão do `PetitionForm` — nunca no page load. Leitura via `getCachedGlobal('site-settings')` (ISR sob a tag `global_site-settings`; a edição do admin já revalida a home). Migração `20260819_213947_add_site_settings_facebook_pixel_id` (colunas `tracking_facebook_pixel_id` + versão, sem backfill). Fora da fatia (gatilhos herdados do plano da petição): CAPI/Advanced Matching, dedup `eventID`, fila/retry de `Lead` e cookie banner/CMP. E2e novo `campaignHomePixel` (script `#meta-pixel-*` presente só com ID configurado, CDN stubada; `mode: serial` por precedente).
|
||||
|
|
@ -1 +0,0 @@
|
|||
**S11** — o feed do Instagram configurado **sem aparecer na home** fica diagnosticável: fim do fail-closed silencioso da S3. A assessoria vê, na mesma global onde preenche o token, o estado da sincronização (`instagramSyncStatus` jsonb hidden na `social-feed-settings`) e o **porquê** de os posts não aparecerem — o painel `InstagramSyncStatusPanel` (campo `ui` irmão do picker) mostra 5 estados fiéis ao rascunho aprovado (desativado / ainda não configurado / falha com motivo em linguagem de produto + "Tentar sincronizar de novo" / sincronizado · há X min · N posts / aguardando 1ª sync). A causa da ocorrência de produção (board vazio com token+ID preenchidos) é agora confirmável em 1 clique após o deploy. Engenharia: `loadInstagramFeed` passou a parsear o body de erro da Graph API e lançar `InstagramApiError` (`status` + `{message,type}`); `describeInstagramError` mapeia para copy pt-BR com a correção — OAuthException/expired → "token inválido/expirado ou emitido via Facebook Login — gere pelo Instagram Login" (o refresh endpoint da Graph API só renova tokens de Instagram Login), "Invalid user id" → "confira o ID da conta Business/Creator", 5xx → indisponível, `TypeError` → rede. Estado escrito por 3 caminhos: render da home (`getInstagramFeed`, dentro do `unstable_cache`, SQL direto sem hooks — padrão anti-`revalidateTag`-no-cache da S2), `afterChange` da global (sync **awaited** quando a credencial IG muda — `instagramCredentialsChanged(doc, previousDoc)`, gate que pula save de exclusões/YouTube; o sync é importado lazy no hook para o `payload.config.ts` não puxar o módulo do feed no load, que é o que todo spec int importa) e a rota `POST /api/social-feed/sync` (botão; `payload.auth` sessão admin exigindo `users` + `isSameOriginRequest` CSRF — allowlistada no spec de convenção de POST routes com razão, é auth de admin ≠ envelope `/campanha`). **Bug real descoberto e corrigido na entrega:** o `afterChange` roda DENTRO da transação do save (a row lock fica presa até os hooks retornarem), então escrever o status/snapshot via pool numa segunda conexão **deadlockava no lock da própria linha** (~80-230s por save, transações-zumbi segurando o lock). A correção roteia os persists do sync-hook pela **mesma transação** (`getPostgresTransactionDatabase` — o helper transacional da C6) com um `req` opcional no `syncInstagramFeed`; o render e a rota sem transação aberta seguem no pool. Diagnóstico reproduzido em `pg_stat_activity` (UPDATE `transactionid` Lock vs `idle in transaction`) e fix verificado (45ms vs 232s). Stub IG ganha estado `invalid-token` (400 OAuthException) para o e2e da cena de falha; runbook `docs/ops/instagram-feed-token-runbook.md` (o que o painel mostra, validação via curl `/me` e `refresh_access_token`, geração pelo Instagram Login, o que NÃO fazer). Migration `20260820_003426_add_instagram_sync_status` (ADD COLUMN jsonb nullable, não destrutiva). **/simplify (3 reviewers):** absorvi a correção P1 de diagnóstico — o mapeamento de ID exigia só "user" (regex frouxo) e mensagens reais de token OAuthException ("the **user** must be logged in", "user changed password") caíam na branch de ID, mostrando a razão errada; passei a exigir "user" E "id" juntos (`isInvalidUserIdMessage`) e adicionei o mapeamento de `AbortError` (timeout → "demorou demais"). Painel: gate passou a respeitar o master `enabled` (master off → "Instagram desativado", nunca "Sincronizado no board"), rótulo do botão no estado de primeira sync ("Tentar sincronizar" vs "de novo"), `outcome` transiente limpo ao editar credencial e `aria-live` nos boxes interativos. Testes: caminho transacional do `syncInstagramFeed` (persist na mesma transação) + fallback pool cobertos por unit; e2e do painel ganhou as cenas fail-500 e "desativado"; cleanup do stub global virou `afterEach`. Débitos registrados: #124 (janela de lock: fetch de rede no sync-hook segura a row lock até 10s, acoplando a home ao save) e #125 (polish P3 — tipo de persist duplicado, rota 200 vs 400, last-writer-wins stale, `5 posts` hardcoded). Verificação: unit 2463 + int 791 verdes; lint/format/tsc/cycles verdes; `pnpm build` ok; e2e local — smoke admin 5/5 (painel ok → falha token → retry → falha 500 → desativado → não configurado) + describe serial frontend 19/19.
|
||||
|
|
@ -1 +0,0 @@
|
|||
**S4** — botão discreto de compartilhar no WhatsApp por card da seção "Acompanhe de perto" da home (jorgesolla1313.com.br): cada card (Artigo/YouTube/Instagram, o board S1+S2+S3) ganha um botão redondo no canto superior direito (visual 28px sobre área de toque 44px, `aria-label` "Compartilhar", sem competir com o CTA do card) que abre um popover de 2 ações: **"Compartilhar no WhatsApp"** — `wa.me` sem destinatário (WhatsApp do próprio remetente; o `buildWhatsAppTextShareUrl` do S6 foi movido para `src/lib/phone.ts` — dono do wa.me, re-exportado no `homeSearchShare.ts` — e o `contentShare.ts` reusa, sem twin) com mensagem pré-configurada **por fonte** (decisão B do gate: "Olha isso do Solla: {título} — {link}" / "Olha esse vídeo…" / "Olha esse post…"; link = página canônica do artigo resolvida contra `window.location.origin` no cliente, ou URL da plataforma para YT/IG; nova aba `noopener noreferrer") — e **"Copiar link"** (clipboard com "Link copiado" + `aria-live`para leitores de tela, fallback honesto em falha). Estrutura: o card inteiro é um anchor — o botão vive como **irmão** absoluto num wrapper`relative`(botão dentro do anchor seria HTML inválido); o conteúdo do popover usa`ui/Popover`(Radix, portal — escapa o`overflow-x-auto`do carrossel mobile que cliparia um menu absoluto), sem roles`menu`/`menuitem`(a semântica honesta de popover com 2 ações — Radix já emite`aria-expanded`). Zero migration/schema/Consent/escrita no servidor — montagem de URL 100% cliente, nada coletado (anti-goal da intenção). Lib pura `src/lib/contentShare.ts`(prefixos por fonte, resolução de link fail-soft contra permalink malformado, URL wa.me) + 11 units; e2e novo no describe serial da seção (cadeia 19/19 verde local): botão por card do bento, mensagem por fonte com URL absoluta,`target/rel`, copiar→clipboard real (`expect.poll`), Escape/clique-fora, mobile ≥44px + 28px visual + portal dentro do viewport, card ativo pinado por identidade. Verificação: unit 2402 + int 791 verdes, tsc/lint/format/cycles/build verdes; knip = limitação P3 pré-existente (não carrega `payload.config.ts`); CI valida. /simplify: 3 revisores paralelos absorvidos (twin do wa.me resolvido pelo owner move, máquina de feedback de copiar simplificada para effect único, foco/roles honestos, min-h-11 nos itens, `aria-live`, origin lazy sem guard morto, malformed-href fail-soft, e2e endurecido: pin de identidade do card ativo + unmount do popover antes do próximo trigger + helper de decode `+`); 1 débito deferido (dedupe do blob de 15 campos do `updateSocialFeedSettings` nos 5 testes do describe — quando os testes S2/S3 forem tocados de novo).
|
||||
|
|
@ -1 +0,0 @@
|
|||
**S7** — polimento dos cards da seção "Acompanhe de perto" da home de campanha (jorgesolla1313.com.br), look editorial e direto no lugar do "card de app": cantos retos (**sem `rounded-xl` no card e `rounded-lg` na capa**), capa de **borda a borda** (o `p-3` saiu do contêiner do card e foi para o bloco de texto — título/subtítulo/meta continuam respirando `p-3` das bordas; `mt-auto` da meta e `overflow-hidden` do zoom hover intactos) e, por decisão do humano posterior ao gate, **sem borda (`border --campaign-line`) e sem sombra** (`shadow-sm`/`hover:shadow-md`) — o card branco lê contra a banda do S5 só pela forma. **Chips de badge removidos de todos os cards** (decisão B do gate): nem os tipos de post do site (`POST_TYPE_BADGE_LABELS` deletado de `src/utilities/posts.ts`, junto com o describe no unit test) nem as origens externas YouTube/Instagram (sinalização externa segue por `target="_blank"`/`rel="noopener"`); `badgeLabel` saiu do tipo `CampaignContentCardData`. Superfície única no componente dono (`CampaignContentCard.tsx`) — bento e carrossel herdam; `ContentShareButton` (S4), banda do S5, carrossel e fail-closed da seção intocados. Zero migration/schema/Consent. e2e do describe da seção atualizados para o novo estado: loop de badges → ausência (`toHaveCount(0)` para os 4 tipos + YouTube/Instagram), pins CSS do card (`border-radius`/`border-width`/`box-shadow` + raio da capa) e pin geométrico da capa de borda a borda (boundingBox do card × capa). Verificação: gate:fast verde, unit 2415 + int 791 verdes, e2e frontend 19/19 local, format/knip/cycles limpos. Plano de implementação: `docs/plans/cards-acompanhe-de-perto-polimento-impl.md`.
|
||||
|
|
@ -1 +0,0 @@
|
|||
**S8** — seção "Nossa história" na home de campanha (jorgesolla1313.com.br): o vídeo da trajetória do Dep. Jorge Solla (~1:40) ganha lugar próprio na home, após a seção "Bandeiras" e antes do rodapé — eyebrow "Nossa história" + título "Conheça a trajetória de Jorge Solla" + frame vertical 9:16 (desktop 2 colunas, copy à esquerda e frame 300px à direita centralizados; mobile empilhado, frame full-width cap 420px). **Divergência aprovada em execução pelo humano:** o vídeo é embutido do canal do deputado no YouTube (Short `i_fbclWWC5o`) em vez do asset self-hosted do plano de intenção — cada play embutido conta view no canal; `youtube-nocookie` (LGPD), `loading="lazy"` nativo (zero bytes até o frame se aproximar do viewport), `playsinline`, `allowfullscreen`, `title` a11y, `allow` com `compute-pressure` (silencia o console.error do player — o guard do e2e falha em console.error); CTA "Assistir à história" rola até o player (play cross-origin não é disparável). Os encodes one-off H.264/AV1 + poster da foto JOA00327 (14.9 MB, validados por design-vision contra o orçamento de 15 MB da intenção) ficam como artefato local gitignored em `data/encode/` — fallback pronto se um dia se voltar a self-hosted. Zero schema/migration/Consent; nenhuma seção existente tocada. e2e novo (posição, copy, atributos do embed, CTA→viewport, layout desktop 2 colunas com centro vertical, 9:16 mobile sem overflow) + abort global do `youtube-nocookie` no fixture `e2eTest.ts` (hermeticidade CI). Verificação: unit 2416 + int 791 verdes, tsc/lint/format/cycles/build verdes; e2e frontend 20/20; knip = limitação P3 pré-existente. /simplify: 3 revisores paralelos absorvidos (JSDoc enxuto, e2e sem assert redundante, plano sem conteúdo morto, desktop asserts); 2 débitos deferidos no impl plan (rótulo do CTA se a assessoria reabrir copy; dedupe da classe do CTA com `CampaignContentSection` quando S5 #33 terminar).
|
||||
|
|
@ -1 +0,0 @@
|
|||
**S9 — captura de novidades da campanha na home pública (2026-08-19):** a home de campanha (`jorgesolla1313.com.br`) ganhou a seção "Receba as novidades da campanha" logo acima do rodapé — nome + WhatsApp obrigatórios, e-mail/estado/cidade/comentário opcionais, toggle pré-selecionado "Quero fazer parte do time" (frequência + grupos de WhatsApp vs. comunicações esporádicas) e confirmação in-place com atalho para as bandeiras. O CTA secundário do hero virou o atalho da seção (âncora `#novidades`, scroll suave no container da home; "Conhecer bandeiras" sai, as bandeiras seguem no rodapé e no estado de sucesso). O registro é `Contact` + `Subscription` em transação com consentimento NOVO por chave estável `campanha-novidades` (fail-closed: sem o texto aprovado no admin, o form recusa a captura — nada é gravado); a escolha do toggle sobrevive como `subscription.campaignLevel` (`time|esporadico`, coluna própria na lista do admin). Migração `20260819_192605_add_campaign_newsletter_capture` (DROP NOT NULL em `contact.state` — estado opcional no form, ficha staff via zod segue exigindo — + coluna `campaign_level` com enum). Precedente `submitWhatsapp` preservado; action nova usa `withPayloadTransaction` (o dono do concern de transação, não o hand-roll gêmeo — `submitWhatsapp`/`submitPetitionSignature` ficam como débito de alinhamento). S10 (pixel Meta) dispara `Lead` na costura do sucesso do form. Chave de consentimento pendente de aprovação jurídica antes da ativação.
|
||||
|
|
@ -1 +0,0 @@
|
|||
**Recently resolved (2026-08-20):** **OPS71-FLIP** — flip pós-merge falhou/silencioso no fluxo GitHub, validado e endurecido (Issue #119, achado ao vivo no merge do PR #746). **Investigação (GitHub API pública):** o `closed` do PR #746 (merge por `github-actions[bot]`, auto-merge armado com o `GITHUB_TOKEN` nativo) **não criou run nenhum** — anti-recursão documentada do GitHub (eventos acionados por ações do `GITHUB_TOKEN` não geram runs; o `closed` fica no timeline com 0 runs; comparação com o #742, mergeado por `fsolla`, cujos runs nasceram 3s após o merge). O 410 do run do #742 é falha separada (API do Forgejo respondeu 410 Gone nginx, transitório). **Endurecimento:** (1) **fix de raiz** — o arm do auto-merge (`agent-pr-ready-automerge.yml` → `scripts/github-pr-automerge.mjs`) agora usa o secret novo **`AUTOMERGE_PAT`** (PAT real do fsolla; decisão pura `automergeArmingToken` em `github-pr-flow.mjs` com fail-closed — sem o secret o job sai 1, nunca fallback pro bot token; o merge passa a ser atribuído a usuário real e o `closed` volta a gerar runs); (2) **recuperação manual** — `workflow_dispatch` com input `pr` nos dois flips (`issue-done-on-main-merge.yml`, `plan-issue-ready-on-main-merge.yml`): o job busca o PR via `gh` com o mesmo contrato do evento (merged + base main + same-repo, senão skip) e repassa o body como `PR_BODY`; (3) **close da Issue** — `forgejo-issue-transition.mjs` agora `PATCH state=closed` via novo `closeIssue` do `forgejo-api.mjs` (era-Forgejo o merge fechava nativo; era-GitHub o PR não toca o tracker — antes, toda Issue mergeada ficava `OPEN` com `done`+`in-prod`); (4) **410 no retry** — `forgejo-api.mjs` retenta **410 em qualquer método** (proxy respondeu antes da origem — sem efeito duplicado; 5xx segue fail-closed em escrita) e corpo nginx vira marker compacto `corpo não-JSON (proxy/nginx…)` no erro. **Testes:** 8 specs novas (retry 410 GET/write, marker após 410 esgotado, `closeIssue` PATCH pin, `automergeArmingToken` ok/fail-closed/vazio) — 50 unit verdes nas specs tocadas. **Docs:** `AGENTS.md` (frase do auto-merge com o PAT + porquê), `docs/AGENT-OPS.md` (tabelas de workflows/secrets + runbook de recuperação manual com fallback local `PR_BODY`). Sem migration/access/Consent/UI. Impl: [`ops71-flip-pos-merge-falhou-silencioso-impl.md`](plans/ops71-flip-pos-merge-falhou-silencioso-impl.md).
|
||||
|
|
@ -1 +0,0 @@
|
|||
**OPS71-INFRA (2026-08-19):** runner **self-hosted do GitHub instalado no homeserver** (Issue #113) — o job `deploy` do `deploy.yml` (dispatch manual) agora roda localmente no homeserver, sem SSH. Detalhes: `~/actions-runner` v2.336.0 linux-x64 (sha256 conferido contra a API de releases), nome `teqo-1313-runner`, labels `self-hosted` + `homeserver`, registrado com o PAT do OPS71 (registration token TTL 1h gerado e aplicado na sequência). Roda como **systemd user** `actions.runner.fsolla-teqo.teqo-1313-runner.service` (linger já ativo; `svc.sh install` exige root — unit user criado manualmente do template oficial), usuário `fsolla` (docker + `~/stack` envs), workdir `_work`, outbound-only (funciona atrás do Cloudflare tunnel). Verificado `online` na API do GitHub com as labels corretas. Registrado em infra-solla `STATE.md` (seção Teqo: runner + fluxo deploy manual atualizado; linha do repo/CI corrigida para o pós-cutover GitHub). Workspace `~/teqo-deploy` re-aponta para o GitHub no primeiro deploy via `set-url` idempotente do script. Pendência registrada: secret `CURSOR_API_KEY` no GitHub fica dormente (helper `archive-cursor-agent.yml`; a key não existe no ambiente — pool morto OPS65) até o usuário pedir a reativação.
|
||||
|
|
@ -1 +0,0 @@
|
|||
**OPS73 — deploy do fix do admin em produção (2026-08-20):** o `/admin` do Payload (jorgesolla1313.com.br) voltou a renderizar o formulário de login em produção — o bug de "tela branca" que travava o CMS do time editorial/campanha está resolvido e verificado. Durante o diagnóstico descobriu-se que a premissa do plano ("produção nunca recebeu o fix OPS69") estava errada: o deploy das 23:09 de 2026-08-19 (revision `8c734ba`) já continha o fix do importMap **no histórico**, mas o commit S9 (`8d2352c0`, captura de novidades na home) tinha regenerado o importMap **sem** as envs `S3_*`, removendo o handler `@payloadcms/storage-s3/client#S3ClientUploadHandler` e reintroduzindo o erro `getFromImportMap: PayloadComponent not found in importMap` — o admin abria em branco de novo ("funcionou e no deploy seguinte parou"). Verificado também que o **main do GitHub havia divergido do Forgejo** (pós-cutover OPS71): o GitHub tinha o fix + OPS71, mas não as features S8 (seção "Nossa história") e S9 (captura de novidades) que produção já rodava — deployar o main como estava regrediria as duas. A entrega: PR #751 re-homou no GitHub main as features S8+S9 (cherry-pick dos commits aprovados no Forgejo `8d2352c0` + `488dcf7d`), mantendo o handler no importMap (prova: `generate:importmap` com envs `S3_*` dummy → diff vazio vs main); CI verde; aguardando o merge. Deploy pós-merge via o pipeline canônico do OPS71 (dispatch manual do `deploy.yml`: `verify` full → job `deploy` no runner self-hosted recém-instalado no homeserver — 2 tentativas "stale run" por merges paralelos no meio do verify, terceira aplicou `dba75ff8`); migração C143 (responsáveis de demandas, aditiva+backfill) aplicada. Verificação pós-deploy: container na revision `dba75ff8`, healthy, **log sem nenhum `getFromImportMap`**, `/admin` renderizando o formulário de login (2 inputs email/senha + botão Login, título "Login - Payload", 0 erros de console), e a home pública mantendo "Nossa história" (S8) e o form de novidades (S9). Evidência: `docs/plans/ops73-deploy-admin-prod-fixed-evidence.png`. Lições registradas: a regeneração do importMap sem envs `S3_*` re-órfão o handler (classe exata que o guard #87 deve impedir no CI); divergência de plataformas pós-cutover — workspace do homeserver ainda apontava para o Forgejo local (`localhost:3000`) e o S10 (pixel) segue pendente de re-home no GitHub.
|
||||
|
|
@ -1,71 +0,0 @@
|
|||
# Runbook — Feed do Instagram: validar/gerar o token (S11)
|
||||
|
||||
Issue: #115 · Entrega: S11 (painel de status da sincronização no admin)
|
||||
|
||||
## O que o painel mostra
|
||||
|
||||
Na global **Configurações → Feed de redes sociais**, o bloco "Instagram — estado
|
||||
da sincronização" diz o estado da última tentativa:
|
||||
|
||||
| Estado | Significado | Ação |
|
||||
| --------------------------------------- | ------------------------------------------------------------------------- | ------------------------------------------------------------------ |
|
||||
| **Sincronizado · há X min · N posts** | Credenciais aceitas; posts no board "Acompanhe de perto" | Nenhuma |
|
||||
| **Falha na última sincronização** | A Graph API recusou a última tentativa; o texto diz o motivo e a correção | Corrigir conforme o motivo e clicar **Tentar sincronizar de novo** |
|
||||
| **Instagram ainda não configurado** | Falta token ou ID | Preencher os dois campos |
|
||||
| **Aguardando a primeira sincronização** | Configurado, nenhuma tentativa ainda | Clicar **Tentar sincronizar de novo** |
|
||||
|
||||
O estado é atualizado no salvar (quando as credenciais mudam), em cada render da
|
||||
home (cache de 5 min) e no clique do botão. A home pública nunca quebra quando a
|
||||
API falha — o board segue com artigos + YouTube.
|
||||
|
||||
## Diagnóstico da ocorrência de 2026-08-19 (causa provável)
|
||||
|
||||
Produção mostrava picker vazio e nenhum card IG com token + ID preenchidos — o
|
||||
fail-closed silencioso da S3 não dizia o porquê. A causa mais provável, na ordem:
|
||||
|
||||
1. **Token emitido via Facebook Login** (page token) — a `graph.instagram.com`
|
||||
recusa; o refresh automático não renova (o endpoint `refresh_access_token`
|
||||
só aceita tokens do Instagram Login).
|
||||
2. **Token expirado/revogado**.
|
||||
3. **ID do usuário errado** (não é o da conta Business/Creator vinculada).
|
||||
|
||||
**Confirmação em 1 clique:** no admin, **Tentar sincronizar de novo** — o painel
|
||||
mostra o motivo exato devolvido pela API. Se a causa for a 1, aparece a mensagem
|
||||
de token recusado com a correção; se for a 3, a mensagem de ID não reconhecido.
|
||||
|
||||
## Validar um token sem o admin
|
||||
|
||||
```bash
|
||||
# O endpoint /me responde com o username e o tipo de conta se o token é válido
|
||||
# para a Graph API do Instagram (Business/Creator):
|
||||
curl -s "https://graph.instagram.com/me?fields=user_id,username&access_token=SEU_TOKEN"
|
||||
|
||||
# Teste o refresh (só tokens emitidos pelo Instagram Login passam):
|
||||
curl -s "https://graph.instagram.com/refresh_access_token?grant_type=ig_refresh_token&access_token=SEU_TOKEN"
|
||||
```
|
||||
|
||||
- `/me` com `OAuthException` → token inválido/expirado ou emitido via
|
||||
**Facebook Login** (a Graph API do Instagram só aceita tokens do Instagram
|
||||
Login).
|
||||
- `/me` ok mas o ID configurado não bate com `user_id` → corrigir o campo
|
||||
"ID do usuário".
|
||||
|
||||
## Gerar um token de longa duração (Instagram Login)
|
||||
|
||||
1. Acessar `developers.facebook.com` com a conta que administra a página →
|
||||
criar/usar um app do tipo **Business** (qualquer tipo serve para o fluxo de
|
||||
negócios, mas o app precisa estar em modo Live para uso fora do sandbox).
|
||||
2. Produto **Instagram** → **Instagram Login with Basic Display** → **Gerar
|
||||
token** com a conta Business/Creator do @depjorgesolla.
|
||||
3. Trocar o token de curta duração pelo de longa duração usando o
|
||||
**Long-Lived Token Exchange** da documentação do Instagram Basic Display
|
||||
(ou usar o endpoint `refresh_access_token` do passo anterior após a troca).
|
||||
4. Colar o token e o ID numérico da conta no admin e clicar
|
||||
**Tentar sincronizar de novo**.
|
||||
|
||||
## O que NÃO fazer
|
||||
|
||||
- Não usar token da API Graph do Facebook (página) — o app recusa.
|
||||
- Não colar o token em arquivos, Issues ou chat — o campo é admin-only e deve
|
||||
continuar.
|
||||
- Não zerar o token para "testar" — o painel de status dispensa tentativa-e-erro.
|
||||
|
|
@ -1,156 +0,0 @@
|
|||
# Runbook: deploy do site 1313 no homeserver (OPS53 + OPS71 — dispatch manual)
|
||||
|
||||
O deploy de produção é uma **action manual** (`workflow_dispatch` no GitHub
|
||||
Actions, `.github/workflows/deploy.yml`): mergir em `main` **não** publica
|
||||
nada. O operador dispara o deploy quando decide publicar; antes de tocar o
|
||||
homeserver, o job `verify` (hosted) roda a suíte **full** (incl. e2e full).
|
||||
|
||||
## Gatilho e fluxo
|
||||
|
||||
1. GitHub → Actions → **Deploy (manual)** → Run workflow (ref: `main`).
|
||||
2. Job `verify` (hosted `ubuntu-latest`, ~50 min): suíte full sem skips —
|
||||
check-test-locations → lint → format → typecheck → knip → cycles → unit →
|
||||
int (migrate+seed nos services) → build → e2e full.
|
||||
3. Se `verify` verde, o job `deploy` (`needs: [verify]`,
|
||||
`runs-on: [self-hosted, homeserver]`) roda no **runner do GitHub instalado
|
||||
no homeserver** e executa `scripts/deploy-homeserver.sh <sha>` **localmente**
|
||||
(sem SSH; o runner conecta outbound ao GitHub — funciona atrás do
|
||||
Cloudflare tunnel; o hosted nunca toca o homeserver; o self-hosted não
|
||||
conta minutos hosted).
|
||||
4. O script, no **homeserver**: guarda de HEAD (`git ls-remote` de
|
||||
`TEQO_REPO_URL` — default `https://github.com/fsolla/teqo.git`, público —
|
||||
== SHA do job, senão "stale run" skip) → `flock` (serializa) → guard
|
||||
"already deployed" (revision do container rodando) → **build do migrator**
|
||||
(o estágio migrator não roda `next build` — builda mesmo contra o schema
|
||||
antigo; BuildKit, secrets do `~/stack/teqo-1313.env`) → push/tag do
|
||||
migrator em `localhost:5000` → swap dos tags de imagem no
|
||||
`~/stack/docker-compose.yml` (backup antes) → **migrations**
|
||||
(`docker compose --profile maintenance run --rm teqo-1313-migrate </dev/null`,
|
||||
já com a imagem do SHA novo) → **build do runner** (contra o banco JÁ
|
||||
migrado — o `next build` da geração estática lê o schema novo, OPS66;
|
||||
`--network host` com proxy socat `teqo-1313-build-proxy` na `stack_default`
|
||||
para alcançar o `postgres`; o proxy é criado idempotentemente pelo script)
|
||||
→ push/tag do runner → `docker compose up -d teqo-1313` → healthcheck →
|
||||
smoke (`/`, `/campanha/login`, `/admin`, barreira 307, WebAuthn
|
||||
login-options, `api/revalidate` com o secret real).
|
||||
5. Falha = job vermelho; nada é publicado pela metade (rollback automático
|
||||
após o swap).
|
||||
|
||||
**Primeiro deploy (verificação ao vivo):** depois do cutover OPS71, dispare o
|
||||
deploy manual e confira no log do job `deploy` o `set-url` do workspace
|
||||
(origem Forgejo → GitHub), o `deployed_sha` lido do container e o guard
|
||||
"already deployed" num segundo dispatch do mesmo SHA.
|
||||
|
||||
## Onde roda cada coisa
|
||||
|
||||
| Máquina | Papel |
|
||||
| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| **GitHub hosted** (ubuntu) | CI de PR (`ci-pr.yml`, job `checks`) e job `verify` do `deploy.yml` (suíte full). Nunca toca o homeserver. |
|
||||
| **homeserver** (8c/16GB) | todo o stack (`~/stack/docker-compose.yml`): postgres `teqo_1313`, forgejo, registry `localhost:5000`, cloudflared, `teqo-1313` na 127.0.0.1:1313; **runner self-hosted do GitHub** (labels `self-hosted`, `homeserver`; instalado 2026-08-19 — Issue #113 OPS71-INFRA; `~/actions-runner` v2.336.0, systemd user `actions.runner.fsolla-teqo.teqo-1313-runner.service` — `systemctl --user status/restart`); workspace `~/teqo-deploy` (clone de `github.com/fsolla/teqo`, público) |
|
||||
| **workstation** | dev/agentes apenas — o runner do Forgejo foi **desligado** no cutover (o schedule do `ci.yml` antigo não tem mais razão; religar é reversível) |
|
||||
|
||||
Segredos **não** ficam no GitHub: o script sourceia `~/stack/teqo-1313.env` e
|
||||
`~/stack/.env` (chmod 600) no próprio homeserver. O repo GitHub é público — o
|
||||
clone do workspace não precisa de credencial. No GitHub ficam apenas os
|
||||
secrets de integração: `FORGEJO_API_TOKEN` (flips pós-merge) e
|
||||
`CURSOR_API_KEY` (archive helper, dormente).
|
||||
|
||||
## Cutover OPS71 (passos manuais, ordem)
|
||||
|
||||
1. Remotes locais (config compartilhada pelos worktrees):
|
||||
`git remote set-url origin git@github.com:fsolla/teqo.git` e
|
||||
`git remote add forgejo ssh://git@192.168.15.142:2222/fsolla/teqo.git`
|
||||
(ou `https://git.solla.dev/fsolla/teqo.git`) — o repo Forgejo congela em
|
||||
main; o tracker (Issues) segue vivo por API.
|
||||
2. GitHub repo: secrets `FORGEJO_API_TOKEN` + `CURSOR_API_KEY`
|
||||
(Settings → Secrets and variables → Actions) e **`Allow auto-merge`**
|
||||
(Settings → General → Pull Requests; ou `PATCH /repos/...` com
|
||||
`allow_auto_merge: true`). Repo é público: em Settings → Actions →
|
||||
General, defina fork PR workflows como não executar (ou exigir aprovação).
|
||||
3. PAT local: criar token GitHub com escopo `repo` e exportar `GITHUB_TOKEN`
|
||||
(para `node scripts/github-pr.mjs` e `pnpm configure:branch-protection`).
|
||||
4. `GITHUB_TOKEN=… pnpm configure:branch-protection` — required check
|
||||
`CI (PR) / checks` (literal de match: `checks` — o GitHub casa pelo nome do
|
||||
check-run; a UI exibe workflow/job; pin PR #742), 0 reviews,
|
||||
`enforce_admins: true`. **Antes do primeiro push** (senão o PR mergearia
|
||||
sem required check).
|
||||
5. **Desligar o runner do Forgejo** na workstation (ex.:
|
||||
`systemctl --user stop forgejo-runner`) — após o primeiro PR GitHub
|
||||
validado; para o schedule do `ci.yml` antigo.
|
||||
6. Instalar o runner self-hosted do GitHub no homeserver (labels
|
||||
`self-hosted` + `homeserver`; usuário com docker + acesso a `~/stack`;
|
||||
registrar em infra-solla: `STATE.md`).
|
||||
7. Push do PR do OPS71 → CI verde → auto-merge → flip da Issue no Forgejo →
|
||||
deploy manual de validação.
|
||||
|
||||
Rollback do cutover (se o GitHub falhar cedo): religar o runner do Forgejo e
|
||||
abrir PRs no Forgejo a partir do main congelado — os workflows de
|
||||
`.forgejo/workflows/` foram removidos na OPS71 Fase 2, mas restauram do git
|
||||
history (`git revert` do PR da remoção, ou clone do main congelado do Forgejo)
|
||||
— remotes: `git remote set-url origin ssh://git@192.168.15.142:2222/fsolla/teqo.git`.
|
||||
|
||||
## Rollback (do deploy)
|
||||
|
||||
A imagem anterior **não** fica mais local após a limpeza pós-deploy (INF3/F2:
|
||||
`docker builder prune` + remoção das tags locais antigas, exceto o SHA em uso)
|
||||
— o registry `localhost:5000` **nunca deleta** e é a fonte do rollback. O
|
||||
compose anterior fica em `~/stack/docker-compose.yml.pre-<sha-do-deploy-que-falhou>`.
|
||||
|
||||
```bash
|
||||
ssh homeserver
|
||||
cd ~/stack
|
||||
# 1. apontar o compose de volta para o SHA anterior (o que estava rodando):
|
||||
# o backup do deploy que falhou é o compose com os tags antigos
|
||||
ls docker-compose.yml.pre-* # escolha o anterior ao deploy ruim
|
||||
cp docker-compose.yml.pre-<sha-anterior> docker-compose.yml
|
||||
# 2. puxar do registry (a imagem antiga já não fica local após a limpeza):
|
||||
docker pull localhost:5000/teqo-1313:<sha-anterior>
|
||||
docker tag localhost:5000/teqo-1313:<sha-anterior> teqo-1313:<sha-anterior>
|
||||
# 3. subir:
|
||||
docker compose up -d teqo-1313
|
||||
```
|
||||
|
||||
**Caveat:** uma migration de schema aplicada no passo de migrate **não** é
|
||||
desfeita pelo rollback (Payload é append-only). Código velho sobre schema novo
|
||||
pode se comportar mal — o caminho primário minimiza essa janela (migrate só
|
||||
roda após migrator build+push+swap ok; rollout só após runner build ok). Desde
|
||||
o OPS66 o migrate roda **antes** do build do runner (o build precisa do schema
|
||||
novo): um build do runner que falhe depois do migrate deixa o banco à frente
|
||||
do código — migrations são append-only e revisadas antes do deploy (checklist);
|
||||
a correção se re-mergeia e o próximo deploy completo publica.
|
||||
|
||||
## Falhas conhecidas
|
||||
|
||||
| Sintoma | Causa | Tratamento |
|
||||
| ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Job verde sem deploy ("stale run") | Push mais novo venceu; o dispatch apontava um SHA que não é mais o HEAD do main do GitHub | Nada a fazer — dispatch novo no main atual |
|
||||
| Job verde sem deploy ("already deployed") | O container rodando já tem a revision do SHA do job (ex.: `workflow_dispatch` duplicado da mesma HEAD) | Nada a fazer — o site já roda esse SHA. Forçar rebuild da MESMA HEAD não é suportado via dispatch: edite o tag da imagem no compose (`image: teqo-1313:<sha>-rebuild`) e `docker compose up -d`, ou espere um commit novo |
|
||||
| Job `deploy` nunca roda / runner offline | Runner self-hosted do homeserver não instalado ou parado (passo manual do cutover OPS71) | Instalar/religar o runner (labels `self-hosted`, `homeserver`); os PRs e o `verify` não dependem dele — só o deploy |
|
||||
| Deploy dispara mas o job `verify` falha | Regressão real na suíte full | Corrigir e re-dispatchar — o homeserver nunca é tocado |
|
||||
| Deploy para logo após o migrate ("Done." e nada mais, EXIT=0) | `docker compose run` anexa stdin por padrão — o container consome o resto do script; o bash chega a EOF e termina sem rodar o rollout | O script já usa `< /dev/null` no `run --rm` do migrate (não remover); sintoma visto 2026-08-17 no primeiro deploy |
|
||||
| Build falha: `network mode "stack_default" not supported by buildkit` | BuildKit (drivers docker e docker-container) recusa rede bridge custom no `--network` | O script builda com `--network host` + proxy socat `teqo-1313-build-proxy` (na `stack_default`, publicado em 127.0.0.1:5433, criado idempotentemente com `restart: unless-stopped`) + `DATABASE_URL` reescrita para o loopback |
|
||||
| Build OOM no homeserver | Laptop 8c/16GB com o stack ativo (~12GB livres medidos) | Re-dispatch; se recorrente, item futuro: build na workstation com túnel |
|
||||
| Build do runner falha: `relation "..." does not exist` no `next build` | Migration nova criou tabela lida em geração estática. Pré-OPS66 a ordem era build→migrate e o deploy morria aqui para sempre (incidente 2026-08-18, S2) | Pós-OPS66 não deve ocorrer: migrate roda antes do build do runner. Se reaparecer, confira no log se o passo migrate rodou; recovery manual: `docker build --target migrator` + `docker run --rm --network stack_default --env-file ~/stack/teqo-1313.env localhost:5000/teqo-1313-migrator:<sha>` e re-dispatch do workflow |
|
||||
| Workspace clone sem o SHA novo | O workspace antigo apontava para o Forgejo local (pré-OPS71) e o fetch não achou o SHA | O script agora re-aponta `origin` para `TEQO_REPO_URL` (idempotente) antes do fetch — se ainda falhar, `git -C ~/teqo-deploy remote -v` e corrigir manualmente |
|
||||
| Migrate falha | Drift/erro de schema | Job vermelho; site segue no container antigo; corrigir e re-mergear |
|
||||
| Smoke falha pós-up | Regressão de runtime | Rollback automático (restore + `up -d`) + job vermelho; investigar |
|
||||
|
||||
## Segurança (decisão deliberada)
|
||||
|
||||
- O job `deploy` usa `runs-on: [self-hosted, homeserver]` — executa como o
|
||||
usuário do runner no homeserver. Está **apenas** no `deploy.yml`
|
||||
(dispatch manual), nunca no `ci-pr.yml`; o hosted (`verify`) não tem acesso
|
||||
ao homeserver e o self-hosted não recebe secrets de produção (as envs vivem
|
||||
no próprio homeserver). `main` só anda por PR mergeado com CI verde; o
|
||||
deploy só roda com `verify` full verde. Fork-PRs não rodam CI (same-repo
|
||||
gate + setting do repo).
|
||||
- Segredos nunca ecoados: sem `set -x`, senhas via `--password-stdin` /
|
||||
build-secrets; envs só no homeserver.
|
||||
|
||||
## Referências
|
||||
|
||||
- `scripts/deploy-homeserver.sh` — o script (fonte da verdade do fluxo)
|
||||
- `.github/workflows/deploy.yml` — verify → deploy
|
||||
- `docs/plans/ops53-ci-deploy-homeserver*.md` — intenção e decisões (era Forgejo)
|
||||
- `docs/plans/ops71-ci-github-actions-tracker-forgejo*.md` — o cutover
|
||||
- infra-solla: `STATE.md`, `plano-infra-final.md` §"Arquitetura de deploy"
|
||||
|
|
@ -1,77 +0,0 @@
|
|||
# Impl: Captura de novidades da campanha na home pública
|
||||
|
||||
Status: rascunho
|
||||
Atualizado em: 2026-08-19
|
||||
Issue: #93
|
||||
Intenção: docs/plans/captura-novidades-home-campanha.md
|
||||
Appetite restante: herdado (~1–1,5 dia eng)
|
||||
|
||||
## Leitura da intenção
|
||||
|
||||
- **Outcome:** visitante da home deixa nome + WhatsApp (obrigatórios; e-mail, estado, cidade e comentário opcionais) numa seção da própria home, escolhe o nível de engajamento num toggle pré-selecionado ("Quero fazer parte do time"), recebe confirmação in-place; o registro vira Contact + assinatura com consentimento novo fail-closed, e a escolha do toggle fica gravada e visível no admin.
|
||||
- **O que NÃO negociar:** nada de `supporter` (bloqueio jurídico Onda 0); consentimento NOVO (não `whatsapp-inscricao`), resolvido por chave estável e fail-closed (sem o texto configurado no admin, o form recusa a captura); sem mescla com `/mandato-no-whatsapp`; CTA secundário do hero vira o atalho da captura **sem esconder as bandeiras** (`#bandeiras` continua acessível — link do rodapé já existe); 6 campos do rascunho, nada mais.
|
||||
- **O que reavaliar:** a hipótese dizia que os campos `NameInput`/`PhoneInput`/`EmailInput`/`StateSelect`/`CitySelect` "já existem e são reutilizáveis" — verdade, mas **`Contact.state` é `NOT NULL` no banco** (migration inicial) e **`StateSelect`/`CitySelect` têm `required` fixo** (usados só pelo `WhatsappForm` hoje). Estado opcional no form exige: `required: false` na collection + migration `DROP NOT NULL`, e um prop `required` nos dois selects (editar o dono, sem gêmeos). A forma do consentimento (um texto vs. dois) fica decidida aqui — ver abordagem.
|
||||
|
||||
## Abordagem recomendada
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
H[CampaignHero<br/>CTA secundário → #novidades] --> S[CampaignNewsletterSection<br/>id=novidades, após o hero]
|
||||
S --> F[CampaignNewsletterForm<br/>cliente, react-hook-form+zod]
|
||||
F -->|submit| A[submitCampaignNewsletter]
|
||||
A --> C[requireConsentByKey<br/>chave campanha-novidades · fail-closed]
|
||||
C --> T[Transação: Contact + Subscription]
|
||||
T -->|ok| X[Sucesso in-place<br/>Ver bandeiras → #bandeiras]
|
||||
A -->|consent ausente| E[Recusa: nenhum dado gravado]
|
||||
```
|
||||
|
||||
**Opções consideradas:** A (dois consents, escolha = relação de consent) · B (**um consent + campo `campaignLevel` na assinatura**) · C (collection nova de captura)
|
||||
**Recomendação:** **B** — porque a escolha do toggle sobrevive como **dado explícito** (`subscription.campaignLevel`, coluna própria no admin), o jurídico aprova **um texto** cobrindo os dois níveis (o rabbit-hole "um consentimento só para tudo" da intenção é cortado justamente quando a escolha **sobrevive no dado** — aqui sobrevive), e o padrão repete o precedente `whatsapp-inscricao` (uma chave por fluxo, `requireConsentByKey` fail-closed). O texto em si continua pendente de aprovação jurídica antes da ativação — nenhum dado é capturado sem o(s) documento(s) no admin.
|
||||
**Rejeitadas:**
|
||||
|
||||
- **A (dois consents, `campanha-novidades-time`/`-esporadico`):** juridicamente o dobro de textos a aprovar; a escolha fica implícita numa relação que o admin teria que interpretar pelo label do documento; e se o jurídico aprovar um texto único, a duplicação em dois docs vira truque. Zero schema change é a única vantagem — e B custa só uma coluna nullable.
|
||||
- **C (collection `campaignNewsletterSignup` nova):** twin do modelo de assinatura; a intenção diz explicitamente "o vínculo reutiliza o modelo de assinatura existente".
|
||||
|
||||
### Componentes / mudanças
|
||||
|
||||
- **`campaignConsentKeys.ts`** (`src/lib/`): `CAMPAIGN_NEWSLETTER_CONSENT_KEY = 'campanha-novidades'` + `CAMPAIGN_NEWSLETTER_CONSENT_MISSING_MESSAGE` ("Consentimento de novidades da campanha ainda não configurado.").
|
||||
- **`campaignConsent.ts`** (`src/utilities/`, edit owner): `requireCampaignNewsletterConsent` no padrão das irmãs.
|
||||
- **`Subscription`** (`src/collections/`): campo novo `campaignLevel` — select `time|esporadico` (labels "Fazer parte do time" / "Comunicações esporádicas", admin label "Nível de engajamento"), **nullable na collection** (linhas do WhatsApp não tocam) e `admin.listColumns` para a coluna aparecer na lista.
|
||||
- **`Contact`** (`src/collections/`): `state` → `required: false` (a ficha staff via zod `contactCreateSchema` continua exigindo — precedente `phones`: collection permissiva, fluxos com regra própria).
|
||||
- **Migration** `pnpm migrate:create` (uma: `ALTER TABLE "contact" ALTER COLUMN "state" DROP NOT NULL` + `CREATE TYPE enum_subscription_campaign_level` + `ALTER TABLE "subscription" ADD COLUMN "campaign_level"`); rebase antes de criar; inspecionar o SQL gerado.
|
||||
- **`schemas/contact.ts`** (edit owner): exportar `contactNameSchema` (hoje const não exportada).
|
||||
- **`schemas/campaign-newsletter.ts`** (novo, `src/lib/schemas/`): `campaignNewsletterSchema` — `name` (`contactNameSchema`, obrigatório), `phone` (`brazilianMobile`, obrigatório, normaliza p/ 11 dígitos), `email` (`optionalPersistedEmail`), `state` (opcional, `''`→undefined), `city` (opcional, min 3 quando presente), `comment` (`commentSchema.shape.comment`), `campaignLevel` (`z.enum(['time','esporadico']).default('time')` — o pré-selecionado do toggle).
|
||||
- **`actions/submitCampaignNewsletter.ts`** (novo, `src/app/(frontend)/actions/`): padrão `submitWhatsapp` — parse zod → `requireConsentByKey` fail-closed → transação (`payload.db.beginTransaction`, `req: { transactionID }`): `contact` (name, email?, `phones: [{ value: phone }]`, state?, city?) + `subscription` (`contact`, `consent`, `campaignLevel`, `comment`) → commit; rollback no erro.
|
||||
- **`StateSelect` / `CitySelect`** (`src/components/`, edit owners): prop `required?: boolean` (default `true`) — o form novo passa `required={false}`; nenhum chamador existente muda.
|
||||
- **`CampaignHero`** (`src/components/`): CTA secundário `href="#novidades"`, texto **duas linhas** "Receba / novidades" (mesma contagem de caracteres de "Conhecer / bandeiras" — preserva as 6 medições pinadas do teste de primeira dobra, ±2px), `aria-label="Receba novidades da campanha"`. **Scroll animado** (decisão de produto 2026-08-19): a âncora depende do `scroll-behavior: smooth` já existente no container `[data-theme="campaign-site"]` (e2e já pina `smooth`) — verificar se a âncora do CTA do hero participa desse container e manter suave; sem smooth no trecho, adicionar no CSS da home (nunca scroll instantâneo).
|
||||
- **`CampaignNewsletterSection`** (novo, `src/app/(frontend)/(home)/`): seção server **logo acima do rodapé** (decisão de produto 2026-08-19: depois da seção de bandeiras, antes do `CampaignFooter`), `id="novidades"`, `data-home-section="newsletter"`, banda `--campaign-band` + `border-y` (mesmo padrão da seção contents), eyebrow/título/copy no vocabulário `campaign-section-*`; compõe o form cliente.
|
||||
- **`CampaignNewsletterForm`** (novo, `src/components/`): cliente, react-hook-form + zodResolver; `NameInput` + `PhoneInput` (obrigatórios), `EmailInput` + `StateSelect`/`CitySelect` (opcionais, placeholders "(opcional)"), `Textarea` comentário, **Checkbox toggle pré-marcado** ("Quero fazer parte do time: receber novidades com frequência, ser adicionado(a) aos grupos de WhatsApp da campanha e participar das ações" + helper "Sem marcar, você recebe apenas comunicações esporádicas…"), botão "QUERO RECEBER NOVIDADES", nota LGPD de rodapé; no sucesso → estado in-place ("Inscrição confirmada" + copy **sem "abaixo"** — as bandeiras ficam acima na home — + botão "Ver bandeiras" → `#bandeiras`). O swap form→sucesso fica no componente cliente — costura pronta para o S10 (pixel) disparar `Lead` aí.
|
||||
- **UI:** Impeccable B — encaixe fiel ao rascunho aprovado; shape → craft → critique → polish.
|
||||
|
||||
### Dados → forma (não se aplica — a captura é o dado; consumo = leitura do admin existente)
|
||||
|
||||
## Fases verificáveis
|
||||
|
||||
1. **Tracer / schema+server** — migration + collections + keys + schema + action + int tests; `pnpm migrate` local; `pnpm exec tsc --noEmit`.
|
||||
2. **UI** — selects opcionais, hero CTA, seção + form + sucesso; e2e: atualizar o teste "animates the flags navigation" (CTA secundário → `#novidades`) e medir/re-pinar o teste de primeira dobra se o ±2px falhar; novo e2e do form (cria o Consent via REST admin, submete, assere "Inscrição confirmada", limpa consent+subscription — chave própria, paralelo-safe).
|
||||
3. **Gates** — `pnpm gate:fast`; `pnpm lint`, `pnpm format:check`, `pnpm exec knip`, `pnpm check:cycles`, `pnpm test`, `pnpm build`; changelog `docs/changelog/2026-08-19-s9.md` + `pnpm changelog:build`; push via `pnpm push`.
|
||||
|
||||
## Rabbit holes / Não escopo (engenharia)
|
||||
|
||||
- Não mexer em `contactCreateSchema` (ficha staff segue exigindo estado — regra do fluxo, não da collection).
|
||||
- Não adicionar footer link "Receba novidades" (intenção pede só CTA do hero + seção).
|
||||
- Não indexar `campaign_level` (sem filtro de admin planejado).
|
||||
- Não enviar e-mail / double opt-in (gatilho futuro se marketing pedir — fora da intenção).
|
||||
|
||||
## Riscos e mitigação
|
||||
|
||||
- **Pins de geometria do hero** (`frontend.e2e.spec.ts` — `secondaryAction` x/y/largura ±2px em 6 viewports): texto com mesma contagem de caracteres ("Receba/novidades" ≈ "Conhecer/bandeiras"); se falhar, re-pinar com valores medidos (a mudança é intencional; o teste preserva a detecção de drift).
|
||||
- **Consent ausente em prod** (bloqueio jurídico pendente): comportamento fail-closed é o aceite — o form recusa a captura com mensagem clara; nenhum dado gravado. Nada no código depende do texto aprovado.
|
||||
- **Estado opcional vs. admin**: contactos sem `state` agora válidos — a ficha staff continua exigindo (zod), listas do admin existentes não filtram por estado obrigatório.
|
||||
- **Concorrência de migration**: rebase em `main` antes de `migrate:create`; a cadeia é validada por `pnpm migrate` + `test:int` no PR.
|
||||
|
||||
## Aceite de engenharia
|
||||
|
||||
- [x] Aceite de produto da intenção ainda coberto (6 campos, toggle pré-marcado gravado, CTA do hero, consent novo fail-closed, bandeiras acessíveis)
|
||||
- [x] Invariantes AGENTS/engineering-standards (transação multi-collection com `req.transactionID`; Consent por chave estável; join com `Contact`; copy pt-BR/identifiers EN; edit owner, sem gêmeos)
|
||||
- [ ] Testes de domínio previstos: int `submitCampaignNewsletter.int.spec.ts` (fail-closed + gravação dos dois níveis + state omitido) e e2e do form → sucesso
|
||||
|
Before Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 13 KiB |
|
|
@ -1,237 +0,0 @@
|
|||
<!doctype html>
|
||||
<html lang="pt-BR">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
|
||||
<title>UI draft — Captura de novidades na home de campanha</title>
|
||||
</head>
|
||||
<body class="bg-zinc-950 text-zinc-100 antialiased">
|
||||
<main class="mx-auto max-w-5xl p-8">
|
||||
<p class="text-sm text-zinc-400">
|
||||
Visitante da home de campanha demonstra apoio e deixa o contato para receber comunicações
|
||||
(e-mail ou grupos de WhatsApp) — CTA do hero leva à seção de captura na própria página.
|
||||
</p>
|
||||
|
||||
<section data-shot="desktop" class="mt-6 overflow-hidden rounded-xl border border-zinc-800">
|
||||
<div class="bg-zinc-900 px-10 pt-12 pb-8">
|
||||
<div class="flex gap-8">
|
||||
<div class="flex-1">
|
||||
<div class="h-3 w-24 rounded bg-zinc-700"></div>
|
||||
<div class="mt-4 h-12 w-72 rounded bg-zinc-700"></div>
|
||||
<div class="mt-2 h-12 w-64 rounded bg-zinc-700"></div>
|
||||
<div class="mt-5 space-y-2">
|
||||
<div class="h-2 w-96 rounded bg-zinc-700"></div>
|
||||
<div class="h-2 w-80 rounded bg-zinc-700"></div>
|
||||
<div class="h-2 w-88 rounded bg-zinc-700"></div>
|
||||
</div>
|
||||
<div class="mt-7 flex gap-3">
|
||||
<div
|
||||
class="inline-flex h-11 items-center justify-center rounded-[10px] bg-red-600 px-5 text-sm font-extrabold text-white"
|
||||
>
|
||||
Quero apoiar
|
||||
</div>
|
||||
<div
|
||||
class="inline-flex h-11 items-center justify-center rounded-[10px] border-2 border-zinc-300 px-5 text-sm font-extrabold text-zinc-100"
|
||||
>
|
||||
Receba novidades
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-8 flex gap-8">
|
||||
<div class="h-2 w-40 rounded bg-zinc-700"></div>
|
||||
<div class="h-2 w-40 rounded bg-zinc-700"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hidden w-64 shrink-0 lg:block">
|
||||
<div class="h-72 w-48 rounded-lg bg-zinc-700/60"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-zinc-100 px-10 py-12 text-zinc-900">
|
||||
<div class="mx-auto max-w-2xl text-center">
|
||||
<div class="mx-auto h-2 w-28 rounded bg-zinc-400"></div>
|
||||
<div class="mx-auto mt-4 h-8 w-96 rounded bg-zinc-400"></div>
|
||||
<div class="mx-auto mt-3 h-2 w-80 rounded bg-zinc-400"></div>
|
||||
<div class="mx-auto mt-3 h-2 w-72 rounded bg-zinc-400"></div>
|
||||
</div>
|
||||
<div class="mx-auto mt-8 max-w-3xl space-y-4">
|
||||
<div
|
||||
class="h-12 rounded-lg border border-zinc-300 bg-white px-4 py-3 text-sm text-zinc-500"
|
||||
>
|
||||
Nome completo*
|
||||
</div>
|
||||
<div
|
||||
class="h-12 rounded-lg border border-zinc-300 bg-white px-4 py-3 text-sm text-zinc-500"
|
||||
>
|
||||
WhatsApp (com DDD)*
|
||||
</div>
|
||||
<div
|
||||
class="h-12 rounded-lg border border-zinc-300 bg-white px-4 py-3 text-sm text-zinc-500"
|
||||
>
|
||||
E-mail (opcional)
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div
|
||||
class="h-12 rounded-lg border border-zinc-300 bg-white px-4 py-3 text-sm text-zinc-500"
|
||||
>
|
||||
Estado (opcional)
|
||||
</div>
|
||||
<div
|
||||
class="h-12 rounded-lg border border-zinc-300 bg-white px-4 py-3 text-sm text-zinc-500"
|
||||
>
|
||||
Cidade (opcional)
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="h-20 rounded-lg border border-zinc-300 bg-white px-4 py-3 text-sm text-zinc-500"
|
||||
>
|
||||
Comentário (opcional)
|
||||
</div>
|
||||
<div class="rounded-lg border border-zinc-300 bg-white p-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<div
|
||||
class="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded border border-red-600 bg-red-600 text-xs font-bold text-white"
|
||||
>
|
||||
✓
|
||||
</div>
|
||||
<div class="text-sm text-zinc-800">
|
||||
<strong>Quero fazer parte do time:</strong> receber novidades com frequência, ser
|
||||
adicionado(a) aos grupos de WhatsApp da campanha e participar das ações.
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-zinc-500">
|
||||
Sem marcar, você recebe apenas comunicações esporádicas da campanha — sem grupos e
|
||||
sem envios frequentes.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mx-auto mt-4 max-w-3xl">
|
||||
<div
|
||||
class="inline-flex h-12 w-full items-center justify-center rounded-lg bg-red-600 text-sm font-extrabold text-white"
|
||||
>
|
||||
QUERO RECEBER NOVIDADES
|
||||
</div>
|
||||
</div>
|
||||
<p class="mx-auto mt-4 max-w-xl text-center text-xs text-zinc-500">
|
||||
Ao enviar, você demonstra apoio à campanha e escolhe como quer acompanhar: comunicações
|
||||
frequentes e grupos de WhatsApp (marcando a opção acima) ou comunicações esporádicas —
|
||||
conforme a Política de Privacidade (LGPD).
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
data-shot="mobile"
|
||||
class="mt-6 w-[390px] overflow-hidden rounded-xl border border-zinc-800"
|
||||
>
|
||||
<div class="bg-zinc-900 px-5 pt-8 pb-6">
|
||||
<div class="h-3 w-20 rounded bg-zinc-700"></div>
|
||||
<div class="mt-4 h-9 w-56 rounded bg-zinc-700"></div>
|
||||
<div class="mt-2 h-9 w-48 rounded bg-zinc-700"></div>
|
||||
<div class="mt-4 space-y-2">
|
||||
<div class="h-2 w-72 rounded bg-zinc-700"></div>
|
||||
<div class="h-2 w-60 rounded bg-zinc-700"></div>
|
||||
</div>
|
||||
<div class="mt-6 flex flex-col gap-2">
|
||||
<div
|
||||
class="inline-flex h-11 items-center justify-center rounded-[10px] bg-red-600 px-5 text-sm font-extrabold text-white"
|
||||
>
|
||||
Quero apoiar
|
||||
</div>
|
||||
<div
|
||||
class="inline-flex h-11 items-center justify-center rounded-[10px] border-2 border-zinc-300 px-5 text-sm font-extrabold text-zinc-100"
|
||||
>
|
||||
Receba novidades
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-zinc-100 px-5 py-8 text-zinc-900">
|
||||
<div class="mx-auto h-2 w-24 rounded bg-zinc-400"></div>
|
||||
<div class="mx-auto mt-4 h-7 w-72 rounded bg-zinc-400"></div>
|
||||
<div class="mx-auto mt-3 h-2 w-64 rounded bg-zinc-400"></div>
|
||||
<div class="mt-6 space-y-3">
|
||||
<div
|
||||
class="h-12 rounded-lg border border-zinc-300 bg-white px-4 py-3 text-sm text-zinc-500"
|
||||
>
|
||||
Nome completo*
|
||||
</div>
|
||||
<div
|
||||
class="h-12 rounded-lg border border-zinc-300 bg-white px-4 py-3 text-sm text-zinc-500"
|
||||
>
|
||||
WhatsApp (com DDD)*
|
||||
</div>
|
||||
<div
|
||||
class="h-12 rounded-lg border border-zinc-300 bg-white px-4 py-3 text-sm text-zinc-500"
|
||||
>
|
||||
E-mail (opcional)
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div
|
||||
class="h-12 rounded-lg border border-zinc-300 bg-white px-4 py-3 text-sm text-zinc-500"
|
||||
>
|
||||
Estado (opcional)
|
||||
</div>
|
||||
<div
|
||||
class="h-12 rounded-lg border border-zinc-300 bg-white px-4 py-3 text-sm text-zinc-500"
|
||||
>
|
||||
Cidade (opcional)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<div
|
||||
class="h-20 rounded-lg border border-zinc-300 bg-white px-4 py-3 text-sm text-zinc-500"
|
||||
>
|
||||
Comentário (opcional)
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3 rounded-lg border border-zinc-300 bg-white p-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<div
|
||||
class="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded border border-red-600 bg-red-600 text-xs font-bold text-white"
|
||||
>
|
||||
✓
|
||||
</div>
|
||||
<div class="text-sm text-zinc-800">
|
||||
<strong>Quero fazer parte do time:</strong> receber novidades com frequência, ser
|
||||
adicionado(a) aos grupos de WhatsApp da campanha e participar das ações.
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-zinc-500">
|
||||
Sem marcar, você recebe apenas comunicações esporádicas da campanha — sem grupos e sem
|
||||
envios frequentes.
|
||||
</p>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<div
|
||||
class="inline-flex h-12 w-full items-center justify-center rounded-lg bg-red-600 text-sm font-extrabold text-white"
|
||||
>
|
||||
QUERO RECEBER NOVIDADES
|
||||
</div>
|
||||
</div>
|
||||
<p class="mx-auto mt-4 max-w-xs text-center text-xs text-zinc-500">
|
||||
Ao enviar, você demonstra apoio à campanha e escolhe como quer acompanhar: comunicações
|
||||
frequentes e grupos de WhatsApp (marcando a opção acima) ou comunicações esporádicas —
|
||||
conforme a Política de Privacidade (LGPD).
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section data-shot="sucesso" class="mt-6 overflow-hidden rounded-xl border border-zinc-800">
|
||||
<div class="bg-zinc-100 px-10 py-16 text-center text-zinc-900">
|
||||
<div class="mx-auto h-10 w-10 rounded-full border-2 border-zinc-400"></div>
|
||||
<div class="mx-auto mt-5 h-7 w-80 rounded bg-zinc-400"></div>
|
||||
<p class="mx-auto mt-4 max-w-md text-sm text-zinc-600">
|
||||
Você vai receber as novidades da campanha. Enquanto isso, acompanhe as bandeiras da
|
||||
campanha abaixo.
|
||||
</p>
|
||||
<div
|
||||
class="mx-auto mt-6 h-11 w-48 rounded-lg border-2 border-zinc-300 bg-white text-sm font-extrabold text-zinc-700"
|
||||
></div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,90 +0,0 @@
|
|||
# Captura de novidades da campanha na home pública
|
||||
|
||||
Status: registrado
|
||||
Atualizado em: 2026-08-19
|
||||
Issue: #93
|
||||
Priority: P1
|
||||
Model: composer-2.5
|
||||
Impeccable: B — encaixe na home de campanha (CTA do hero + nova seção de captura)
|
||||
Rascunho UI: docs/plans/captura-novidades-home-campanha-ui-draft.html + PNGs embutidos abaixo
|
||||
Appetite: ~1–1,5 dia eng; seção de captura na home + CTA no hero + ação transacional com consentimento novo fail-closed
|
||||
Responsável: —
|
||||
|
||||
## Intenção
|
||||
|
||||
O site público de campanha (home `jorgesolla1313.com.br`) hoje converte visitante em **doação** (CTA "Quero apoiar") e em **comunidade WhatsApp** (`/mandato-no-whatsapp`), mas não captura o visitante que demonstra apoio e quer acompanhar a campanha sem necessariamente doar. Sem esse degrau, o tráfego orgânico e pago da home sai sem deixar contato, e o time não tem lista própria de interesse para comunicações (novidades, agenda, ações) e para grupos de WhatsApp da campanha. A campanha está em janela eleitoral (propaganda iniciada 16/08, 1º turno 04/10) — cada visitante não capturado é audiência perdida para as semanas decisivas.
|
||||
|
||||
A captura tem dupla função, confirmada com produto: o contato é **um sinal de apoio** a Solla (a pessoa se dispõe a receber comunicações da campanha) **e** a porta de entrada para comunicações por e-mail **ou inclusão em grupos de WhatsApp da campanha** — por isso o telefone é campo obrigatório.
|
||||
|
||||
## Persona e fluxo
|
||||
|
||||
- **Persona / contexto:** visitante da home de campanha (eleitor simpatizante ou indeciso que quer acompanhar), navegando no celular ou no desktop; demonstra apoio deixando o contato para receber comunicações.
|
||||
- **Job principal:** deixar o contato em segundos (nome + WhatsApp) para receber novidades da campanha, sem sair da página, escolhendo o nível de engajamento.
|
||||
- **Fluxo desejado:**
|
||||
1. Chega na home e vê o CTA secundário do hero apontando para a captura (substitui "Conhecer bandeiras").
|
||||
2. Preenche nome + WhatsApp (obrigatórios) e, opcionalmente, e-mail, estado, cidade e comentário, numa seção da própria home.
|
||||
3. Decide o nível de engajamento num toggle **pré-selecionado** "Quero fazer parte do time": marcado = comunicações frequentes + inclusão em grupos de WhatsApp da campanha + participação nas ações; desmarcado = apenas comunicações esporádicas, sem grupos e sem envios frequentes.
|
||||
4. Envia; a seção vira confirmação in-place ("Inscrição confirmada").
|
||||
5. O registro vira um contato com consentimento próprio **e a escolha do toggle gravada** (nível "time" vs "esporádico"), visível no admin junto das demais listas públicas.
|
||||
- **Anti-goals de produto:** segundo cadastro de pessoa paralelo a `Contact`; gravar `supporter` (registro nominal interno da campanha — bloqueado pelo lote jurídico da Onda 0; este fluxo é captura pública com consentimento próprio, não o registro de apoiador interno); virar mini-CRM com segmentação/fase do funil; mesclar com o fluxo `/mandato-no-whatsapp` (comunidade do mandato, consentimento `whatsapp-inscricao` — canal e semântica diferentes).
|
||||
|
||||
### Rascunho UI (B)
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
Fonte iterável: `docs/plans/captura-novidades-home-campanha-ui-draft.html`.
|
||||
|
||||
## Objetivo e aceite
|
||||
|
||||
- Visitante da home consegue demonstrar apoio e deixar **nome + WhatsApp** (obrigatórios) para receber comunicações da campanha, com e-mail, estado, cidade e comentário opcionais, e confirmação imediata na própria página.
|
||||
- **Toggle "Quero fazer parte do time" pré-selecionado**: marcado = comunicações frequentes + inclusão em grupos de WhatsApp da campanha; desmarcado = apenas comunicações esporádicas (sem grupos, sem envios frequentes). A **escolha fica gravada no registro** — o admin distingue quem é "time" de quem é "esporádico".
|
||||
- O CTA secundário do hero ("Conhecer bandeiras") vira o atalho para a seção de captura — as bandeiras continuam acessíveis na navegação da própria home (âncora `#bandeiras` já existe; o fluxo de captura não pode escondê-las).
|
||||
- O registro é gravado como contato + vínculo de assinatura com **consentimento novo** (não reutiliza `whatsapp-inscricao`), no padrão fail-closed dos fluxos públicos existentes: sem o(s) texto(s) de consentimento aprovado(s) configurado(s) no admin, o form recusa a captura (nenhum dado é gravado).
|
||||
- O(s) texto(s) do consentimento cobre(m) os dois níveis — "fazer parte do time" (comunicações frequentes + grupos de WhatsApp) e "esporádico" — aprovado(s) pelo jurídico antes da ativação.
|
||||
- Guardrail LGPD: nenhuma captura antes do(s) documento(s) de consentimento (chave estável nova) existir(em) no admin — o executor resolve por chave, não por ID; a estrutura exata do consentimento (um texto com dois níveis vs. dois textos) é decisão de implementação junto do jurídico.
|
||||
|
||||
## Dados (intenção)
|
||||
|
||||
- **Vou apresentar dados?** Não — a captura é o dado; o consumo (quantidade de inscritos, crescimento) é leitura do admin existente, sem superfície nova.
|
||||
|
||||
## Direção no codebase (hipótese)
|
||||
|
||||
- **Áreas prováveis:** home `src/app/(frontend)/(home)/page.tsx` (nova seção + composição), hero `src/components/CampaignHero.tsx` (troca do CTA secundário), componente de formulário cliente em `src/components/` no padrão `WhatsappForm` (campos `NameInput`/`PhoneInput`/`EmailInput`/`StateSelect`/`CitySelect` já existem e são reutilizáveis), action em `src/app/(frontend)/actions/` no padrão `submitWhatsapp` (transação `Contact` + `Subscription` com consent por chave estável, `requireConsentByKey` fail-closed), chave nova em `src/lib/campaignConsentKeys.ts`.
|
||||
- **Precedente a olhar:** `src/app/(frontend)/actions/submitWhatsapp.ts` (transação + consent), `src/components/WhatsappForm.tsx` (form e inputs), `src/lib/schemas/whatsapp-form.ts` (schema base `contactSchema` — obrigatoriedade difere: aqui nome + telefone), `/mandato-no-whatsapp` (página irmã — **não** mesclada nesta fatia).
|
||||
- **Risco de acoplamento:** a home é superfície compartilhada do trilho S (últimos itens S1–S6) — toca o mesmo arquivo que o item S8 (pixel) tocará; serializado por dependência.
|
||||
|
||||
## Dependências
|
||||
|
||||
- Nenhuma dura. **S10** (pixel da home) depende deste item para disparar `Lead` no sucesso do form.
|
||||
|
||||
## Fora de escopo
|
||||
|
||||
- Mescla com `/mandato-no-whatsapp` ou reuso do consentimento `whatsapp-inscricao`.
|
||||
- Gravação em `supporter` (registro nominal interno da campanha) — permanece bloqueado pelo lote jurídico da Onda 0.
|
||||
- Segmentação, tags de interesse, double opt-in por e-mail, integração com ferramenta de e-mail marketing — anotar como gatilho futuro se marketing pedir.
|
||||
- Redesign do hero ou da home (mudança de um CTA + uma seção).
|
||||
|
||||
## Rabbit holes de produto
|
||||
|
||||
- **Mini-CRM.** Se alguém "só completar": campos extras, segmentação, fases de funil. **Corte neste item:** os 6 campos do rascunho (nome + WhatsApp obrigatórios; e-mail, estado, cidade, comentário opcionais) e nada mais; o vínculo reutiliza o modelo de assinatura existente.
|
||||
- **"Já que o telefone é obrigatório, vira apoiador do sistema interno".** Puxa o registro nominal bloqueado juridicamente. **Corte:** o registro vive nas listas públicas de contato/assinatura com consentimento próprio; `supporter` só quando o lote jurídico liberar.
|
||||
- **"Um consentimento só para tudo" ou "dois consents mas um só registro".** A escolha do toggle tem que sobreviver no dado, senão o time não sabe quem pode entrar em grupo. **Corte:** a escolha fica gravada e visível no admin; a forma (um texto com os dois níveis vs. dois textos) é decisão de implementação com o jurídico.
|
||||
- **Form igual ao do WhatsApp.** Copiar a semântica do "comunidade do mandato" arrasta canal e consentimento errados. **Corte:** consentimento novo; fluxos permanecem irmãos, não gêmeos.
|
||||
- **Double opt-in / verificação de e-mail.** Vira infraestrutura de e-mail marketing. **Corte:** confirmação in-place sem envio de e-mail; gatilho futuro se marketing pedir.
|
||||
|
||||
## Questões em aberto (produto)
|
||||
|
||||
- **Texto(s) do consentimento?** Novo texto cobrindo os dois níveis — "fazer parte do time" (comunicações frequentes + grupos de WhatsApp + participação nas ações) e "esporádico" (comunicação direta, sem grupos/sem frequência) — com chave estável nova, resolvida fail-closed. **Recomendação:** o jurídico aprova o(s) texto(s) e o admin cria o(s) documento(s) antes da ativação — o app não captura sem ele(s); a chave exata e a estrutura (um vs. dois textos) ficam para a implementação junto do jurídico. _(decisões de campos/local/toggle/Lead já confirmadas com produto em 2026-08-19; só o texto jurídico fica pendente de aprovação)_
|
||||
|
||||
## Referências
|
||||
|
||||
- Rascunho UI (gate): `docs/plans/captura-novidades-home-campanha-ui-draft.html` + PNGs embutidos acima
|
||||
- `src/app/(frontend)/actions/submitWhatsapp.ts` — padrão de escrita transacional com consent fail-closed
|
||||
- `src/components/WhatsappForm.tsx`, `src/lib/schemas/whatsapp-form.ts` — padrão de form cliente e schema
|
||||
- `src/lib/campaignConsentKeys.ts` — keys estáveis existentes
|
||||
- `src/components/CampaignHero.tsx` — CTA "Conhecer bandeiras" (linha ~104–114) a substituir
|
||||
- `AGENTS.md` — `Contact` como pessoa normalizada + joins, consentimento por chave estável, transações multi-collection, bloqueio jurídico de `supporter` (Onda 0)
|
||||
|
|
@ -1,68 +0,0 @@
|
|||
# Impl: Cards da seção "Acompanhe de perto": sem cantos arredondados, imagem de borda a borda, chip removido
|
||||
|
||||
Status: aprovado
|
||||
Atualizado em: 2026-08-19
|
||||
Issue: #90
|
||||
Intenção: docs/plans/cards-acompanhe-de-perto-polimento.md
|
||||
Appetite restante: herdado (~0,5–1 dia; na prática, horas)
|
||||
|
||||
## Leitura da intenção
|
||||
|
||||
- **Outcome:** os cards da seção "Acompanhe de perto" (bento 1+4 no desktop, carrossel no mobile) passam a ter cantos retos (card e capa), capa de borda a borda, **sem borda e sem sombra no card** (decisão do humano em 2026-08-19, após o gate — estende o rascunho, que mantinha `border`/`shadow`) e nenhum chip de badge — nem tipos de post do site, nem origens externas (decisão B do gate). O texto interno (título, subtítulo, meta) continua com o respiro atual (`p-3`) em relação às bordas.
|
||||
- **O que NÃO negociar:** nada além da superfície muda — compartilhamento (S4) usável no canto, grade do bento, carrossel, links, bandas do S5 (borda/background da seção) e o fail-closed da seção (some inteira quando nada é visível). Nada de tokens de cor, capas reais, proporções ou outras seções.
|
||||
- **O que reavaliar:** a hipótese da intenção ("mover o padding para o bloco de texto e remover o badge; dados de badge tendem a ficar órfãos — o executor decide o que descartar"). Confirmado no código: `badgeLabel` vive só no card + nas três funções de mapeamento da seção + `POST_TYPE_BADGE_LABELS` (com um unit test dedicado). Decisão de engenharia: remover os órfãos por completo (tipo do card, constante e teste unitário) — manter morto seria dead code flagrável pelo knip (CI-blocking).
|
||||
|
||||
## Abordagem recomendada
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[CampaignContentCard.tsx<br/>superfície do card] --> B["card: -rounded-xl -p-3 -gap<br/>capa: -rounded-lg<br/>texto: +p-3<br/>badge span: removido"]
|
||||
A --> C["CampaignContentCardData<br/>-badgeLabel"]
|
||||
D[CampaignContentSection.tsx] --> E["-badgeLabel nas 3 funções<br/>-import POST_TYPE_BADGE_LABELS"]
|
||||
F[posts.ts] --> G["-POST_TYPE_BADGE_LABELS"]
|
||||
H[postsDateFormats.unit.spec.ts] --> I["-describe POST_TYPE_BADGE_LABELS"]
|
||||
J[frontend.e2e.spec.ts] --> K["badge loop: 812-814,<br/>YouTube: 952,<br/>Instagram: 1153<br/>→ ausência + raio 0"]
|
||||
```
|
||||
|
||||
**Opções consideradas:** A (superfície no card único + remoção dos órfãos) | B (variante/flag de card, ex. `variant="flat"`) | C (manter `POST_TYPE_BADGE_LABELS` e o badge no DOM com CSS oculto)
|
||||
**Recomendação:** A — o card é o único componente da seção (bento e carrossel reusam `CampaignContentCard`); não há outro consumidor com cantos arredondados para preservar, então uma variante seria abstração sem dono. A remoção dos dados de badge é limpa: `badgeLabel` não é usado em mais nenhum lugar do repo.
|
||||
**Rejeitadas:** B (over-engineering: nenhum outro card usa o componente; "edit the owner, don't twin"); C (dead code + dead data path; knip derruba o CI e o teste unitário continuaria pinando um contrato morto).
|
||||
|
||||
### Componentes / mudanças
|
||||
|
||||
- **`CampaignContentCard.tsx`** (`src/components/`): `cardClassName` vira constante sem `featured` (o único uso do param era o `gap-3`/`gap-2`; `featured` continua dirigindo `sizes` da imagem e o `h3`): `flex h-full flex-col bg-white transition-shadow ...` — sem `rounded-xl`, sem `p-3`, sem `gap`, **sem `border border-(--campaign-line)` e sem `shadow-sm`/`hover:shadow-md`/`transition-shadow`**. Capa: remove `rounded-lg` (mantém `overflow-hidden` — o zoom hover continua contido na própria capa). Bloco de texto: ganha `p-3` (`flex flex-1 flex-col gap-1 p-3`), igual ao draft. Badge `<span>` removido; `badgeLabel` sai de `CampaignContentCardData`. O anel de foco (`focus-visible:ring-2 ring-(--pt-red)`) do link permanece — a11y.
|
||||
- **`CampaignContentSection.tsx`** (`src/components/`): `badgeLabel` removido de `toArticleCardData`/`toVideoCardData`/`toInstagramCardData`; import `POST_TYPE_BADGE_LABELS` removido. Nada mais muda (limites, mesclagem, capas, metas).
|
||||
- **`src/utilities/posts.ts`**: deleta `POST_TYPE_BADGE_LABELS` (único consumidor era a seção; `POST_TYPE_LABELS` plural fica — usado nas listagens).
|
||||
- **`tests/unit/postsDateFormats.unit.spec.ts`**: import e describe de `POST_TYPE_BADGE_LABELS` removidos.
|
||||
- **Migration:** sem migration (nenhuma mudança de schema/coleção).
|
||||
- **Access / Consent:** nenhum.
|
||||
- **UI:** Impeccable B — encaixe em um componente existente (shape→craft→critique→polish). O botão `ContentShareButton` (S4) segue `absolute top-2 right-2` sobre a capa, agora sem moldura; o draft mantém o botão no canto — sem mudança nele.
|
||||
|
||||
### Dados → forma (se aplicável)
|
||||
|
||||
- N/A — nenhum dado novo; apenas remoção de um campo de superfície (`badgeLabel`) do tipo de dados do card.
|
||||
|
||||
## Fases verificáveis
|
||||
|
||||
1. **Superfície + órfãos** — `CampaignContentCard.tsx`, `CampaignContentSection.tsx`, `posts.ts`, unit test do badge; verificação: `pnpm typecheck` + `pnpm test:unit` (o describe removido some, o resto passa).
|
||||
2. **e2e** — `tests/e2e/frontend.e2e.spec.ts`: loop de badges (812–814) e as asserções `YouTube`/`Instagram` (952, 1153) → asserções de ausência (`toHaveCount(0)` para os quatro tipos + YouTube/Instagram nos testes de feed) e pin do novo visual no teste do bento (`border-radius: 0px` no card e na capa, `border-top-width: 0px` e `box-shadow: none` no card, no mesmo espírito dos pins CSS do S5). `toHaveCSS` com `first()` para não quebrar com o carrossel escondido no mobile.
|
||||
3. **Gates** — `pnpm lint`, `pnpm format:check`, `pnpm exec knip`, `pnpm check:cycles`, `pnpm test` (unit+int). E2e roda no CI (OPS59) — local opcional com `pnpm test:e2e:affected`.
|
||||
|
||||
## Rabbit holes / Não escopo (engenharia)
|
||||
|
||||
- Não tocar `ContentShareButton`, `CampaignContentCarousel`, banda/bordas da seção (S5), `POST_TYPE_LABELS` (plural), `getVisiblePosts`/fail-closed ou capas. O rascunho HTML do gate fica intocado como artefato — a remoção de borda/sombra é decisão do humano posterior ao gate e não altera o arquivo do rascunho.
|
||||
- Não criar variante de card, não esconder badge via CSS, não renomear `h3`/metas.
|
||||
- Cuidado com o `gap` removido: o `mt-auto` da meta continua pinando a data ao rodapé do bloco; o `p-3` do bloco de texto é quem dá o respiro (draft).
|
||||
|
||||
## Riscos e mitigação
|
||||
|
||||
- **e2e que assere badge que não mapeei** — verificado por grep: só 812–814 (loop), 952 (YouTube) e 1153 (Instagram) no `frontend.e2e.spec.ts`; `waitForHomeHTML` só usa títulos/links de cabeçalho. Grep final antes do push.
|
||||
- **Raio 0 pinado pelo e2e pode flake por arredondamento** — `toHaveCSS('border-radius', '0px')` é determinístico (nenhum token de raio no card/capa).
|
||||
- **Dead code residual** — `POST_TYPE_BADGE_LABELS` e `badgeLabel` removidos do mesmo PR; knip no gate confirma.
|
||||
- **Capa sem cover (placeholder `--campaign-band`)** — continua com borda a borda e cantos retos, igual ao draft; nada quebra.
|
||||
|
||||
## Aceite de engenharia
|
||||
|
||||
- [ ] Aceite de produto da intenção ainda coberto (raio 0 card+capa, capa borda a borda, texto `p-3`, nenhum chip em nenhum card, **sem borda e sem sombra no card**)
|
||||
- [ ] Invariantes AGENTS/engineering-standards (card único como dono da superfície; S5/bandas intocadas; fail-closed da seção intacto)
|
||||
- [ ] Testes de domínio previstos (unit/int) onde access/write paths mudam — N/A (nenhum write path muda); e2e atualizados para o novo estado visual
|
||||
|
Before Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 29 KiB |
|
|
@ -1,143 +0,0 @@
|
|||
<!doctype html>
|
||||
<html lang="pt-BR">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
|
||||
<title>
|
||||
UI draft — Cards da seção "Acompanhe de perto": sem raio, imagem de borda a borda, chip
|
||||
removido
|
||||
</title>
|
||||
</head>
|
||||
<body class="bg-zinc-950 text-zinc-100 antialiased">
|
||||
<main class="mx-auto max-w-6xl p-8">
|
||||
<p class="text-sm text-zinc-400">
|
||||
Visitante da home pública de campanha (jorgesolla1313) — cards da seção "Acompanhe de perto"
|
||||
propostos: sem cantos arredondados, imagem de borda a borda no card, texto com respiro
|
||||
interno, chip removido de todos os cards (posts do site e origens externas). Cenário 1:
|
||||
desktop. Cenário 2: mobile ~390px.
|
||||
</p>
|
||||
|
||||
<section data-shot="desktop" class="mt-8 border-y border-zinc-800 bg-zinc-200">
|
||||
<div class="mx-auto w-full max-w-[1160px] px-5 py-10 sm:px-8 lg:px-10">
|
||||
<div class="flex flex-wrap items-end justify-between gap-3">
|
||||
<div>
|
||||
<p class="text-xs font-black uppercase tracking-[0.1em] text-red-700">
|
||||
Acompanhe de perto
|
||||
</p>
|
||||
<h2 class="mt-1 text-2xl font-black tracking-tight text-zinc-900">
|
||||
A caminhada, em tempo real
|
||||
</h2>
|
||||
<p class="mt-1 text-sm text-zinc-600">
|
||||
Bastidores, caravanas e as lutas do mandato: conteúdo atualizado, direto das redes.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-4 text-sm font-bold text-red-700 underline-offset-4">
|
||||
<span>Ver artigos →</span>
|
||||
<span>Seguir no Instagram →</span>
|
||||
<span>YouTube →</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 grid grid-cols-4 gap-4">
|
||||
<div class="col-span-2 row-span-2">
|
||||
<article class="flex h-full flex-col border border-zinc-400 bg-white shadow-sm">
|
||||
<div class="relative aspect-video w-full shrink-0 bg-zinc-300"></div>
|
||||
<div class="flex flex-1 flex-col gap-1 p-3">
|
||||
<h3 class="text-base font-bold leading-snug text-zinc-900">
|
||||
Caravana do Jorge chega ao interior com mutirão de saúde
|
||||
</h3>
|
||||
<p class="text-xs leading-relaxed text-zinc-600">
|
||||
Equipe percorreu quatro municípios e levou atendimento, roda de conversa e a
|
||||
defesa da retomada do programa.
|
||||
</p>
|
||||
<span class="mt-auto text-xs text-zinc-600">há 2 horas · Saúde</span>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<article class="flex h-full flex-col border border-zinc-400 bg-white shadow-sm">
|
||||
<div class="relative aspect-video w-full shrink-0 bg-zinc-300"></div>
|
||||
<div class="flex flex-1 flex-col gap-1 p-3">
|
||||
<h3 class="text-sm font-bold leading-snug text-zinc-900">
|
||||
Plenária em Feira de Santana debate a reforma tributária
|
||||
</h3>
|
||||
<span class="mt-auto text-xs text-zinc-600">há 5 horas · Eleitoral</span>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="flex h-full flex-col border border-zinc-400 bg-white shadow-sm">
|
||||
<div class="relative aspect-video w-full shrink-0 bg-zinc-300"></div>
|
||||
<div class="flex flex-1 flex-col gap-1 p-3">
|
||||
<h3 class="text-sm font-bold leading-snug text-zinc-900">
|
||||
Jorge em visita à fábrica de remédios no Polo Petroquímico
|
||||
</h3>
|
||||
<span class="mt-auto text-xs text-zinc-600">há 1 dia · Mandato</span>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="flex h-full flex-col border border-zinc-400 bg-white shadow-sm">
|
||||
<div class="relative aspect-video w-full shrink-0 bg-zinc-300"></div>
|
||||
<div class="flex flex-1 flex-col gap-1 p-3">
|
||||
<h3 class="text-sm font-bold leading-snug text-zinc-900">
|
||||
Direto da caminhada: o povo no centro do projeto
|
||||
</h3>
|
||||
<span class="mt-auto text-xs text-zinc-600">há 1 dia · 12 mil visualizações</span>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="flex h-full flex-col border border-zinc-400 bg-white shadow-sm">
|
||||
<div class="relative aspect-square w-full shrink-0 bg-zinc-300"></div>
|
||||
<div class="flex flex-1 flex-col gap-1 p-3">
|
||||
<h3 class="text-sm font-bold leading-snug text-zinc-900">
|
||||
Mutirão de castração gratuita reúne famílias no bairro
|
||||
</h3>
|
||||
<span class="mt-auto text-xs text-zinc-600">há 2 dias</span>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section data-shot="mobile" class="mt-8 w-[390px] border-y border-zinc-800 bg-zinc-200">
|
||||
<div class="px-5 py-8">
|
||||
<p class="text-xs font-black uppercase tracking-[0.1em] text-red-700">
|
||||
Acompanhe de perto
|
||||
</p>
|
||||
<h2 class="mt-1 text-xl font-black tracking-tight text-zinc-900">
|
||||
A caminhada, em tempo real
|
||||
</h2>
|
||||
<p class="mt-1 text-sm text-zinc-600">
|
||||
Bastidores, caravanas e as lutas do mandato: conteúdo atualizado, direto das redes.
|
||||
</p>
|
||||
|
||||
<div class="mt-5">
|
||||
<article class="flex h-full flex-col border border-zinc-400 bg-white shadow-sm">
|
||||
<div class="relative aspect-video w-full shrink-0 bg-zinc-300"></div>
|
||||
<div class="flex flex-1 flex-col gap-1 p-3">
|
||||
<h3 class="text-sm font-bold leading-snug text-zinc-900">
|
||||
Direto da caminhada: o povo no centro do projeto
|
||||
</h3>
|
||||
<p class="text-xs leading-relaxed text-zinc-600">
|
||||
Do comício em Salvador aos mutirões no interior, o dia a dia da campanha em vídeo,
|
||||
direto do canal.
|
||||
</p>
|
||||
<span class="mt-auto text-xs text-zinc-600">há 1 dia · 12 mil visualizações</span>
|
||||
</div>
|
||||
</article>
|
||||
<div class="mt-3 flex items-center justify-center gap-2">
|
||||
<span class="h-2 w-2 rounded-full bg-red-700"></span>
|
||||
<span class="h-2 w-2 rounded-full bg-zinc-500"></span>
|
||||
<span class="h-2 w-2 rounded-full bg-zinc-500"></span>
|
||||
<span class="h-2 w-2 rounded-full bg-zinc-500"></span>
|
||||
<span class="h-2 w-2 rounded-full bg-zinc-500"></span>
|
||||
</div>
|
||||
<p class="mt-2 text-center text-xs font-semibold text-zinc-600">
|
||||
1 de 5 · deslize para ver os próximos
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,107 +0,0 @@
|
|||
# Cards da seção "Acompanhe de perto": sem cantos arredondados, imagem de borda a borda, chip removido
|
||||
|
||||
Status: rascunho
|
||||
Atualizado em: 2026-08-19
|
||||
Issue: #90
|
||||
Priority: P2
|
||||
Model: composer-2.5
|
||||
Impeccable: B — encaixe nos cards da seção "Acompanhe de perto" da home pública (jorgesolla1313)
|
||||
Rascunho UI: docs/plans/cards-acompanhe-de-perto-polimento-ui-draft.html + PNGs embutidos abaixo
|
||||
Appetite: ~0,5–1 dia de eng — CSS em um componente + ajuste de teste
|
||||
Responsável: —
|
||||
|
||||
## Intenção
|
||||
|
||||
Os cards da seção "Acompanhe de perto" (a home de campanha, bento 1+4 no desktop e carrossel no
|
||||
mobile) ficaram com cara de "card de app": cantos arredondados, imagem de capa emoldurada por um
|
||||
respiro de padding e um chip "Notícia" repetido em todo post do site. O desejo é um look mais
|
||||
editorial e direto: **cantos retos**, **imagem de capa de borda a borda no card** e **chips de
|
||||
badge removidos de todos os cards** (posts do site e origens externas — decisão do gate) — o
|
||||
texto interno continua com respiro em relação às bordas do card. Nada além disso muda: o mesmo
|
||||
componente serve o bento e o carrossel, e a mudança vale para os dois.
|
||||
|
||||
## Persona e fluxo
|
||||
|
||||
- **Persona / contexto:** visitante da home pública de campanha, navegando no celular ou desktop;
|
||||
rola até a seção "Acompanhe de perto" e escaneia os cards.
|
||||
- **Job principal:** ver o conteúdo de relance — a imagem é o chamariz, o título e a data seguem
|
||||
abaixo, sem ruído de etiqueta repetida.
|
||||
- **Fluxo desejado:** a seção abre como hoje; cada card mostra a capa em tela cheia, cantos retos,
|
||||
e o texto respira da borda; nenhum chip de badge polui a leitura; o compartilhamento (S4) segue
|
||||
disponível no canto.
|
||||
- **Anti-goals de produto:** não redesenhar a seção (banda de fundo do S5, textos, links e
|
||||
carrossel seguem iguais); não mexer em outras seções da home; não mudar tokens de cor ou o
|
||||
comportamento fail-closed da seção.
|
||||
|
||||
### Rascunho UI (B/C/D)
|
||||
|
||||
Cena 1 (desktop — bento 1+4): cards sem raio, capa de borda a borda, texto com `p-3`, nenhum
|
||||
chip de badge em nenhum card:
|
||||
|
||||

|
||||
|
||||
Cena 2 (mobile ~390px — carrossel): mesmo card do carrossel, capa de borda a borda, sem chip:
|
||||
|
||||

|
||||
|
||||
## Objetivo e aceite
|
||||
|
||||
- Nenhum card da seção tem cantos arredondados — nem o card, nem a capa (desktop e mobile).
|
||||
- A capa encosta nas bordas superior/laterais do card (sem padding no contêiner do card); o texto
|
||||
(título, subtítulo, meta) tem padding interno consistente com o respiro atual (`p-3`) em relação
|
||||
às bordas do card.
|
||||
- O chip de tipo do post do site ("Notícia" e os demais tipos de post) some dos cards de posts —
|
||||
e, por decisão do gate, os chips de origem externa (YouTube/Instagram) também são removidos:
|
||||
nenhum chip de badge aparece em nenhum card.
|
||||
- O compartilhamento (botão de canto, S4), a grade do bento, o carrossel, os links e o
|
||||
comportamento fail-closed da seção continuam funcionando; o e2e que assere os textos de badge
|
||||
(`tests/e2e/frontend.e2e.spec.ts`, loop de `Artigo|Notícia|Campanha|Evento`) é atualizado para o
|
||||
novo estado visual (e o e2e dos feeds externos, se asserir "YouTube"/"Instagram" na seção).
|
||||
|
||||
## Dados (intenção)
|
||||
|
||||
- **Vou apresentar dados?** Não — nenhum número novo; só a superfície dos cards muda.
|
||||
|
||||
## Direção no codebase (hipótese)
|
||||
|
||||
- **Áreas prováveis:** `src/components/CampaignContentCard.tsx` (único componente de card —
|
||||
classes `rounded-xl`/`rounded-lg` do card e da capa, `p-3` do contêiner, `span` do badge no
|
||||
canto superior esquerdo da capa; mover o padding para o bloco de texto e remover o badge); o
|
||||
carrossel (`CampaignContentCarousel.tsx`) herda a mudança por reuso do mesmo card; os dados de
|
||||
badge (`badgeLabel` em `CampaignContentSection.tsx` / `POST_TYPE_BADGE_LABELS` em
|
||||
`src/utilities/posts.ts`) tendem a ficar órfãos com a remoção de todos os chips — o executor
|
||||
decide o que descartar sem quebrar os e2e.
|
||||
- **Precedente a olhar:** `docs/plans/separacao-visual-secao-conteudos-home.md` (S5 — banda da
|
||||
seção, que não deve ser tocada) e `docs/plans/secao-conteudos-home-artigos.md` (S1).
|
||||
- **Risco de acoplamento:** o e2e da home (`tests/e2e/frontend.e2e.spec.ts`) assere os textos de
|
||||
badge na seção; o `ContentShareButton` vive no canto do card e deve continuar usável sobre a
|
||||
capa sem padding.
|
||||
|
||||
## Dependências
|
||||
|
||||
- Nenhuma (S1–S6 entregues e em produção).
|
||||
|
||||
## Fora de escopo
|
||||
|
||||
- Remover borda ou sombra dos cards (não pedido — a definição contra a banda do S5 continua).
|
||||
- Mudar textos, links, ordem, capas reais, proporção das capas ou o carrossel em si.
|
||||
- Polimento de outras seções da home ou tokens de cor.
|
||||
|
||||
## Rabbit holes de produto
|
||||
|
||||
- **"Já que os cards ficaram retos, aproveita e redesenha a seção".** Explosão: nova paleta,
|
||||
microinterações, outras seções. **Corte neste item:** só raio, padding do card e chip.
|
||||
|
||||
## Questões em aberto (produto)
|
||||
|
||||
- **Qual o alcance da remoção do chip?** **Decidido no gate (2026-08-19): B — remover todos os
|
||||
chips, incluindo YouTube/Instagram** (a identificação da plataforma de origem deixa de aparecer
|
||||
no card; o link externo segue sinalizado por abrir em nova aba).
|
||||
|
||||
## Referências
|
||||
|
||||
- Rascunho UI (gate): `docs/plans/cards-acompanhe-de-perto-polimento-ui-draft.html` + PNGs acima
|
||||
- `src/components/CampaignContentCard.tsx` — card único (bento + carrossel)
|
||||
- `src/components/CampaignContentSection.tsx` — montagem da seção (badges por fonte)
|
||||
- `src/components/ContentShareButton.tsx` — controle de compartilhar do canto (S4, intocado)
|
||||
- `tests/e2e/frontend.e2e.spec.ts` — asserções de badge da seção a atualizar
|
||||
|
|
@ -1,107 +0,0 @@
|
|||
# Impl: Compartilhar conteúdos da home no WhatsApp (S4)
|
||||
|
||||
Status: aprovado
|
||||
Atualizado em: 2026-08-19
|
||||
Issue: #19
|
||||
Intenção: docs/plans/compartilhar-conteudos-home-whatsapp.md
|
||||
Appetite restante: herdado (~0,5–1 dia eng)
|
||||
|
||||
## Pós-entrega (2026-08-19)
|
||||
|
||||
Entregue e mergeado em `main`. Desvios de execução em relação a este plano:
|
||||
|
||||
- **`buildWhatsAppTextShareUrl` já existia** em `src/lib/homeSearchShare.ts` (S6) — em vez do builder próprio do rascunho, o `contentShare.ts` reusa o canônico, e o dono do builder foi movido para `src/lib/phone.ts` (o módulo wa.me) com re-export no `homeSearchShare.ts` (sem twin).
|
||||
- **Roles `menu`/`menuitem` removidos do popover** (achado P1 do critique): Radix Popover não implementa o contrato de widget de menu (setas/roving focus); semântica honesta é popover de 2 ações com Tab — Radix emite `aria-expanded` no trigger.
|
||||
- **Encoding do `text` é `+` para espaço** (URLSearchParams do builder canônico) — válido para o WhatsApp; os decodes de teste fazem `replace(/\+/g, ' ')`.
|
||||
- **Débito deferido:** dedupe do blob de 15 campos do `updateSocialFeedSettings` (5 cópias no describe do e2e) — quando os testes S2/S3 forem tocados de novo.
|
||||
|
||||
## Leitura da intenção
|
||||
|
||||
- **Outcome:** todo card de conteúdo da home (Artigo/YouTube/Instagram — seção S1+S2+S3 entregue) ganha um botão discreto de compartilhar que abre `wa.me` com mensagem pronta por fonte + "Copiar link", sem competir com o CTA do card, sem coletar nada.
|
||||
- **O que NÃO negociar:** mensagem por fonte (artigo/vídeo/post, decisão B travada); link = artigo → página canônica, YT/IG → URL da plataforma; nova aba com `noopener`; botão acessível (aria-label, alvo ≥44px no mobile) e sem mudar o layout do card; sem migration/schema/Consent — montagem de URL no cliente, sem escrita no servidor; mensagem honesta.
|
||||
- **O que reavaliar:** a hipótese "reusar `buildWhatsAppUrl` (`src/utilities/phone.ts`)" está **errada** — (a) o módulo é `src/lib/phone.ts`, e (b) `buildWhatsAppUrl` **exige telefone brasileiro válido e lança erro sem ele**; S4 não tem destinatário — é o `wa.me/?text=` do próprio remetente (precedente exato: `PetitionSuccessDialog.tsx:39`). Também reavaliar: o card inteiro é um `<a>`/`<Link>` — um `<button>` aninhado seria HTML inválido e o clique navegaria; o botão precisa ser **irmão** do anchor.
|
||||
|
||||
## Abordagem recomendada
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Section["CampaignContentSection (server)<br/>mappers setam shareKind<br/>article|video|instagram"]
|
||||
Card["CampaignContentCard (server)<br/>wrapper relative + anchor irmão"]
|
||||
Button["ContentShareButton (client)<br/>Popover (Radix, portal)"]
|
||||
Menu["menu role=menu:<br/>Compartilhar no WhatsApp / Copiar link"]
|
||||
Wa["wa.me/?text=msg encodada<br/>target _blank noopener noreferrer"]
|
||||
Copy["navigator.clipboard<br/>label 'Link copiado' 2s"]
|
||||
Lib["contentShare.ts (puro)<br/>mensagem por fonte + URL absoluta + wa.me"]
|
||||
|
||||
Section --> Card
|
||||
Card --> Button
|
||||
Button --> Menu
|
||||
Menu --> Wa
|
||||
Menu --> Copy
|
||||
Button -. monta .-> Lib
|
||||
```
|
||||
|
||||
**Opções consideradas:** A | B | C (por decisão abaixo)
|
||||
**Recomendação:** descrita nas decisões — irmão do anchor + `wa.me/?text=` + Popover Radix.
|
||||
**Rejeitadas:** `buildWhatsAppUrl` (exige telefone; throw); botão aninhado no anchor (HTML inválido, clique navega); `navigator.share` nativo (não é o aceite — WhatsApp + copiar link); menu absoluto caseiro (reimplementa portal/foco/fora/escape que o `ui/Popover` já tem).
|
||||
|
||||
### Decisões de engenharia
|
||||
|
||||
**D1 — Estrutura do card (o botão não cabe dentro do anchor).**
|
||||
Opções: A) wrapper `relative` no card + anchor irmão + botão absoluto (`z-10`, visual 28px em área de toque 44px, canto superior direito — `group` migra para o wrapper, preservando o zoom do cover e o `h-full` da cadeia bento/li) | B) botão aninhado com `preventDefault`/stopPropagation | C) âncora só no conteúdo e botão em fluxo normal.
|
||||
Recomendação: **A** — siblings resolvem propagação e a11y por construção, sem `preventDefault` mágico; zero mudança de layout (absoluto). Rejeitadas: B (interactive dentro de interactive — invalid HTML, `role` confuso para leitor de tela); C (muda a hit-area/estética do card — viola "não muda o layout").
|
||||
|
||||
**D2 — Link absoluto (artigo é caminho relativo).**
|
||||
Opções: A) montar no cliente no clique: `new URL(href, window.location.origin)` — hrefs YT/IG já absolutos passam inalterados; origem é a do visitante real (preview correto) | B) resolver `siteUrl` no server (metadata global) e passar link absoluto no card data.
|
||||
Recomendação: **A** — casa com o aceite "montagem de URL no cliente"; zero dado novo no contrato do card; precedente `PetitionSuccessDialog` (`window.location.href`). Rejeitada: B (acopla a seção à leitura da global + prop extra sem necessidade).
|
||||
|
||||
**D3 — Mensagem por fonte (aceite travado).**
|
||||
Opções: A) campo explícito `shareKind: 'article' | 'video' | 'instagram'` no `CampaignContentCardData`, setado pelos 3 mappers da seção (ponto único de renderização) | B) derivar da fonte por heurística (`badgeLabel`, forma do href).
|
||||
Recomendação: **A** — `badgeLabel` é display (mudaria a msg se a label mudar); href é heurística frágil. A lógica pura fica em `src/lib/contentShare.ts` (unit-testável): prefixo por kind (`Olha isso do Solla: ` / `Olha esse vídeo do Solla: ` / `Olha esse post do Solla: `) + `{título} — {link}`.
|
||||
|
||||
**D4 — Menu (clipping do carrossel mobile).**
|
||||
Opções: A) Popover Radix (`src/components/ui/Popover.tsx` — portal, escape, clique-fora, foco de graça) | B) menu absoluto caseiro dentro do card | C) Sheet/Drawer no mobile.
|
||||
Recomendação: **A** — o track do `CampaignContentCarousel` é `overflow-x-auto`; um absoluto caseiro estouraria o box do card e criaria scrollbar vertical/clipping; o portal do Radix escapa qualquer container. Rejeitadas: B (reimplementa foco/fora/escape e arrisca clipping); C (overkill para 2 ações). Conferir no craft o token de superfície do conteúdo (`bg-popover` existe no tema público? senão `bg-white` + `border-(--campaign-line)` igual ao card).
|
||||
|
||||
**D5 — Feedback de copiar.**
|
||||
Opções: A) swap inline do item para "Link copiado" + `CheckIcon` por 2s (padrão `LeadershipInviteButtons`/`CalendarFeedDialog`) | B) `toast` (sonner).
|
||||
Recomendação: **A** — o layout público não monta `<Toaster>`; swap inline é o padrão do repo. `navigator.clipboard` com try/catch (fallback de label em falha).
|
||||
|
||||
### Componentes / mudanças
|
||||
|
||||
- **`ContentShareKind` + `buildContentShareMessage` + `buildContentShareLink` + `buildContentShareWhatsAppUrl`** (`src/lib/contentShare.ts`, novo, puro): frase por fonte, resolução `new URL(href, origin)` e `https://wa.me/?text=${encodeURIComponent(message)}` — a forma do `PetitionSuccessDialog` com `noopener noreferrer` no call site.
|
||||
- **`CampaignContentCardData.shareKind`** (novo campo, obrigatório) + **`CampaignContentCard`** (server): wrapper `relative` (absoluto do botão), anchor irmão; renderiza `<ContentShareButton kind title href />`. Sem migration.
|
||||
- **`CampaignContentSection`**: os 3 mappers (`toArticleCardData`/`toVideoCardData`/`toInstagramCardData`) setam `shareKind`. Único ponto — nada duplicado por plataforma.
|
||||
- **`ContentShareButton`** (`src/components/ContentShareButton.tsx`, novo, client): trigger `aria-label="Compartilhar"` (`Share2Icon` — já usado no repo), visual 28px `rounded-full bg-white shadow` sobre área de toque ≥44px (`h-11 w-11`), `PopoverTrigger` + `PopoverContent` (portal) com `role="menu"`; itens: `<a href={waUrl} target="_blank" rel="noopener noreferrer">` com `WhatsAppIcon` (de `socialIcons`, usado no público) e botão "Copiar link" (`CopyIcon` → "Link copiado" + `CheckIcon`).
|
||||
- **Migration:** nenhuma. **Access/Consent:** nenhum — zero escrita, zero leitura nova.
|
||||
- **UI:** Impeccable B (discreto, não compete com o CTA) — shape → craft → critique → polish no padrão da seção (tokens `--campaign-*`, `--pt-red` no foco, `motion-reduce`).
|
||||
|
||||
## Fases verificáveis
|
||||
|
||||
1. **Lib pura + unit** — `src/lib/contentShare.ts` + `tests/unit/contentShare.unit.spec.ts` (frases por fonte, resolução de link relativo/absoluto, encoding do `wa.me`, round-trip `decodeURIComponent`).
|
||||
2. **Card + seção** — wrapper relativo + `shareKind` nos mappers + `ContentShareButton` com Popover; verificar visual no dev (bento desktop + carrossel mobile, incl. card inerte do carrossel).
|
||||
3. **E2E** — dentro do describe `Campaign home content section` (`tests/e2e/frontend.e2e.spec.ts`, serial, fixtures/stubs existentes): botão por card; menu abre; href `wa.me` correto por fonte (título + link absoluto, `target=_blank` `rel=noopener noreferrer`); copiar link grava a URL absoluta no clipboard (grant `clipboard-read/write`) e troca label; Escape/clique-fora fecha; mobile: área de toque ≥44px no card do carrossel e menu visível (portal não é clippado).
|
||||
4. **Gates** — `pnpm exec tsc --noEmit`, `pnpm lint`, `pnpm format:check`, `pnpm exec knip`, `pnpm check:cycles`, `pnpm test`, `pnpm build`; e2e verde no CI.
|
||||
|
||||
## Rabbit holes / Não escopo (engenharia)
|
||||
|
||||
- **UTM/medição de clique** — fora (intenção: item futuro explícito); nada é coletado.
|
||||
- **`navigator.share` nativo / outros canais** — fora; aceite é WhatsApp + copiar link.
|
||||
- **Terceira ação no menu, ou menu por plataforma** — não; 2 itens fixos, um componente.
|
||||
- **Generalizar o popover de compartilhar para fora da home** — não; 1 call site.
|
||||
- **Tocar `CampaignContentCarousel`** — não; o botão vem do card, e o `inert` do carrossel já cobre os cards não ativos.
|
||||
|
||||
## Riscos e mitigação
|
||||
|
||||
- **HTML inválido / navegação ao compartilhar** → botão irmão do anchor (D1); zero propagação.
|
||||
- **Clipping do menu no carrossel `overflow-x-auto`** → portal do Radix (D4).
|
||||
- **Token `bg-popover` ausente no tema público** → conferir em `styles.css`; fallback `bg-white` + borda do card.
|
||||
- **Quebra da cadeia `h-full` do bento/`li`** → wrapper `h-full` + anchor `h-full` (verificar no craft com o card featured).
|
||||
- **Clipboard indisponível (http/privado)** → try/catch com label de erro honesto, sem crash.
|
||||
- **Hidratação** → props de server→client são só `kind`/`title`/`href` (strings) — sem serialização nova.
|
||||
|
||||
## Aceite de engenharia
|
||||
|
||||
- [ ] Aceite de produto da intenção ainda coberto (mensagens por fonte, links, `noopener`, ≥44px mobile, sem mudança de layout, sem escrita)
|
||||
- [ ] Invariantes AGENTS: identificadores em inglês / copy pt-BR; sem migration; sem Consent; reuso de `ui/Popover`, `WhatsAppIcon`, padrão `copied` do repo
|
||||
- [ ] Unit da lib pura + E2E no describe existente da seção (cobertura de domínio: mensagem por fonte e URL absoluta)
|
||||
|
|
@ -1,85 +0,0 @@
|
|||
# Compartilhar conteúdos da home no WhatsApp (S4)
|
||||
|
||||
Status: registrado
|
||||
Atualizado em: 2026-08-17
|
||||
Issue: #19
|
||||
Priority: P2
|
||||
Model: composer-2.5
|
||||
Impeccable: B — botão discreto de compartilhar por card de conteúdo (não compete com o CTA)
|
||||
Rascunho UI: docs/plans/secao-conteudos-home-ui-draft.html + PNG embutido abaixo
|
||||
Appetite: ~0,5–1 dia eng; botão por card + wa.me com mensagem pré-configurada + copiar link
|
||||
Responsável: —
|
||||
|
||||
## Intenção
|
||||
|
||||
Depois que a seção de conteúdos da home estiver funcional (S1+S2+S3), cada card de conteúdo vira compartilhável no WhatsApp — o canal nº 1 de compartilhamento no Brasil. O visitante manda o artigo/vídeo/post para alguém com uma mensagem pronta (título + link), sem sair da home. É a fatia de viralidade do board: conteúdo circulando de volta para a campanha na reta final.
|
||||
|
||||
## Persona e fluxo
|
||||
|
||||
- **Persona / contexto:** visitante (eleitor, liderança, simpatizante) no meio de funil que achou um conteúdo bom e quer mandar para o grupo/família.
|
||||
- **Job principal:** repassar um conteúdo específico da home para alguém no WhatsApp com um clique, com texto pronto.
|
||||
- **Fluxo desejado:** vê o card → toca no botão de compartilhar (ícone discreto no canto do card) → opção "Compartilhar no WhatsApp" (abre `wa.me` com mensagem pré-configurada contendo título + link do conteúdo) e/ou "Copiar link" → cola onde quiser.
|
||||
- **Anti-goals de produto:** o botão de compartilhar NUNCA compete com o CTA primário da página (botão discreto, não é o foco do card); não é compartilhamento por seção inteira (v1 é por card); sem WhatsApp Business API (é `wa.me` do próprio remetente).
|
||||
|
||||
### Esboço de fluxo (B)
|
||||
|
||||
```text
|
||||
[card de conteúdo] → [botão compartilhar (ícone)] → [menu: WhatsApp / Copiar link]
|
||||
→ [wa.me com mensagem "Olha isso do Solla: <título> — <link>"] → [remetente envia pelo próprio WhatsApp]
|
||||
```
|
||||
|
||||
### Rascunho UI (B)
|
||||
|
||||

|
||||
|
||||
Fonte iterável: [`secao-conteudos-home-ui-draft.html`](secao-conteudos-home-ui-draft.html) (cena `compartilhar`).
|
||||
|
||||
## Objetivo e aceite
|
||||
|
||||
- Todo card de conteúdo da seção (Artigo/YouTube/Instagram) tem um botão discreto de compartilhar (canto do card, sem competir com o CTA primário da página).
|
||||
- Ação principal: abre `wa.me` com mensagem pré-configurada **por fonte** (decisão do cliente): artigo → "Olha isso do Solla: {título} — {link}" · vídeo → "Olha esse vídeo do Solla: {título} — {link}" · post IG → "Olha esse post do Solla: {título} — {link}". Link: artigo → página canônica do artigo; YT/IG → URL da plataforma. Nova aba com `noopener`.
|
||||
- Ação secundária: "Copiar link" (navegador/clipboard) com feedback de "copiado".
|
||||
- O botão é acessível (aria-label, alvo de toque ≥44px no mobile) e não muda o layout do card. No mobile, o botão vive no card único do carrossel (1 conteúdo por tela — decisão do cliente); no desktop, no canto de cada card do bento.
|
||||
- Sem migration/schema/Consent — o fluxo é montagem de URL no cliente, sem escrita no servidor.
|
||||
- Mensagem padrão honesta (ex.: "Olha isso do Solla: {título} — {link}") — sem texto enganoso.
|
||||
|
||||
## Dados (intenção)
|
||||
|
||||
- **Vou apresentar dados?** Não — só a URL compartilhada. (Se a assessoria quiser medir circulação, UTM por card é um item futuro separado.)
|
||||
- **Decisões desbloqueadas:** o visitante decide compartilhar; nenhum dado é coletado por este fluxo.
|
||||
|
||||
## Direção no codebase (hipótese)
|
||||
|
||||
- **Áreas prováveis:** componente client pequeno no card de conteúdo da seção S1/S2/S3 (`src/components/`), reusando `buildWhatsAppUrl` (`src/utilities/phone.ts`) e o padrão de copiar link já existente (ex.: `ShareNucleusDialog`/`campaignListShare` — ver `docs/plans/compartilhar-pagina.md` como precedente de wa.me + copiar).
|
||||
- **Precedente a olhar:** `compartilhar-pagina.md` (padrão `wa.me` + copiar link + `noopener`), `src/utilities/phone.ts` (`buildWhatsAppUrl`), plano-site §7.1 ("Compartilhamento por seção" — ideia do cliente).
|
||||
- **Risco de acoplamento:** o botão vive no card generalizado da seção — implementar no ponto único de renderização do card (não em três lugares por plataforma).
|
||||
|
||||
## Dependências
|
||||
|
||||
- **S3** (seção completa: artigos + YouTube + Instagram) — dura (conteúdos funcionais antes de compartilhar).
|
||||
|
||||
## Fora de escopo
|
||||
|
||||
- Compartilhar por outros canais (e-mail, X, threads) — só WhatsApp + copiar link no v1.
|
||||
- UTM por seção/card e medição de circulação (item futuro — ideia §7.1 do plano-site).
|
||||
- Imagem OG por seção/card (geração de card de preview) — item futuro (§7.1).
|
||||
- WhatsApp Business API / disparo em massa (vedado pela Res. TSE 23.610; é `wa.me` do próprio remetente).
|
||||
|
||||
## Rabbit holes de produto
|
||||
|
||||
- **Botão de compartilhar competindo com o CTA.** Se alguém "só completar": dois pesos visuais por card. **Corte:** ícone discreto no canto, sem destaque de cor de marca.
|
||||
- **Compartilhar a seção inteira.** Se alguém "só completar": mensagem gigante com N links. **Corte:** v1 é por card, um link por mensagem.
|
||||
- **Telemetria disfarçada de compartilhar.** Se alguém "só completar": vira rastreador de clique. **Corte:** nada é coletado neste item; UTM fica para item futuro explícito.
|
||||
|
||||
## Questões em aberto (produto)
|
||||
|
||||
- **Mensagem padrão por fonte?** **Opções:** A) template único "Olha isso do Solla: {título} — {link}" | B) específica por fonte (artigo/vídeo/post). **Recomendação:** B — decisão do cliente nesta sessão. _(decidido — B)_
|
||||
|
||||
## Referências
|
||||
|
||||
- GitHub Issue #19
|
||||
- Rascunho UI (gate): `docs/plans/secao-conteudos-home-ui-draft.html` (cena `compartilhar`) + PNG acima
|
||||
- `docs/campanha/plano-site-campanha-2026.md` §7.1 — "Compartilhamento por seção" (ideia do cliente registrada)
|
||||
- `docs/plans/compartilhar-pagina.md` — precedente de wa.me + copiar link no repo
|
||||
- `src/utilities/phone.ts` — `buildWhatsAppUrl`
|
||||
- `docs/plans/secao-conteudos-home-artigos.md` (S1) — estrutura do card onde o botão entra
|
||||
|
|
@ -1,89 +0,0 @@
|
|||
# Impl: Demandas visíveis apenas para responsáveis explícitos + candidato/coordenador
|
||||
|
||||
Status: aprovado
|
||||
Atualizado em: 2026-08-19
|
||||
Issue: #106
|
||||
Intenção: docs/plans/demandas-responsaveis.md
|
||||
Appetite restante: herdado (~1–1,5 dias eng; tracer primeiro, polish depois)
|
||||
|
||||
## Leitura da intenção
|
||||
|
||||
- **Outcome:** a demanda tem um conjunto de **responsáveis** (staff da campanha); candidato e coordenador sempre a veem; assessor do município relacionado que não é responsável **não vê** — nem lista, nem URL, nem busca. Criador entra automaticamente como responsável. Quem atualiza a demanda gerencia os responsáveis. Leader lockdown intacto; custo/comprovantes seguem a visibilidade da demanda.
|
||||
- **O que NÃO negociar:** visibilidade **fail-closed** por vínculo explícito (nada de herança do município); regra nos dados (access), não escondida no cliente; leader nunca vê demandas; escalada continua candidato/coordenador.
|
||||
- **O que reavaliar:** a hipótese da intenção era "`canReadCampaignDemand` passa a ser unrestricted ou responsável". Confirma-se no código: hoje o escopo vive **só** em `src/utilities/access/demands.ts:33` (`resolveActorScopedRead` com `municipality`), e **todos** os consumidores (lista, detalhe, busca global do dashboard, demandas vinculadas da atividade, C90, omnibox) herdam via Local API `overrideAccess: false` — não há REST nem client query. O corte é cirúrgico: regra nova + campo + migration + UI de gestão.
|
||||
|
||||
## Abordagem recomendada
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
F[CampaignDemand.responsibles<br/>hasMany → campaignUser] --> A[canRead/UpdateCampaignDemand<br/>advisor → responsibles contains id]
|
||||
H[enforceDemandWorkflow<br/>criador vira responsável na criação] --> F
|
||||
M[Migration: campaign_demand_rels<br/>+campaign_user_id + backfill createdBy] --> F
|
||||
S[updateCampaignDemandResponsibles<br/>tx + advisory lock] --> A
|
||||
U[DemandResponsibleMultiSelect<br/>nova + wizard + detalhe] --> S
|
||||
```
|
||||
|
||||
**Opções consideradas:**
|
||||
|
||||
- **A — Campo `responsibles` hasMany → `campaignUser` + regra `where` de membership** (espelho de `municipality.advisors` / `{ advisors: { contains } }`).
|
||||
- **B — Relação polimórfica `responsible` (union campaignUser/leadership/stateDeputy) como a `activity`.**
|
||||
- **C — `or: [responsibles contains, createdBy equals]`** (criador visível mesmo se removido dos responsáveis).
|
||||
|
||||
**Recomendação:** **A** — single collection, sem union desnecessária (responsável é sempre staff da campanha — `eligibleCampaignStaffWhere`), query escalar `{ responsibles: { contains: id } }` (mesmo mecanismo do `advisors`), e a join table `campaign_demand_rels` **já existe** (hoje usada por `receipts`/media) — a migration só adiciona a coluna `campaign_user_id` + FK + índice.
|
||||
|
||||
**Rejeitadas:** **B** porque responsável é sempre `campaignUser` (não há liderança/dobradinha como responsável de demanda — a intenção fala de "pessoas da campanha"/staff; union polimórfica traria o custo do `equals { relationTo, value }` e catalog triplo sem ganho). **C** porque fura o modelo de responsável explícito: remover o criador da lista não o tiraria da visibilidade, e a intenção fixou "criador entra **como responsável**" (vínculo explícito, único source of truth). O criador entra na lista por hook **na criação** + **backfill** dos existentes.
|
||||
|
||||
### Decisões de engenharia
|
||||
|
||||
1. **Regra de leitura/atualização** — `canReadCampaignDemand` deixa de usar `resolveActorScopedRead` (que é o prólogo municipal) e vira função própria:
|
||||
`admin → true; leader → false; unrestricted → true; advisor → { responsibles: { contains: id } }; resto → false`. `canUpdateCampaignDemand = canReadCampaignDemand` (regra única "quem vê gerencia" — aceite: "quem pode atualizar pode gerenciar os responsáveis"). `canCreateCampaignDemand` **inalterado** (staff no município da carteira). `canDeleteCampaignDemand` admin-only inalterado. `canReadCampaignStaffField`/`canManageCampaignStaffField` inalterados.
|
||||
2. **Criador auto-responsável** — no branch `create` do hook `enforceDemandWorkflow` (`CampaignDemand.ts:129-143`), quando `actor` é campaignUser: `data.responsibles = union(data.responsibles, [actor.id])`. Cobre **todos** os caminhos de criação: form `/demandas/nova`, wizard (A5), rascunhos C90 (activity.ts passa `user: currentActor`), admin UI (payload admin → actor null → sem responsáveis → fail-closed: invisível a assessores, o que é aceitável — quem criou via admin vê tudo). Sem auto-add no update.
|
||||
3. **Backfill dos existentes** — na própria migration (padrão C90 `20260808_184113_remodel_activity_responsible.ts`, `RAISE NOTICE` + SQL no up): `INSERT INTO campaign_demand_rels (order, parent_id, path, campaign_user_id) SELECT 0, id, 'responsibles', created_by_id FROM campaign_demand WHERE created_by_id IS NOT NULL`. Preserva "a criação não some do criador" para as demandas pré-c143. Campo novo → nenhuma linha tem responsável → sem dedup.
|
||||
4. **Gestão (server)** — `updateCampaignDemandResponsiblesRecord` em `actions/demand.ts`, mesmo shape dos irmãos: schema zod novo (`{ id, responsibles: array de positiveRelationshipId }`), `withPayloadTransaction`, `reloadCampaignActor`, staff check, advisory lock `campaign-demand:{id}`, `findByID` scoped (`overrideAccess: false` — a row access autoriza antes do write), `payload.update` com a lista **completa** (replace). Replace é a forma mais simples e segura: o estado do client já é a lista inteira e o advisory lock serializa editores concorrentes (add/remove atômico seria 2 formas de escrita para o mesmo dado).
|
||||
5. **Busca de candidatos (UI)** — server action `searchDemandResponsibleOptions(query, municipalityId)` (padrão `searchDemandActivityOptions`): query < 2 chars → sugestões = assessores do município da demanda (read scoped de `municipality.advisors`); senão → busca por nome em `campaignUser` com `eligibleCampaignStaffWhere` (reusa o padrão de `loadEligibleAdvisorOptions`), `overrideAccess: false` (campaign users leem `campaignUser` — `canReadCampaignUsers`). Sugestão NÃO é herança: é atalho de preenchimento, a visibilidade vem só do campo salvo.
|
||||
6. **Componente de multi-select** — `DemandResponsibleMultiSelect` novo em `src/components/campaign/demand/` (domain, não shared): single collection, chips removíveis, chip do criador rotulado "Você (criador)", sugestões no diálogo quando query curta, hidden inputs repetidos `responsibles` (contrato de `RelationMultiSelect`, não o JSON de `ResponsibleMultiSelect`). **Não reusar `ResponsibleMultiSelect` (C90)**: é polimórfico (typeLabel/relationTo), tem cap hardcoded `MAX_ACTIVITY_RESPONSIBLES=20`, e o contrato de FormData é JSON tipado — forçar modo single-collection seria 4 props novas + branch, um módulo raso; dois componentes com contratos distintos é honesto (mesmo critério que separou `RelationMultiSelect` de `ResponsibleMultiSelect`).
|
||||
7. **Detalhe** — card `DemandResponsiblesCard` (client) com a lista (nome + avatar via `loadCampaignUserDisplayByIds`, helper existente), add por combobox, remove por chip, e o rótulo estático "Visível para: Responsáveis · Candidato · Coordenador". Layout do rascunho: grade `lg:grid-cols-3` — coluna 1–2 com os blocos atuais (descrição, decisão, workflow, controle interno), coluna 3 com o card. Todos os readers são updaters (update access = read access), então o card de gestão aparece para qualquer staff que abra a demanda — sem flag.
|
||||
8. **Criação** — `DemandFields` ganha o campo responsáveis (opcional via prop `responsiblesMunicipalityId`/`currentUser`): `/demandas/nova` e wizard compartilham o mesmo parse (`parseCampaignDemandCreateFormData` ganha `responsibles: formData.getAll('responsibles')`), schema de create ganha `responsibles: z.array(positiveRelationshipId)` (sem cap — como `municipality.advisors`; o hook faz o union, dedup implícito). Draft: `/demandas/nova` em grade 2 colunas (form | responsáveis); wizard empilhado (o wizard herda via `DemandFields`).
|
||||
|
||||
### Componentes / mudanças
|
||||
|
||||
- **`CampaignDemand.responsibles`** (`src/collections/CampaignDemand.ts`): `relationship hasMany → campaignUser`, label "Responsáveis", index; sem field access (row access já gateia; staff nativo não abre `/admin`).
|
||||
- **`enforceDemandWorkflow`** (`CampaignDemand.ts`): union do criador no create (campaignUser only).
|
||||
- **`canReadCampaignDemand`/`canUpdateCampaignDemand`** (`src/utilities/access/demands.ts`): regra de membership; remove `resolveActorScopedRead`/`getAccessibleMunicipalityIds` deste módulo.
|
||||
- **Migration:** `add_campaign_demand_responsibles` (drizzle): `ALTER TABLE campaign_demand_rels ADD COLUMN campaign_user_id` + FK (`ON DELETE cascade`, padrão `state_deputy_rels`) + índice; + backfill no up. Gerar com `pnpm migrate:create`.
|
||||
- **Access / Consent:** sem Consent (dado interno staff). Fail-closed no where da access.
|
||||
- **Server actions:** `updateCampaignDemandResponsiblesRecord` + `setDemandResponsiblesFormAction` (`[slug]/formActions.ts`, `revalidatePath`); `searchDemandResponsibleOptions` (`demandas/responsibleSearchActions.ts`).
|
||||
- **UI:** `DemandResponsibleMultiSelect` + `DemandResponsiblesCard` em `src/components/campaign/demand/`; `DemandFields`/`DemandForm`/`WizardRegisterDemandStep` ganham o campo; `[slug]/page.tsx` ganha o card + grade lg. Impeccable B — seguir o rascunho (`demandas-responsaveis-ui-draft-*.png`): chips, placeholder "Buscar assessor…"/"Adicionar responsável…", textos informativos do rascunho.
|
||||
- **View-model:** `DemandDetailViewModel.responsibles: Array<{ id, name, avatarUrl }>` via `loadCampaignUserDisplayByIds`; `nova/page.tsx` carrega o `currentUser` (id/nome) para seed do picker.
|
||||
|
||||
### Dados → forma
|
||||
|
||||
- Sem dados apresentados ao usuário além de nomes/avatares de responsáveis — forma já existente (chips com nome + avatar, padrão do código). Lista da demanda **não** ganha coluna de responsáveis (fora do aceite — apenas visibilidade muda).
|
||||
|
||||
## Fases verificáveis
|
||||
|
||||
1. **Schema + server (tracer)** — migration + campo + hook do criador + access rule; `pnpm generate:types`; int tests de access reescritos/adicionados (`campaignDemandWorkflow.int.spec.ts`, `homeSearchDemands.int.spec.ts`); backfill verificado no local.
|
||||
2. **Ações + busca** — `updateCampaignDemandResponsiblesRecord` + zod + `searchDemandResponsibleOptions`; int de gestão (add/remove/self-removal, advisor não-responsável não lê).
|
||||
3. **UI** — multi-select na criação (nova + wizard) e card no detalhe; shape → critique → polish contra o rascunho; e2e: assessor não-responsável não vê (lista vazia + URL → 404); ajustar e2e existentes se o aceite mudou o comportamento (criador auto-responsável preserva os fluxos atuais).
|
||||
4. **Gates** — `tsc`, `lint`, `format:check`, `knip`, `check:cycles`, unit+int, `build` local; e2e afetado no CI. Aikido nos arquivos tocados.
|
||||
|
||||
## Rabbit holes / Não escopo (engenharia)
|
||||
|
||||
- **Notificação ao ser marcado responsável** (fora de escopo da intenção).
|
||||
- **Coluna de responsáveis na lista / omnibox** — visibilidade herda a access, sem mudança de UI de lista.
|
||||
- **C141 "Visão Tudo"** — não existe ainda; a regra nova já falha fechado para assessor em qualquer perfil futuro.
|
||||
- **Generalizar `ResponsibleMultiSelect`/`RelationMultiSelect`** — rejeitado (contratos distintos, módulo raso).
|
||||
- **Overflow "+N" dos chips no detalhe** — o padrão do código é wrap; o "+2" do rascunho é affordance de wireframe, não spec.
|
||||
|
||||
## Riscos e mitigação
|
||||
|
||||
- **Regra nova muda o que assessores veem em todos os pontos de demanda** (lista, detalhe, busca home, activity detail, C90). Mitigação: tudo herda por `overrideAccess: false` — um único ponto de corte; int tests cobrem cada superfície; C90 cria com `user: currentActor` → criador auto-responsável → a atividade continua listando as próprias demandas.
|
||||
- **Self-removal deixa a demanda sem responsável** → visível só a candidato/coordenador (fail-closed, aceitável e coerente com o modelo explícito — gestão é dos responsáveis).
|
||||
- **Join table já existente** (`campaign_demand_rels` com `path='media'`) — a migration adiciona coluna/FK; conferir o SQL gerado (não regenerar a tabela).
|
||||
- **`codebaseConventions.unit.spec.ts`** — o guard anti-fragmento municipal não é violado (a regra nova não re-escreve `{ municipality: { in } }`); rodar o guard no gate.
|
||||
|
||||
## Aceite de engenharia
|
||||
|
||||
- [x] Aceite de produto da intenção ainda coberto (visibilidade fail-closed por responsáveis explícitos; criador automático; gestão por quem atualiza; leader lockdown; escalada inalterada)
|
||||
- [x] Invariantes AGENTS/engineering-standards (Local API com `user`/`overrideAccess: false`; transação + advisory lock nas escritas multi-step; pt-BR em labels, identificadores em inglês)
|
||||
- [x] Testes de domínio previstos (int: access rule, gestão de responsáveis, backfill; e2e: invisibilidade para não-responsável; adjust: workflow/homeSearch/listData)
|
||||
|
Before Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 61 KiB |
|
|
@ -1,167 +0,0 @@
|
|||
<!doctype html>
|
||||
<html lang="pt-BR">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
|
||||
<title>UI draft — Demandas: responsáveis explícitos</title>
|
||||
</head>
|
||||
<body class="bg-zinc-950 text-zinc-100 antialiased">
|
||||
<main class="mx-auto max-w-6xl p-8">
|
||||
<p class="text-sm text-zinc-400">
|
||||
Demanda só aparece para candidato/coordenador e para quem está marcado como responsável —
|
||||
nem assessor do município vê fora disso. Cenas: detalhe com seção Responsáveis + editor,
|
||||
formulário de criação com sugestão.
|
||||
</p>
|
||||
|
||||
<!-- Cena 1: detalhe da demanda — visão de coordenador, com responsáveis -->
|
||||
<section data-shot="detalhe" class="mt-6 overflow-hidden rounded-xl border border-zinc-800">
|
||||
<div class="bg-zinc-100 px-8 py-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="rounded bg-zinc-300 px-2 py-0.5 text-xs font-bold text-zinc-800"
|
||||
>Material de campanha</span
|
||||
>
|
||||
<span class="rounded bg-zinc-300 px-2 py-0.5 text-xs font-bold text-zinc-800"
|
||||
>Em aberto</span
|
||||
>
|
||||
</div>
|
||||
<div class="mt-2 text-lg font-bold text-zinc-900">
|
||||
Cartazes para a caminhada do fim de semana
|
||||
</div>
|
||||
<div class="mt-1 text-sm text-zinc-500">Ilhéus · liderança: Nome da liderança</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<div class="rounded-lg border border-zinc-200 bg-white p-5">
|
||||
<div class="text-sm font-bold text-zinc-900">Descrição</div>
|
||||
<div class="mt-2 text-sm leading-relaxed text-zinc-600">
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor
|
||||
incididunt ut labore et dolore magna aliqua.
|
||||
</div>
|
||||
<div class="mt-4 flex items-center justify-between border-t border-zinc-100 pt-3">
|
||||
<div class="text-xs text-zinc-500">Custo estimado</div>
|
||||
<div class="text-sm font-bold text-zinc-900">R$ 1.200,00</div>
|
||||
</div>
|
||||
<div class="mt-2 flex items-center justify-between">
|
||||
<div class="text-xs text-zinc-500">Comprovantes</div>
|
||||
<div class="text-sm text-zinc-600">2 anexos</div>
|
||||
</div>
|
||||
<div class="mt-4 flex items-center justify-between border-t border-zinc-100 pt-3">
|
||||
<div class="text-xs text-zinc-500">Visível para</div>
|
||||
<div class="text-sm text-zinc-600">Responsáveis · Candidato · Coordenador</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border border-zinc-200 bg-white p-5">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="text-sm font-bold text-zinc-900">Responsáveis pela demanda</div>
|
||||
<div class="text-xs text-zinc-400">Só quem é responsável vê esta demanda</div>
|
||||
</div>
|
||||
<div class="mt-3 flex flex-wrap gap-1.5">
|
||||
<span class="rounded bg-zinc-200 px-2 py-1 text-xs text-zinc-700"
|
||||
>Assessora de campo</span
|
||||
>
|
||||
<span class="rounded bg-zinc-200 px-2 py-1 text-xs text-zinc-700"
|
||||
>Outro assessor</span
|
||||
>
|
||||
<span
|
||||
class="inline-flex h-6 items-center rounded bg-zinc-100 px-2 text-xs text-zinc-500"
|
||||
>+2</span
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
class="mt-3 h-9 w-full rounded-md border border-zinc-300 bg-zinc-50 px-3 text-sm text-zinc-500"
|
||||
>
|
||||
Adicionar responsável…
|
||||
</div>
|
||||
<div class="mt-2 text-[11px] text-zinc-500">
|
||||
Criador já entra como responsável automaticamente. Assessores do município aparecem
|
||||
como sugestão, mas só quem for marcado enxerga.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 rounded-md bg-zinc-100 px-4 py-2 text-xs leading-relaxed text-zinc-600">
|
||||
Assessor do município que não é responsável: a demanda não aparece na lista dele nem
|
||||
abre por URL — mesmo com permissão "Visão Tudo".
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Cena 2: criação de demanda — campo responsáveis -->
|
||||
<section data-shot="criacao" class="mt-6 overflow-hidden rounded-xl border border-zinc-800">
|
||||
<div class="bg-zinc-100 px-8 py-6">
|
||||
<div class="text-base font-bold text-zinc-900">Nova demanda</div>
|
||||
|
||||
<div class="mt-4 grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<div class="text-xs font-bold text-zinc-700">Título</div>
|
||||
<div
|
||||
class="mt-1 h-9 w-full rounded-md border border-zinc-300 bg-white px-3 text-sm text-zinc-500"
|
||||
>
|
||||
Cartazes para a caminhada do fim de semana
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs font-bold text-zinc-700">Município</div>
|
||||
<div
|
||||
class="mt-1 flex h-9 w-full items-center justify-between rounded-md border border-zinc-300 bg-white px-3 text-sm text-zinc-800"
|
||||
>
|
||||
Ilhéus
|
||||
<span class="text-xs text-zinc-400">▾</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs font-bold text-zinc-700">Tipo</div>
|
||||
<div
|
||||
class="mt-1 flex h-9 w-full items-center justify-between rounded-md border border-zinc-300 bg-white px-3 text-sm text-zinc-800"
|
||||
>
|
||||
Material de campanha
|
||||
<span class="text-xs text-zinc-400">▾</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border border-zinc-200 bg-white p-4">
|
||||
<div class="text-xs font-bold text-zinc-700">Responsáveis pela demanda</div>
|
||||
<div class="mt-2 flex flex-wrap gap-1.5">
|
||||
<span class="rounded bg-zinc-300 px-2 py-1 text-xs font-bold text-zinc-800"
|
||||
>Você (criador)</span
|
||||
>
|
||||
<span class="rounded bg-zinc-200 px-2 py-1 text-xs text-zinc-700"
|
||||
>Assessora de campo</span
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
class="mt-3 h-9 w-full rounded-md border border-zinc-300 bg-zinc-50 px-3 text-sm text-zinc-500"
|
||||
>
|
||||
Buscar assessor…
|
||||
</div>
|
||||
<div class="mt-2 text-[11px] text-zinc-500">
|
||||
Sugestões: assessores do município. Se ninguém for marcado além de você, só você,
|
||||
candidato e coordenador verão a demanda.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 flex justify-end gap-2">
|
||||
<div
|
||||
class="inline-flex h-9 items-center rounded-lg border border-zinc-300 px-4 text-sm font-bold text-zinc-700"
|
||||
>
|
||||
Cancelar
|
||||
</div>
|
||||
<div
|
||||
class="inline-flex h-9 items-center rounded-lg bg-red-600 px-4 text-sm font-extrabold text-white"
|
||||
>
|
||||
Criar demanda
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,88 +0,0 @@
|
|||
# Demandas visíveis apenas para responsáveis explícitos + candidato/coordenador
|
||||
|
||||
Status: aguardando execução
|
||||
Atualizado em: 2026-08-19
|
||||
Issue: #106
|
||||
Priority: P1
|
||||
Model: cursor-grok-4.5-high
|
||||
Impeccable: B — encaixe no detalhe de demanda (`/campanha/demandas/[slug]`) e na criação
|
||||
Rascunho UI: docs/plans/demandas-responsaveis-ui-draft.html
|
||||
Appetite: ~1–1,5 dias eng; campo de responsáveis + regra de visibilidade fail-closed
|
||||
Responsável: —
|
||||
|
||||
## Intenção
|
||||
|
||||
Demandas carregam custo estimado e comprovantes (controle interno de gastos). Hoje **qualquer assessor que administra o município da demanda a vê** — e a mesa descobriu que isso é amplo demais: a demanda deve ser vista **só por quem é responsável por ela**, pelo candidato e pelo coordenador geral. Mesmo um assessor do município relacionado, se não estiver marcado como responsável, não vê. Decidido no gate (2026-08-19, opção B): responsável é **vínculo explícito por demanda**.
|
||||
|
||||
## Persona e fluxo
|
||||
|
||||
- **Persona / contexto:** Alex (Coordenador Geral) e o Candidato acompanhando custos e decisões; assessores responsáveis acompanhando "suas" demandas; assessores do município que NÃO devem esbarrar em demandas que não são deles.
|
||||
- **Job principal:** quem cria a demanda (ou a coordenação) define os responsáveis; só eles — além de candidato/coordenador — enxergam e tocam a demanda.
|
||||
- **Fluxo desejado:**
|
||||
1. Um assessor cria uma demanda no município da carteira: o formulário pede "Responsáveis pela demanda" (criador já vem marcado; sugestões = assessores do município).
|
||||
2. No detalhe da demanda, quem pode atualizá-la adiciona/remove responsáveis.
|
||||
3. Um assessor do município que não é responsável: a demanda não aparece na lista dele, não abre por URL e não aparece em buscas — sem erro "sem permissão", simplesmente não existe para ele.
|
||||
- **Anti-goals de produto:** responsável herdado automaticamente do município (a decisão do gate foi explícita); visibilidade por município como default; auditoria de quem viu (fora de escopo).
|
||||
|
||||
### Esboço de fluxo (B/C/D)
|
||||
|
||||
```text
|
||||
[criar demanda] → [campo "Responsáveis" — criador pré-marcado, sugestão do município]
|
||||
→ [criar] → [criador vira responsável; lista só para responsáveis + candidato/coordenador]
|
||||
[detalhe] → [responsáveis gerenciam responsáveis]
|
||||
[assessor não-responsável] → [demanda invisível: lista, URL e busca]
|
||||
```
|
||||
|
||||
### Rascunho UI (B/C/D)
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
## Objetivo e aceite
|
||||
|
||||
- A demanda tem um conjunto de **responsáveis** (pessoas da campanha); candidato e coordenador sempre a veem, responsável ou não.
|
||||
- **Visibilidade fail-closed:** sem responsável marcado além do criador, a demanda é visível só ao criador + candidato/coordenador. Assessor do município relacionado, se não for responsável, **não vê** — nem na lista, nem por URL, nem na busca.
|
||||
- O **criador** entra automaticamente como responsável (garante pelo menos um dono e que a criação não suma do criador).
|
||||
- Quem pode **atualizar** a demanda pode gerenciar os responsáveis (mesma regra de visibilidade: responsáveis + candidato/coordenador).
|
||||
- A criação pede os responsáveis (sugestão = assessores do município da demanda), mas não obriga além do criador.
|
||||
- Guardrails: leader lockdown intacto (liderança nunca vê demandas); decisão de escalada continua candidato/coordenador; custo/comprovantes seguem a visibilidade da demanda.
|
||||
|
||||
## Dados (intenção)
|
||||
|
||||
- **Vou apresentar dados?** Não — regra de visibilidade e controle de acesso.
|
||||
- **Decisões desbloqueadas:** coordenação decide quem acompanha cada demanda.
|
||||
|
||||
## Direção no codebase (hipótese)
|
||||
|
||||
- **Áreas prováveis:** campo de responsáveis na demanda em `src/collections/CampaignDemand.ts`; regra de leitura/atualização em `src/utilities/access/demands.ts` (hoje `canReadCampaignDemand` = escopo do município — passa a ser "unrestricted ou responsável"); server actions `src/app/(campaign)/campanha/actions/demand.ts`; UI do detalhe `src/components/campaign/demand/` e do fluxo de criação (registrar pedido).
|
||||
- **Precedente a olhar:** o fragmento de escopo P3-D (não re-escrever o `{ municipality: { in: ids } }` por aí — a regra nova substitui o escopo municipal de demandas); `getFreshCampaignUser`/`memoizePerRequest` para a leitura por request; o padrão de "membership" de `municipality.advisors` para o controle de responsáveis.
|
||||
- **Risco de acoplamento:** atividades geram demandas (C90) e a lista/omnibox de demandas filtra por escopo — a regra nova muda o que assessores veem em todos os pontos; o Sollinha usa as mesmas queries de demanda (escopo por user).
|
||||
|
||||
## Dependências
|
||||
|
||||
- Nenhuma dura. **Guardrail:** C141 (perfis por assessor) nunca contorna esta regra — Visão "Tudo" não abre demandas fora da responsabilidade.
|
||||
|
||||
## Fora de escopo
|
||||
|
||||
- Perfil de permissão por assessor (C141) e apresentação por perfil (C142).
|
||||
- Notificação/feed ao ser marcado responsável.
|
||||
- Auditoria de leituras da demanda.
|
||||
|
||||
## Rabbit holes de produto
|
||||
|
||||
- **"Responsável = assessor do município, simples."** Isso é exatamente o modelo de hoje, que o gate rejeitou. **Corte:** vínculo explícito; sugestões sim, herança automática não.
|
||||
- **"Vou usar o histórico de quem criou."** `createdBy` existe, mas só o criador não cobre quem vai executar a demanda. **Corte:** campo próprio de responsáveis; criador vira um responsável, não o único.
|
||||
- **"Vou esconder no cliente."** Esconder na UI não segura URL direta nem a API. **Corte:** regra no acesso de dados (fail-closed) — lista, URL e busca respeitam o mesmo where.
|
||||
|
||||
## Questões em aberto (produto)
|
||||
|
||||
Fechadas no gate. Assunções operacionais (sem pergunta nova): criador entra automaticamente como responsável; quem atualiza a demanda gerencia responsáveis; sugestões do campo = assessores do município da demanda; visível para = "Responsáveis · Candidato · Coordenador" (rótulo exibido no detalhe). _Assumidas — validar se contrariarem a mesa._
|
||||
|
||||
## Referências
|
||||
|
||||
- Decisão do gate 2026-08-19 (resposta 1): responsáveis explícitos; assessor do município não-responsável não vê.
|
||||
- Item irmão: `docs/plans/permissao-granular-assessores.md` (C141).
|
||||
- Rascunho UI (gate): `docs/plans/demandas-responsaveis-ui-draft.html` + PNGs acima.
|
||||
- Para abrir primeiro: `src/utilities/access/demands.ts`, `src/collections/CampaignDemand.ts`, `docs/plans/demandas-campanha.md`.
|
||||
- `AGENTS.md` — convenção P3-D do escopo de assessor.
|
||||
|
|
@ -1,60 +0,0 @@
|
|||
# Escala/DRY pós-S9 — integridade e alinhamento das ações públicas
|
||||
|
||||
Status: registrado
|
||||
Atualizado em: 2026-08-19
|
||||
Issue: a registrar (depends: #93)
|
||||
Priority: P2
|
||||
Model: composer-2.5
|
||||
Appetite: ~0,5–1 dia eng (3 fases pequenas, lock primeiro)
|
||||
|
||||
## Fase 1 — Deletar `Consent` em uso falha com erro cru (expensive_lock)
|
||||
|
||||
**Sintoma:** deletar um consentimento referenciado por `subscription` (ou
|
||||
`signature`/`petition`) estoura `23502` no Postgres: a FK é `ON DELETE SET
|
||||
NULL` mas a coluna `subscription.consent_id` é `NOT NULL` — o gatilho tenta
|
||||
setar NULL e viola a constraint; no admin o usuário vê um 500 sem explicação.
|
||||
|
||||
**Por que existe:** `required: true` no campo `consent` (migration inicial).
|
||||
O comportamento "não deletar consentimento em uso" é **protetivo** (texto
|
||||
legal versionado) — o problema é só a superfície: erro cru, sem orientação.
|
||||
|
||||
**Decisão de implementação (com produto/jurídico):** mensagem clara no admin
|
||||
("Consentimento em uso por N assinaturas/inscrições — não é possível
|
||||
excluir") vs. semântica de arquivamento. Onde: `beforeDelete` hook do
|
||||
`Consent` (contar referências; lançar `APIError` amigável) ou validação
|
||||
explícita no delete operation. Descoberto na sessão S9 (também travou os
|
||||
lifecycles de teste — as purges de `campanha-novidades` precisam deletar
|
||||
subscriptions antes).
|
||||
|
||||
## Fase 2 — `submitWhatsapp`/`submitPetitionSignature` → `withPayloadTransaction`
|
||||
|
||||
As duas actions públicas irmãs usam o hand-roll (begin/try/commit/rollback)
|
||||
que o `submitCampaignNewsletter` (S9) já substituiu pelo dono do concern
|
||||
(`src/utilities/payloadTransaction.ts`). O helper entrega rollback também
|
||||
quando o **commit** falha, `AggregateError` quando rollback+erro falham juntos
|
||||
e o registro after-commit (`onPayloadTransactionCommit`) — a costura pronta
|
||||
para S10 disparar `Lead` e para notificações pós-write. Sem mudança de
|
||||
contrato: mesmos creates, mesmo retorno `{ ok: true }`.
|
||||
|
||||
## Fase 3 — Consolidar os schemas de estado/cidade em `contact.ts`
|
||||
|
||||
O S9 exportou `optionalContactStateSchema` e `contactCityFieldSchema`, mas as
|
||||
4 fichas do próprio `contact.ts` (`contactSchema`, `contactCreateSchema`,
|
||||
`contactFieldUpdateSchema`, `contactFullUpdateSchema`) mantêm cópias inline do
|
||||
predicate `value in CitiesByState` e da chain de cidade (mesma mensagem,
|
||||
mesmo comportamento). Trocar as cópias pelos exports únicos — DRY de
|
||||
conhecimento no próprio dono.
|
||||
|
||||
## Já resolvido no simplify/critique (não reabrir)
|
||||
|
||||
- e2e do path de recusa (consent ausente) — cortado de propósito: a action
|
||||
fail-closed devolve 500 e o `e2eFailureGuard` trata 5xx como falha; o int
|
||||
spec cobre com a mensagem exata + nada gravado.
|
||||
- `contactStateSchema` privado (knip) — o export só teria 1 consumidor.
|
||||
|
||||
## Explicitamente fora (defers com gatilho)
|
||||
|
||||
- Refatorar o hand-roll das actions irmãs ANTES de S9 existir — já feito na
|
||||
action nova; o débito é só das irmãs (Fase 2).
|
||||
- Gatilho: quando S10 precisar do `onPayloadTransactionCommit` para `Lead`, a
|
||||
Fase 2 vira requisito (não só limpeza).
|
||||