Compare commits

..

3 commits

Author SHA1 Message Date
Francisco Solla
bc1a109239 docs(OPS68): agregado do changelog com a entrada OPS68 (ficou fora do rebase)
All checks were successful
CI (PR) / checks (pull_request) Successful in 1m15s
PR Ready + auto-merge / ready-automerge (pull_request) Successful in 1m21s
Promote plan-issue Related on main merge / plan-issue-ready (pull_request) Has been skipped
Main merge flips claimed issues to done + in-prod / issue-done (pull_request) Successful in 12s
O --ours no conflito do agregado (OPS66) deixou a entrada OPS68 fora do
commit; changelog:build regenera e o docs-guard do CI exige o agregado
commitado.
2026-08-19 10:31:16 -03:00
Francisco Solla
235cf36dc1 docs(OPS68): reaplica fixes do AGENTS.md perdidos na resolução do rebase (OPS66)
Some checks failed
CI (PR) / checks (pull_request) Failing after 37s
PR Ready + auto-merge / ready-automerge (pull_request) Failing after 2m3s
O checkout --ours no conflito da L15 (gate + frase build-after-migrate do
OPS66) descartou os hunks limpos do rebase: Neon-refs da seção de DB
(header/at Neon/no Neon risk/use Neon), rationale WebAuthn host-agnóstico.
2026-08-19 10:26:14 -03:00
Francisco Solla
0043608302 docs(OPS68): reescreve docs canônicos da era GitHub/Vercel para Forgejo/homeserver
Parte 2 do OPS50 (fechamento): AGENT-OPS, README, roadmap (ponteiro),
AGENTS.md (refs Neon/job gate), rule agent-pr-workflow e 8 skills de
operação sem comandos gh (API/MCP + safety net) nem secrets Vercel —
fonte canônica única: git.solla.dev/fsolla/teqo.
2026-08-19 10:21:29 -03:00
247 changed files with 1911 additions and 82332 deletions

View file

@ -1,19 +1,19 @@
---
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)
pnpm push -u origin HEAD
```
Depois, abra a PR no **GitHub** — **Ready**, base **`main`**, title `<id> — <title>`, body:
Depois, abra a PR via API/MCP do opencode (humano) ou `ManagePullRequest` (Cursor Cloud) — **Ready**, base **`main`**, title `<id> — <title>`, body:
```
## O que é
@ -25,12 +25,11 @@ Closes #<N>
- **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`
- **`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.
- Não há auto-merge a armar por PR: o safety net (abaixo) espera o rollup `checks` e mergea sozinho.
- Never end a turn with an open agent PR left as **Draft** — inclui PRs do `engineering-audit` (artefatos e remediações).
## Push (determinístico)
@ -38,51 +37,53 @@ Closes #<N>
**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` sem e2e (e2e roda uma vez, no CI, após o push; local opcional: `pnpm test:e2e:affected`), 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)
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.
The safety net (`agent-pr-ready-automerge.yml`, abaixo) merges the PR when the required checks pass — there is nothing to arm per PR. Do not wait for human approval on normal deliveries.
## Required checks
Branch protection on `main` (GitHub, applied at the OPS71 cutover via `pnpm configure:branch-protection`) requires only:
Branch protection on `main` (applied 2026-08-18, OPS61) requires only:
- `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).
- `CI (PR) / checks` — the ci-pr cascade rollup (the only context that covers
every ci-pr job; on the server the rule matches it as a glob,
`CI (PR) / checks*`)
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.
(`migration-lock` removed 2026-08-12 — the CI validates migration chains via migrate + int on every PR/main.)
(`migration-lock` removed 2026-08-12 — the CI validates migration chains via migrate + int on every PR.)
**There is no deploy from PR CI.** The `ci.yml` verifier on `main` runs the full suite in its fixed 30-minute window and, on a production change, the `deploy` job publishes to the homeserver (`jorgesolla1313.com.br`, self-hosted) — migrations applied before the rollout. Do not add a Vercel/Git-integration deploy gate.
**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.
When watching CI, use the PR page (or the API) — the safety net is what waits on checks; `gh` does not exist in this repo (OPS50).
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).
Use the required check `CI (PR) / checks` — without it, you are not covering the cascade.
Use the rollup context `CI (PR) / checks` — without it, you are not covering the cascade.
## 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 the full suite and, on a production change, deploys to the homeserver (OPS53/OPS65; runbook: `docs/ops/teqo-1313-deploy.md`).
## Safety net (workflow)
`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).
O workflow roda `scripts/forgejo-pr-automerge.mjs` (plain Node, zero-dep): se
draft `cursor/*` → marca Ready; espera os checks e mergea por rebase (o
equivalente Forgejo do auto-merge — sem `gh`). O wait exige o rollup
`CI (PR) / checks` POSTADO e verde (OPS61) — o único contexto que só existe
quando a cascata inteira do ci-pr assentou (jobs ainda não agendados não
postam status; o próprio status do job do safety net é `pending` durante o run
e **nunca** entra no veredito). A regra de branch protection no servidor
bloqueia o merge POST (405) enquanto o contexto exigido não estiver satisfeito
— inclusive merges manuais via API: foi um merge manual que colocou a PR #52
em main com o CI vermelho (o CLI nunca mergeou sozinho antes do OPS61; todo
merge Forgejo era manual).
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 é

View file

@ -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` **sem e2e** — OPS59: o e2e roda **uma vez, no CI**, em PR e na main; local opcional para feedback antecipado: `pnpm test:e2e:affected`; **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 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

View file

@ -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

View file

@ -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)

View file

@ -36,7 +36,7 @@ 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
@ -96,12 +96,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

View file

@ -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,10 @@ 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 via API/MCP (ou `ManagePullRequest` no Cloud) — **Ready** (nunca draft), base `main`, com `Closes #<N>`
5. O safety net `agent-pr-ready-automerge.yml` espera o rollup `CI (PR) / checks` e mergea por rebase — nada a armar
6. CI flipa `done`/`in-prod` no merge. Comente na Issue o desfecho em uma linha.
## Deltas por ator

View file

@ -0,0 +1,57 @@
# Safety net: every same-repo PR to main must be Ready + auto-merge.
# 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).
#
# Forgejo port (OPS50): Forgejo's API has no auto-merge scheduling, so the
# workflow runs scripts/forgejo-pr-automerge.mjs (plain Node): marks draft →
# ready, waits for the commit statuses (the branch-protection `checks` gate),
# then merges by rebase. Same semantics as `gh pr merge --auto --rebase`,
# implemented as poll + merge. GitHub's cancel-workflow-on-failure action was
# dropped (no GITHUB_RUN_ID plumbing on Forgejo).
#
# OPS61: the job injects FORGEJO_API_TOKEN. The merge POST performs a git push
# of the rebased branch to main; Forgejo's pre-receive hook rejects that push
# when the caller's user has no repo write — and the native GITHUB_TOKEN acts
# as the Actions system user (no write, and the same 403 that broke issue
# label flips). Every pre-OPS61 "auto-merge" that ended success had actually
# found the PR already merged manually; the CLI's own merge attempts died on
# 409 "User permission denied for writing".
name: PR Ready + auto-merge
on:
pull_request:
types: [opened, reopened, synchronize, ready_for_review, converted_to_draft]
branches: [main]
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. Fork PRs are excluded here because the repo is public
# and a merged fork PR would deploy attacker code to production. 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
runs-on: ubuntu-latest
# OPS61: the CLI now waits for the full CI (PR) cascade (the `checks`
# rollup posts only after every ci-pr job, incl. e2e shards) — 55 min keeps
# 10 min of slack over the CLI's 45 min timeout.
timeout-minutes: 55
env:
FORGEJO_API_TOKEN: ${{ secrets.FORGEJO_API_TOKEN }}
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 merge when checks pass
run: node scripts/forgejo-pr-automerge.mjs --pr "${{ github.event.pull_request.number }}"

View file

@ -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

View file

@ -1,71 +1,53 @@
# 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).
# PR gate: feature → main. 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, the run fails and
# the slot is released immediately. No cancel-run API (Forgejo 9.0.3 has
# none), no watchdog job, no upgrades: with one job there are no siblings to
# cancel. Steps keep their own logs, so diagnosis is intact after a failure.
# The job id `checks` preserves the `CI (PR) / checks` status context the
# automerger waitForChecks and branch protection rely on (OPS61).
#
# 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.
# as a step; its outputs drive `if:` on the heavy steps (same modes as the
# former per-job cascade: none/changed/full).
#
# 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.
# Forgejo port (OPS50): Postgres services live on the job network and are
# reached by NAME (postgres-int / postgres-build on 5432) — no host publish,
# so concurrent runs never collide on host ports (OPS62 X1). Two isolated
# services keep e2e from inheriting int fixture residue.
name: CI (PR)
on:
pull_request:
branches: [main]
permissions:
contents: read
concurrency:
group: ci-pr-${{ github.head_ref }}
cancel-in-progress: true
env:
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
runs-on: ubuntu-latest
timeout-minutes: 50
services:
# Services are reached by NAME on the job network (no host publish —
# OPS62 X1): concurrent runs never collide on host ports, which the
# single long-lived job would hold for the whole run. Two isolated
# services — int and build+e2e — keep e2e from inheriting int fixture
# residue (same isolation the former jobs had). Forgejo 9 does not
# expand expressions in `services.ports`, so a per-run port scheme was
# dropped in favour of the job-network DNS.
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
@ -77,8 +59,6 @@ jobs:
POSTGRES_USER: teqo
POSTGRES_PASSWORD: teqo
POSTGRES_DB: teqo_test
ports:
- 5433:5432
options: >-
--health-cmd "pg_isready -U teqo"
--health-interval 5s
@ -153,11 +133,11 @@ jobs:
- name: Prepare database URL (int service)
run: |
set -euo pipefail
echo "DATABASE_URL=postgresql://teqo:teqo@localhost:5432/teqo_test" >> "$GITHUB_ENV"
echo "DATABASE_URL=postgresql://teqo:teqo@postgres-int: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
# `pnpm worktree next` uses). OPS50.
echo "DATABASE_URL=postgresql://teqo:teqo@postgres-int:5432/teqo_test" > .env.test.local
- name: Migrate (int database)
run: pnpm migrate
- name: Seed minimal (int database)
@ -166,10 +146,10 @@ jobs:
- 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).
# Self-hosted runner has 8 CPUs; vitest forks ≈ CPUs and each fork
# holds a Payload pool (max 10) → overflows the service Postgres' 100
# clients ("too many clients already", CI run 180). 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)
@ -179,16 +159,16 @@ jobs:
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'
if: steps.scope.outputs.build_mode != 'none' || steps.scope.outputs.e2e_mode != 'none'
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
echo "DATABASE_URL=postgresql://teqo:teqo@postgres-build:5432/teqo_test" >> "$GITHUB_ENV"
echo "DATABASE_URL=postgresql://teqo:teqo@postgres-build:5432/teqo_test" > .env.test.local
- name: Migrate (build/e2e database)
if: steps.scope.outputs.build_mode != 'none' || steps.scope.outputs.e2e_mode == 'selected'
if: steps.scope.outputs.build_mode != 'none' || steps.scope.outputs.e2e_mode != 'none'
run: pnpm migrate
- name: Seed minimal (build/e2e database)
if: steps.scope.outputs.build_mode != 'none' || steps.scope.outputs.e2e_mode == 'selected'
if: steps.scope.outputs.build_mode != 'none' || steps.scope.outputs.e2e_mode != 'none'
run: pnpm db:seed:minimal
- name: Build (against the local service database)
@ -196,7 +176,7 @@ jobs:
run: pnpm build
- name: Cache Playwright browsers
if: steps.scope.outputs.e2e_mode == 'selected'
if: steps.scope.outputs.e2e_mode != 'none'
uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
@ -204,23 +184,26 @@ jobs:
restore-keys: |
playwright-${{ runner.os }}-
- name: Install Playwright chromium
if: steps.scope.outputs.e2e_mode == 'selected'
if: steps.scope.outputs.e2e_mode != 'none'
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'
# .next-e2e; the fallback `.next-e2e` dir is what gate-ci.mjs builds).
if: steps.scope.outputs.e2e_mode != 'none'
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).
- name: E2E tests (full suite, single job, 4 workers)
if: steps.scope.outputs.e2e_mode == 'full'
# Explicit CI workers; the local default stays 2 (playwright.config.ts).
# 4 workers in one process replace the former 2 shards × 2 workers.
# 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
- name: E2E tests (manifest-selected specs)
if: steps.scope.outputs.e2e_mode == 'selected'
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 }}

229
.forgejo/workflows/ci.yml Normal file
View file

@ -0,0 +1,229 @@
# Post-merge verifier on `main`, in a fixed 30-minute window (OPS65): the
# workflow no longer fires on every push. The `schedule` trigger polls every
# 30 min and a cheap gate (`gate`, runs-on host, no pnpm) classifies whether
# anything that would enter the docker build context changed since the
# revision running on the homeserver (the `org.opencontainers.image.revision`
# label of the running container, read over ssh).
# - Deployed == HEAD → suite + deploy skip; the run stays green.
# - Anything else (production change, unknown deployed state, dispatch) →
# full suite; deploy in the same window.
# - `workflow_dispatch` always runs the full pipeline (explicit operator
# intent); the deploy is idempotent via the "already deployed" guard in
# scripts/deploy-homeserver.sh.
#
# Fail-open: an unreadable/unknown deployed revision makes the gate report
# production=true, so the suite ALWAYS verifies main and a quiet day only
# skips when we KNOW deployed == HEAD. A gate that cannot classify at all
# (git/.dockerignore error) exits non-zero and turns the run RED — never a
# silent green blackout. The deploy job only runs when the gate succeeded
# AND production changed AND checks are green — never deploy blind.
#
# When the suite is green, the `deploy` job publishes teqo-1313 on the
# homeserver (OPS53): it streams scripts/deploy-homeserver.sh to the homeserver
# over SSH; the remote script builds the standalone image at the pushed SHA,
# pushes it to the local registry (localhost:5000), applies pending migrations
# through the compose maintenance service and rolls the container out.
#
# 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, the run fails and the slot is released
# immediately. No cancel-run API (Forgejo 9.0.3 has none), no watchdog job,
# no upgrades. `deploy` (needs: [checks]) never starts on a red run.
#
# Forgejo port (OPS50): Postgres services live on the job network and are
# reached by NAME (postgres-int / postgres-build on 5432) — no host publish,
# so concurrent runs never collide on host ports (OPS62 X1). No
# `permissions:` (Forgejo issues one native GITHUB_TOKEN) and no
# `concurrency:` (unsupported). `gh workflow run` requeues became
# `node scripts/forgejo-dispatch.mjs`.
name: CI
on:
schedule:
- cron: '*/30 * * * *'
workflow_dispatch:
env:
PAYLOAD_SECRET: test-only-secret-not-used-in-production
NEXT_PUBLIC_SITE_URL: http://localhost:3000
jobs:
gate:
name: gate production change
# Host runner: needs `ssh homeserver` (same environment as the deploy
# job — the key lives on the workstation host) and a full clone to diff
# <deployed-rev>..HEAD. Main + dispatch only: a branch dispatch must not
# run that branch's code on the host — the skipped gate makes the suite
# run fail-open on ubuntu containers instead (test-only secrets).
if: github.ref == 'refs/heads/main'
runs-on: host
timeout-minutes: 10
outputs:
production: ${{ steps.classify.outputs.production }}
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0
- uses: actions/setup-node@v5
with:
node-version: 24
package-manager-cache: false
- name: Read deployed revision and classify production change
id: classify
run: |
set -euo pipefail
# Truth = the RUNNING container's revision label (the compose file
# can lie after a failed rollback — it is swapped before the
# rollout). Empty/malformed = unknown deployed state → the
# classifier reports production=true (full run, green).
deployed_sha="$(ssh homeserver "docker inspect -f '{{index .Config.Labels \"org.opencontainers.image.revision\"}}' teqo-1313 2>/dev/null" || true)"
if ! printf '%s' "$deployed_sha" | grep -Eq '^[0-9a-f]{40}$'; then
deployed_sha=""
fi
echo "deployed_sha: ${deployed_sha:-<none>}"
out="$(node scripts/ci-classify-production.mjs --deployed "$deployed_sha")"
printf '%s\n' "$out"
echo "production=$(printf '%s' "$out" | node -pe 'JSON.parse(require("fs").readFileSync(0,"utf8")).production')" >> "$GITHUB_OUTPUT"
checks:
runs-on: ubuntu-latest
timeout-minutes: 50
needs: [gate]
# Fail-open: an errored gate never skips the verifier; only a gate that
# succeeded AND found no production change skips the suite.
if: needs.gate.result != 'success' || needs.gate.outputs.production == 'true'
services:
# Services are reached by NAME on the job network (no host publish —
# OPS62 X1): concurrent runs never collide on host ports, which the
# single long-lived job would hold for the whole run. Two isolated
# services — int and build+e2e — keep e2e from inheriting int fixture
# residue. Forgejo 9 does not expand expressions in `services.ports`,
# so a per-run port scheme was dropped in favour of the job-network DNS.
postgres-int:
image: postgres:17-alpine
env:
POSTGRES_USER: teqo
POSTGRES_PASSWORD: teqo
POSTGRES_DB: teqo_test
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
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: 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@postgres-int: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). OPS50.
echo "DATABASE_URL=postgresql://teqo:teqo@postgres-int: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:
# Self-hosted runner has 8 CPUs; 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: Prepare database URL (build/e2e service)
run: |
set -euo pipefail
echo "DATABASE_URL=postgresql://teqo:teqo@postgres-build:5432/teqo_test" >> "$GITHUB_ENV"
echo "DATABASE_URL=postgresql://teqo:teqo@postgres-build:5432/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).
# 4 workers in one process replace the former 2 shards × 2 workers.
# 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. Runs directly on the runner HOST (not a job container): the
# deploy needs the workstation's SSH key to reach the homeserver
# (`ssh homeserver`) and streams the whole remote build/migrate/rollout
# to this job's logs. Deliberately wired ONLY here — main window +
# workflow_dispatch — never in ci-pr.yml: a host job is code execution as
# `fsolla` on the workstation, and main only ever moves through a merged
# PR whose full suite was already green.
# OPS65: double-gated — the verifier must be green AND the gate must have
# found a production change. The explicit `if` prevents both "deploy
# blind on a failed gate" and "deploy with a red suite" (a bare output
# condition would run despite failed needs).
needs: [checks, gate]
if: needs.checks.result == 'success' && needs.gate.outputs.production == 'true'
runs-on: host
timeout-minutes: 60
steps:
- uses: actions/checkout@v5
- name: Deploy teqo-1313 on the homeserver
run: |
set -euo pipefail
ssh homeserver "bash -s -- $GITHUB_SHA" < scripts/deploy-homeserver.sh

View file

@ -0,0 +1,30 @@
# Main merge flips claimed issues to done + in-prod.
# Forgejo port (OPS50): the gh loop became scripts/forgejo-issue-transition.mjs
# (plain Node, reads the PR body from the API itself).
# OPS61: the job injects FORGEJO_API_TOKEN — the native GITHUB_TOKEN 403s on
# issue label writes, and the flip was silently skipped (script logged the 403
# and the job still ended success). 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).
name: Main merge flips claimed issues to done + in-prod
on:
pull_request:
types: [closed]
jobs:
issue-done:
if: github.event.pull_request.base.ref == 'main' && github.event.pull_request.merged == true && github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
env:
FORGEJO_API_TOKEN: ${{ secrets.FORGEJO_API_TOKEN }}
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: Flip Closes/Fixes #N issues to done and in-prod
run: node scripts/forgejo-issue-transition.mjs --pr "${{ github.event.pull_request.number }}"

View file

@ -0,0 +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).
#
# 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.
# Forgejo port (OPS50): script reads the PR via the Forgejo API (plain Node).
# OPS61: the job injects FORGEJO_API_TOKEN — the native GITHUB_TOKEN 403s on
# issue label writes (same silent-skip failure as issue-done-on-main-merge).
# 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).
name: Promote plan-issue Related on main merge
on:
pull_request:
types: [closed]
branches: [main]
jobs:
plan-issue-ready:
if: >-
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 #')
)
runs-on: ubuntu-latest
timeout-minutes: 5
env:
FORGEJO_API_TOKEN: ${{ secrets.FORGEJO_API_TOKEN }}
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 (packageManager in package.json) before pnpm/action-setup.
- uses: actions/setup-node@v5
with:
node-version: 24
package-manager-cache: false
- name: Promote Related #N awaiting plan → ready
run: node scripts/agent-promote-related-on-merge.mjs --pr "${{ github.event.pull_request.number }}"

View file

@ -1,64 +0,0 @@
# 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
on:
pull_request:
types: [opened, reopened, synchronize, ready_for_review, converted_to_draft]
branches: [main]
permissions:
contents: write
pull-requests: write
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
runs-on: ubuntu-latest
timeout-minutes: 10
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 }}"

View file

@ -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"

View file

@ -1,80 +0,0 @@
# 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
pull-requests: 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 != '')
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 }}
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 }}"

View file

@ -1,88 +0,0 @@
# 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).
#
# 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
pull-requests: 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 != '')
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 }}
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 (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 }}"

3
.gitignore vendored
View file

@ -90,6 +90,3 @@ next-env.d.ts
# Proprietary design font files kept only in the local workspace
/public/arial-mt/
/data/encode/
/data/poster-candidates/

File diff suppressed because one or more lines are too long

View file

@ -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**.
> 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`).
> 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`).
> 3. CI verde → o safety net `agent-pr-ready-automerge.yml` mergea em `main` por rebase; o verificador `ci.yml` do main roda a suíte completa na janela fixa de 30 min.
> 4. Com mudança de produção → o job `deploy` publica no homeserver (`jorgesolla1313.com.br`, self-hosted) — migrações aplicadas antes do rollout.
> 5. Secrets humanos (uma vez): `FORGEJO_API_TOKEN` no Forgejo; envs de prod em `~/stack/teqo-1313.env` no homeserver. Branch protection de `main` já aplicada (reaplicar: `pnpm configure:branch-protection`).
>
> Comandos: `pnpm agent:claim | agent:register | agent:prioritize | agent:file-miss | worktree next` 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:** envs do homeserver, branch protection, runbook de rollback.
> Tudo em detalhe: [`docs/AGENT-OPS.md`](docs/AGENT-OPS.md) · CI: `.forgejo/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.

View file

@ -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ó Forgejo + 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** (`teqo_1313`, homeserver) | real | job `deploy` do `ci.yml` (OPS53) |
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`.
## 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 (janela de 30 min, OPS65) full suite verde → job `deploy` (homeserver, OPS53 — merge com mudança de produção == site atualizado em ~30 min + suite/deploy)
```
**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 = Issues do Forgejo (`git.solla.dev/fsolla/teqo`).
- **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 via API/MCP **Ready** (nunca draft) com `Closes #N` → o safety net `agent-pr-ready-automerge.yml` espera o rollup `CI (PR) / checks` e mergea por rebase. 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), `pnpm configure:branch-protection` (idempotente; reaplicar se o drift voltar), runbook manual/rollback (`docs/ops/teqo-1313-deploy.md`).
### Dono do PR, dono do CI
@ -36,29 +36,28 @@ main → (opcional, deliberado) workflow_dispatch deploy.yml → verify full sui
| Comando | Faz |
| ------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| `pnpm agent:claim [-- --dry-run]` | Fila ready+unblocked por prio → `in-progress` + brief (Forgejo) |
| `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 no Forgejo (`--plan` ⇒ `blocked` até `agent:ready`; sem plano ⇒ `ready`) |
| `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 **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 push [-- -u origin HEAD …]` | Canônico: ensure-deps + gate:push + push |
| `pnpm configure:branch-protection [-- --dry-run]` | Proteção de `main`: required status check `CI (PR) / checks` (rollup do ci-pr; idempotente — cria/atualiza/no-op) |
| `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: `pnpm push -u origin HEAD` → PR via API/MCP (Ready, base `main`, `Closes #N`) → o safety net `agent-pr-ready-automerge.yml` (`scripts/forgejo-pr-automerge.mjs`) espera o rollup `CI (PR) / checks` e mergea por rebase — não há `gh` em nenhuma máquina (OPS50), o safety net é quem espera os checks. **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). **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). 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,46 +67,39 @@ 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` | 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 e o slot é liberado; e2e 1 processo com `PLAYWRIGHT_WORKERS=4`; 2 services por nome na rede do job (`postgres-int`/`postgres-build`, sem publish host) |
| `ci.yml` | schedule `*/30` / dispatch `main` | 2× service Postgres 17 | Janela fixa (OPS65): gate `gate` (host, main-only, sem pnpm) classifica mudança de produção desde a **revision do container rodando** no homeserver (`.dockerignore` = fonte da verdade; fail-open; incapaz de classificar → run **vermelho**); sem mudança → suite + deploy skipped, run verde; com mudança → job único `checks` sequencial (mesma mecânica X1) + job `deploy` (runs-on `host`) publica no homeserver via `scripts/deploy-homeserver.sh` (OPS53, guard "already deployed") |
| `issue-done-on-main-merge.yml` | PR merged → `main` (same-repo) | — | `Closes`/`Fixes` → `done` + `in-prod` (PAT `FORGEJO_API_TOKEN`; `GITHUB_TOKEN` 403 em Issues) |
| `plan-issue-ready-on-main-merge.yml` | PR merged → `main` (same-repo) | — | `Related #N` aguardando plano → `ready` (OPS18; soft-skip; mesmo PAT) |
| `agent-pr-ready-automerge.yml` | PR same-repo → `main` (open/sync/…) | — | Safety net via `forgejo-pr-automerge.mjs` (plain Node): espera o rollup `checks` e mergea por rebase com `FORGEJO_API_TOKEN` (o `GITHUB_TOKEN` nativo não tem write no repo — o push do merge é rejeitado com 409); draft `cursor/*` → Ready; 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 |
`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.
`agent-pool.yml` foi **removido no OPS65** (pool dormente; o tick `*/10` custava 144 `pnpm install`/dia na workstation).
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 |
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `FORGEJO_API_TOKEN` | Supervisor do pool e scripts Forgejo (`forgejo-api.mjs`); também nos workflows pós-merge (`issue-done`, `plan-issue-ready`) e no safety net — o `GITHUB_TOKEN` nativo 403 em Issues e não tem write no repo (OPS61) |
| `CURSOR_API_KEY` | Supervisor do pool (Cursor Cloud) — **dormente (OPS65)**; só o helper `archive-cursor-agent.yml` a usa hoje |
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).
`POOL_GITHUB_TOKEN` é **legado** (era da transição GitHub): o `scripts/agent-pool.mjs` o ignora quando presente; não criar no Forgejo. 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 Forgejo.
### Flips pós-merge (OPS71-FLIP): o evento `closed` e a recuperação manual
Branch protection de `main` (aplicada em 2026-08-18, OPS61): required status check `CI (PR) / checks` (o rollup do `ci-pr.yml` — único contexto que cobre a cascata inteira), `strict=false`, 0 reviews. O `waitForChecks` do safety net espera esse mesmo rollup antes de mergear (o próprio status do job é `pending` durante o run e nunca entra no veredito); a regra no servidor é o gate real — o Forgejo bloqueia o merge POST com 405 se o contexto exigido não estiver verde, inclusive merges manuais via API (a PR #52 entrou em main com CI vermelho por merge manual). Reaplicar/consertar drift: `pnpm configure:branch-protection` (idempotente).
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).
Deploy (OPS53 + OPS65): o `ci.yml` do main roda em **janela fixa de 30 min** (`schedule */30` + `workflow_dispatch` — não dispara mais a cada push). O job `gate` (host, sem pnpm) lê a **revision do container rodando** no homeserver (label `org.opencontainers.image.revision` — o compose pode mentir após rollback falho) e classifica "mudança de produção" pelos padrões do `.dockerignore` (fonte da verdade do artefato; `docs/`, `.agents/`, `.env*` viram skip; `scripts/`, `tests/`, `.forgejo/`, `AGENTS.md` contam como produção — conservador). Deployed == HEAD → suite e deploy skipped (run verde); estado desconhecido (sem revision) → suite roda fail-open; gate incapaz de classificar → suite roda e o run fica **vermelho** (nunca blackout). Com mudança → suite full verde → job `deploy` (runs-on `host`) streama `scripts/deploy-homeserver.sh` para o homeserver via SSH — build no homeserver (secrets de `~/stack/teqo-1313.env`), registry `localhost:5000`, migrate pelo serviço de maintenance `teqo-1313-migrate` antes do rollout, smoke pós-deploy; o script é idempotente (revision do container já no SHA → skip verde "already deployed"). Nenhum secret novo no Forgejo. Runbook (rollback, falhas conhecidas): `docs/ops/teqo-1313-deploy.md`. O deploy Vercel (`vercel-promote.yml`, scripts, `vercel.json`) foi removido no OPS50.
## 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

View file

@ -4,46 +4,6 @@ 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).
@ -64,16 +24,10 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente
**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`.
@ -104,8 +58,6 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente
**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.

View file

@ -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 (`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; 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`. 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).

View file

@ -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`.

View file

@ -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).

View file

@ -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`.

View file

@ -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`.

View file

@ -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).

View file

@ -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`).

View file

@ -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.

View file

@ -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).

View file

@ -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).

View file

@ -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).

View file

@ -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.

View file

@ -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).

View file

@ -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`.

View file

@ -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).

View file

@ -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.

View file

@ -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).

View file

@ -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.

View file

@ -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.

View file

@ -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.

View file

@ -1,30 +1,40 @@
# Runbook: deploy do site 1313 no homeserver (OPS53 + OPS71 — dispatch manual)
# Runbook: deploy do site 1313 no homeserver (OPS53, janela OPS65)
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).
O deploy de produção é uma etapa do CI (`ci.yml` no Forgejo): mergir em `main`
com a suite verde publica o site em `jorgesolla1313.com.br` — sem passo manual.
Merge **com mudança de produção** = site atualizado em ~30 min (janela) + tempo
da suite/deploy (~15–25 min cada; tipicamente < 1 h no total).
## 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**
1. `git push` para `main` (via PR merge) — o `ci.yml` **não dispara mais a cada
push**: roda por **janela fixa de 30 min** (`schedule */30`) ou via
`workflow_dispatch` (deploy manual, imediato, sempre full suite).
2. Job `gate` (barato, host, sem pnpm): lê a **revision do container rodando**
no homeserver (`docker inspect` → label `org.opencontainers.image.revision`
do `teqo-1313` — a fonte de verdade; o compose pode mentir após rollback
falho, pois é trocado antes do rollout) e classifica "mudança de produção"
com o `.dockerignore` como fonte da verdade (o que não entra na imagem 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.
- Sem revision conhecida (label ausente, container sem label) → fail-open:
suite + deploy rodam na janela (run verde; comportamento conservador).
- Gate incapaz de classificar (erro de git/`.dockerignore`) → suite roda e
o run fica **vermelho** — nunca blackout silencioso.
3. Com mudança de produção: suite completa verde (static/int/build/e2e/checks).
4. Job `deploy` (runs-on `host`, na **workstation**) roda
`ssh homeserver "bash -s -- <sha>" < scripts/deploy-homeserver.sh`.
O script é **idempotente**: se o container rodando já tem a revision do SHA
(ex.: dispatch duplicado), sai verde "already deployed" sem rebuild.
5. O script, no **homeserver**: guarda de HEAD (`main` remoto == SHA do job,
senão skip) → `flock` (serializa) → guard "already deployed" (revision do
container) → **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;
@ -33,68 +43,31 @@ homeserver, o job `verify` (hosted) roda a suíte **full** (incl. e2e full).
→ 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).
6. Falha = job vermelho no commit; nada é publicado pela metade.
**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.
**Primeira janela (verificação ao vivo):** após o merge do OPS65, acompanhe um
run agendado — confira no log do job `gate` o `deployed_sha` lido do container e
o veredito da classificação, e que um dispatch manual roda suite + deploy
(idempotência: segundo dispatch no mesmo SHA sai "already deployed"). Isso
prova ao vivo a paridade Forgejo de `needs.<gate>.outputs` com gate falho
(suite roda; deploy pula; run vermelho).
## 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) |
| Máquina | Papel |
| -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **workstation** (16c/61GB) | runner Forgejo (`workstation-runner`); job `deploy` roda no host (chave SSH `~/.ssh/id_ed25519` → `ssh 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; workspace `~/teqo-deploy` (clone do Forgejo local) |
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).
Segredos **não** ficam no Forgejo: o script sourceia `~/stack/teqo-1313.env` e
`~/stack/.env` (chmod 600) no próprio homeserver. Nenhum secret novo em
Settings → Actions.
## Cutover OPS71 (passos manuais, ordem)
## Rollback
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>`.
A imagem anterior continua local e no registry (`localhost:5000/teqo-1313:<sha>`
— o registry nunca deleta). O compose anterior fica em
`~/stack/docker-compose.yml.pre-<sha-do-deploy-que-falhou>`.
```bash
ssh homeserver
@ -103,7 +76,7 @@ cd ~/stack
# 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):
# 2. a imagem antiga ainda está local; se não, puxe do registry:
docker pull localhost:5000/teqo-1313:<sha-anterior>
docker tag localhost:5000/teqo-1313:<sha-anterior> teqo-1313:<sha-anterior>
# 3. subir:
@ -117,40 +90,39 @@ roda após migrator build+push+swap ok; rollout só após runner build ok). Desd
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.
a correção se re-mergeia e a próxima janela completa o deploy.
## 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 |
| Sintoma | Causa | Tratamento |
| ----------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Job verde sem deploy ("stale run") | Push mais novo venceu; o deploy antigo saiu da fila | Nada a fazer — o run do SHA novo deploya |
| 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 |
| Gate `gate` vermelho (erro de git/`.dockerignore`) | SHA deployado ilegível para o git (ex.: história reescrita) ou `.dockerignore` corrompido no checkout | Fail-open: suite roda; deploy pula (nunca deploy às cegas). Corrigir a causa e esperar a próxima janela (auto-cura). Se `ssh homeserver` estiver fora, o gate lê revision vazia e roda tudo (verde) — checar tailnet se a suite roda sem deploy recorrente |
| Sem revision no container (label ausente no compose) | O compose do homeserver não define `org.opencontainers.image.revision` (deploy pré-OPS53 ou edição manual) | Degradação conservadora: toda janela roda suite + rebuild (verde). Corrigir adicionando o label ao serviço `teqo-1313` no compose e re-deployando |
| Todo job docker quebra com `exec: "node": executable file not found in $PATH` | `runner.envs.PATH` do `~/.forgejo-runner/config.yaml` (workstation) com paths só de host (nvm/bun) — o container perde o node do toolcache (`/opt/acttoolcache`); edição do config exige o toolcache primeiro (ver comentário no próprio config) | Corrigir o PATH no config (toolcache primeiro — ver comentário no próprio arquivo) + `systemctl --user restart forgejo-runner` (incidente 2026-08-17) |
| 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 que vai no pipe do `bash -s`; 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 |
| `checkout@v5` falha no job `host` | Label host do act_runner | Fallback: baixar o script direto do Forgejo (`curl -s https://git.solla.dev/fsolla/teqo/raw/branch/main/scripts/deploy-homeserver.sh`) |
| 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).
- O job `deploy` usa `runs-on: host` — executa como `fsolla` na workstation.
Está **apenas** no `ci.yml` (janela `*/30` em `main` + dispatch), nunca no
`ci-pr.yml`. O job `gate` tem o mesmo perfil e é restrito a
`github.ref == 'refs/heads/main'` (dispatch em branch não roda código do
branch no host — a suite roda fail-open em containers com secrets de teste).
`main` só anda por PR mergeado com suite verde. Fork-PRs não disparam
workflows no Forgejo (default off — verificado).
- 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
- `scripts/deploy-homeserver.sh` — o script remoto (fonte da verdade do fluxo)
- `docs/plans/ops53-ci-deploy-homeserver*.md` — intenção e decisões
- infra-solla: `STATE.md`, `plano-infra-final.md` §"Arquitetura de deploy"

View file

@ -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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

View file

@ -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>

View file

@ -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)
![Rascunho UI — desktop](captura-novidades-home-campanha-ui-draft-desktop.png)
![Rascunho UI — mobile](captura-novidades-home-campanha-ui-draft-mobile.png)
![Rascunho UI — sucesso](captura-novidades-home-campanha-ui-draft-sucesso.png)
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)

View file

@ -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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

View file

@ -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>

View file

@ -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:
![Rascunho UI — desktop](cards-acompanhe-de-perto-polimento-ui-draft-desktop.png)
Cena 2 (mobile ~390px — carrossel): mesmo card do carrossel, capa de borda a borda, sem chip:
![Rascunho UI — mobile](cards-acompanhe-de-perto-polimento-ui-draft-mobile.png)
## 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

View file

@ -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)

View file

@ -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)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

View file

@ -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>

View file

@ -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)
![Rascunho UI — detalhe da demanda com seção Responsáveis](demandas-responsaveis-ui-draft-detalhe.png)
![Rascunho UI — criação com campo Responsáveis](demandas-responsaveis-ui-draft-criacao.png)
## 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.

View file

@ -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).

View file

@ -1,62 +0,0 @@
# Escala pós-C141: options write-scoped para formulários de escrita
Status: rascunho
Atualizado em: 2026-08-19
Issue: C144 (a criar)
Priority: P2
Model: deepseek/deepseek-v4-flash
Impeccable: A — backend de dados de formulário (o C142 cuida da apresentação)
Appetite: ~0,5–1 dia eng
## Contexto (achado do /simplify da entrega C141)
`loadMunicipalityOptions` (`src/utilities/campaignRelationOptions.ts`) resolve os
municípios oferecidos nos formulários de escrita com o escopo de LEITURA
(`overrideAccess: false` + access de `canReadMunicipality`). Desde o C141, um
assessor com **Visão "Tudo" + Edição "Carteira"** vê os 435 municípios nos
pickers de atividade/demanda/apoiador/liderança, mas o servidor rejeita o
submit fora da carteira (fail-closed, mensagem genérica) — e em **atividade**
o servidor hoje **aceita** (o create individual foi corrigido no C141, mas o
formulário continua oferecendo o estado inteiro). A apresentação por perfil é
o item irmão C142; este item entrega a base de dados correta para os
formulários de escrita.
## Objetivo
- Os pickers de município dos **formulários de escrita** oferecem apenas
municípios no escopo de **escrita** do ator (`getWritableMunicipalityIds`):
unrestricted/tudo → todos; carteira → carteira; somente leitura → nenhum
(formulários de escrita nem abrem para esse assessor).
- Superfícies alvo: atividade (create/update + giro), demanda (criar),
apoiador (novo), liderança (criar/editar), organização (vínculos).
- Leitura (listas/detalhes/mapa) continua read-scoped — não tocar.
- Sem mudança de enforcement (C141 já fail-closed no servidor).
## Direção no codebase
- `src/utilities/campaignRelationOptions.ts` (`loadMunicipalityOptions`) —
variante write-scoped (parametro `scope: 'read' | 'write'` ou helper irmão)
consumida pelos form actions/pages de escrita; manter o fragmento P3-D
(`advisorMunicipalityScopeWhere`) e o `getWritableMunicipalityIds` do C141.
- Mapear os call sites dos pickers (atividades/agenda, demandas, apoiadores,
lideranças, organizações) e trocar só os de escrita.
## Fases
1. Variante write-scoped + call sites de atividade (onde o servidor aceita) — tester int: picker de atividade reflete o eixo.
2. Demanda/apoiador/liderança/organização (UX — servidor já rejeita).
3. Gates (`pnpm push`); e2e no CI.
## Já resolvido no simplify/critique (não reabrir)
- Criação de atividade avulsa/giro fora do escopo: server-side no C141
(`getWritableMunicipalityIds` no create individual e no batch do giro).
- Ficha de contatos write-scoped (`assertPersonContactWritable`).
- Ações de liderança write-scoped (`assertMunicipalitiesWithinScope`).
- `allocationDecision` create capado na carteira.
## Explicitamente fora (deste lote)
- Apresentação por perfil (botões/controles/atalhos) — C142.
- Enforcement server-side — C141 (já entregue).
- Options de leitura (listas/detalhes) — read-scoped permanece.

View file

@ -1,123 +0,0 @@
# Impl: Feed do Instagram configurado não aparece na home pública
Status: aprovado
Atualizado em: 2026-08-19
Issue: #115
Intenção: docs/plans/feed-instagram-nao-aparece-home.md
Appetite restante: ~1 dia eng
## Leitura da intenção
- **Outcome:** a assessoria vê, na própria global "Feed de redes sociais", o estado da sincronização do Instagram — "Sincronizado · há X min · N posts", "Falha na última sincronização" com o motivo em linguagem de produto e a correção esperada, ou "Instagram ainda não configurado" — e tem um botão "Tentar sincronizar de novo" para feedback imediato. O board público permanece fail-closed como hoje (artigos + YouTube quando o IG falha); a home pública não muda em nada.
- **O que NÃO negociar:** painel de status por plataforma na própria global, só estado atual + motivo (sem histórico, sem alertas, sem dashboard); sem wizard OAuth; sem embeds no site público; token nunca exposto fora do admin (`read` admin-only preservado); copy em pt; identificadores em inglês; a home pública nunca quebra nem vaza erro.
- **O que reavaliar:** a hipótese da intenção apontava `InstagramPostExclusionPicker` como superfície do status — o picker é a lista de exclusão (S3) e **fica intacto**; o painel de status é um **irmão** (`InstagramSyncStatusPanel`), mesma família de componente `admin.components`, mesmo padrão do rascunho (caixa de status separada da lista). `revalidateRequest.ts` realmente não muda (a tag `social-feed` já cobre). O diagnóstico da ocorrência em produção é impossível deste worktree (sem acesso ao DB de prod — `PROD_DATABASE_URL` não existe localmente): o próprio painel vira o instrumento de diagnóstico (1 clique após o deploy confirma a causa), e o runbook documenta validação/geração de token.
## Abordagem recomendada
```mermaid
flowchart LR
A[Global SocialFeedSettings<br/>+ instagramSyncStatus jsonb] --> H[afterChange<br/>revalidate + sync se credencial mudou]
H --> S[syncInstagramFeed<br/>load + snapshot + status]
B[Botão Tentar sincronizar] --> R[POST /api/social-feed/sync<br/>auth admin + same-origin]
R --> S
C[Render home<br/>getInstagramFeed · unstable_cache] -->|sucesso| P[persist snapshot + status<br/>SQL cru, sem hooks]
C -->|falha| Q[persist status erro +<br/>fallback snapshot]
S --> P
S --> Q
P --> G[(social_feed_settings)]
Q --> G
G --> PANEL[InstagramSyncStatusPanel<br/>ui field · 5 estados]
```
**Opções consideradas:** A | B | C
**Recomendação:** A — estado da sincronização **persistido** numa coluna `jsonb` da própria global (escrito por render, hook e botão, todos pelo mesmo módulo `instagramSync.ts`); painel admin como campo `ui` irmão do picker; rota `POST /api/social-feed/sync` (auth de sessão admin + `isSameOriginRequest`) para o botão; erro da API mapeado para copy de produto (`InstagramApiError` + `describeInstagramError`). O caminho de render ganha apenas a persistência do status dentro do try/catch existente — zero mudança visual na home, fail-closed intacto.
**Rejeitadas:** B) estado derivado em runtime (o painel admin não renderiza a home — sem estado não há como mostrar nada; e a intenção exige o estado da **última** sincronização, que precisa sobreviver a restart) | C) server action no botão (primeiro uso sem precedente no shell do admin do Payload; rota HTTP é previsível, testável e segue o precedente de `/api/revalidate`).
### Decisões de engenharia
1. **Estado persistido como `instagramSyncStatus` (jsonb, campo hidden) — uma coluna, shape `{ lastSyncAt?, postCount?, error?, errorAt? }`.**
Opções: A) uma coluna jsonb (padrão do `instagramFeedSnapshot`) | B) 3 colunas (`lastSyncAt`, `postCount`, `lastError`) | C) estado em memória/cache.
Recomendação: A — o snapshot já é jsonb escrito por SQL cru; uma coluna nova espelha o padrão; o shape é autodescritivo e muda sem migration futura. `lastSyncAt`/`postCount` são da última sincronização **bem-sucedida**; `error`/`errorAt` da última **falha** (falha após sucesso preserva os campos de sucesso? **não** — o painel em falha mostra só o erro, cena 1 do rascunho; overwrite é mais simples e sem leitura-modificação-escrita).
Rejeitadas: B 3 colunas = 3 DDLs para o mesmo dado; C não sobrevive a restart e o painel admin não compartilha o render.
→ **Migration:** `pnpm migrate:create add_instagram_sync_status` (ADD COLUMN jsonb nullable, não destrutiva; revisar o DDL gerado — precedente S2/S3 de drift em migrações hand-written).
2. **`InstagramApiError` (status + apiMessage) no `loadInstagramFeed` + `describeInstagramError` para copy de produto.**
Opções: A) o fetch parseia o body de erro da Graph API (`{ error: { message, type } }`) e lança erro tipado; `describeInstagramError` mapeia para pt-BR com a correção | B) mensagem genérica "falhou com status N" (status quo) | C) mapa estático por status.
Recomendação: A — o produto exige o "porquê" (token recusado vs ID inválido vs rede); o body da Graph API distingue OAuthException (token inválido/expirado/emitido via Facebook Login — copy do draft) de "Invalid user id"; status 5xx = API indisponível; `TypeError` de fetch = rede. O mesmo `describeInstagramError` serve render, hook e rota (uma spelling).
Rejeitadas: B é o bug atual (assessoria cega); C não lê o body e não cobre casos reais.
- Mapeamentos: `400|401|403` + OAuthException/`expired|invalid|revoked` → "O token do Instagram foi recusado pela Graph API — está inválido/expirado ou foi emitido via Facebook Login (a Graph API só aceita tokens gerados pelo Instagram Login). Gere um novo token de longa duração pelo Instagram Login e atualize o campo acima."; `400` + menção a user/id → "O ID do usuário não foi reconhecido pela Graph API. Confira se é o ID numérico da conta Business/Creator."; `5xx`/outros → "A Graph API está indisponível no momento (status N). Tente novamente em alguns minutos."; `TypeError` de fetch → "Não foi possível falar com a API do Instagram (rede indisponível)."; desconhecido → "Erro inesperado ao sincronizar o Instagram."
3. **Rota `POST /api/social-feed/sync` para o botão — auth de sessão admin + CSRF same-origin.**
Opções: A) route handler em `(frontend)/api/social-feed/sync` com `payload.auth({ headers })` exigindo `collection === 'users'` + `isSameOriginRequest` (helper já existente) | B) server action | C) rota sem auth (confia no botão).
Recomendação: A — o precedente `sameOriginRequest.ts` é literalmente para "cookie-authenticated JSON route handlers"; `payload.auth` lê o cookie `payload-token` da sessão admin (precedente `campaignAuth`); a rota vive ao lado de `/api/revalidate` (grupo `(frontend)` — route handlers não passam por layouts; o URL é `/api/social-feed/sync`). Retorna `{ ok, status }`; status `null`/400 quando IG não configurado (o botão nem renderiza nesse estado — defesa).
Rejeitadas: B sem precedente no shell admin (risco de incompatibilidade com o RSC do Payload, custo de spike); C permite abuso do rate da Graph API.
→ Depois do sync, a rota chama `revalidateTag('social-feed')` (fora de cache — permitido).
4. **Sync no `afterChange` da global quando a credencial IG muda (awaited, bounded, à prova de erro).**
Opções: A) hook `afterChange` [revalidateFeed, syncInstagramAfterChange] — sync **awaited** quando `doc` ≠ `previousDoc` em `instagramAccessToken`/`instagramUserId`/`instagramEnabled` (false→true); `AbortSignal.timeout(10_000)` no fetch; try/catch total — erro de sync nunca quebra o save | B) sync em todo save | C) sem hook — só botão e render.
Recomendação: A — o fluxo da intenção é "preenche token + ID e salva → o admin mostra o estado": com o sync awaited, o reload da global após o salvar já mostra o resultado **com as credenciais novas** (o estado antigo mostraria a falha da credencial antiga — confuso e o problema do status quo). Gate por mudança: save de exclusões/YouTube não gasta chamada de API nem adiciona latência. Timeout: Graph API responde em <1s mesmo em erro; 10s cap em API pendurada.
Rejeitadas: B latência e custo em todo save sem ganho; C deixa o fluxo principal da intenção em dois cliques e o estado inicialmente mentiroso.
→ O hook usa o MESMO `syncInstagramFeed` da rota (uma implementação, dois call sites).
5. **Painel `InstagramSyncStatusPanel` (campo `ui`, irmão do picker) — 5 estados.**
Opções: A) componente client próprio lendo os campos do form via `useAllFormFields` + estado local pós-botão | B) estender o picker | C) campo `json` não-hidden renderizado cru.
Recomendação: A — o picker prova o padrão (campo `ui` + componente client + `useAllFormFields`); o painel lê `instagramSyncStatus` (inicial), `instagramAccessToken`/`instagramUserId`/`instagramEnabled` e mantém estado local só para o pós-clique (o status já está persistido no servidor — o form não precisa ser modificado). Estados: (1) `instagramEnabled === false` → "Instagram desativado — os posts não aparecem no board."; (2) sem token ou sem ID → "Instagram ainda não configurado" + explicação (cena 3); (3) `error` → "Falha na última sincronização" + mensagem + botão "Tentar sincronizar de novo" (cena 1); (4) `lastSyncAt` → "Sincronizado · há X min · N posts" + "Próxima atualização automática em ~5 min. Os N posts mais recentes estão no board 'Acompanhe de perto' da home." (cena 2, sem botão — fiel ao rascunho); (5) configurado sem status → "Aguardando a primeira sincronização" + botão. Pós-clique: `pending` ("Sincronizando…") → sucesso cena 2 / falha cena 1 com o motivo retornado. Tempo relativo com `Intl.RelativeTimeFormat('pt-BR')` local (o painel é client; `formatRelativePostDate` é server-only — `posts.ts` abre com `import 'server-only'`).
Rejeitadas: B mistura duas responsabilidades no picker (o picker é lista de exclusão; o status é estado operacional — o rascunho os desenha como caixas separadas); C expõe JSON cru (anti-produto).
→ **UI:** Impeccable C — encaixe na linguagem do admin (caixas zinc/red do draft, `text-sm`, bordas `rounded-lg`), shape → craft → critique → polish no navegador (admin, 3 cenas + estados).
6. **`loadInstagramFeed` ganha `signal?: AbortSignal` (repasse ao `fetchImpl`).**
O hook precisa do deadline; o `LoadInstagramFeedArgs` aceita `init` no fetchImpl — o signal flui sem mudar a assinatura do fetch. Unit-testado.
7. **Stub IG ganha estado `invalid-token` (400 com body `{ error: { type: 'OAuthException', ... } }`).**
A cena 1 do draft é literalmente esse erro; o e2e do painel precisa exercitá-lo (o estado `fail` atual responde 500 genérico). O painel de falha com a copy do token é o aceite central do item.
### Componentes / mudanças
- **`InstagramApiError` + `parseInstagramErrorBody`** (`src/utilities/socialFeed/instagramFeed.ts`, estender): parseia body de erro em resposta não-2xx, lança `InstagramApiError` (`status` + `apiMessage`); `loadInstagramFeed` aceita `signal` opcional repassado ao `fetchImpl` (fetch do usuário, media e refresh).
- **`src/utilities/socialFeed/instagramSync.ts`** (novo, `server-only`, dentro do subfolder `socialFeed/` — a pin de top-level da `codebaseConventions.unit.spec.ts` não se aplica a subfolders, o comentário já cobre o domínio):
- `InstagramSyncStatus` (`{ lastSyncAt?: string; postCount?: number; error?: string; errorAt?: string }`).
- `describeInstagramError(cause: unknown): string` — mapeamentos da Decisão 2 (puro, exportado p/ unit).
- `successInstagramSyncStatus(postCount)` / `failedInstagramSyncStatus(error)` — builders puros.
- `persistInstagramSyncStatus(payload, status)` — SQL cru UPDATE+INSERT (mesmo padrão/precondição do snapshot; rodado DENTRO de `unstable_cache` no render — sem hooks).
- `syncInstagramFeed(payload, { signal? }): Promise<{ ok: true; status } | { ok: false; status }>` — lê a global, retorna "não configurado" sem tocar na API quando `enabled === false`/`instagramEnabled === false`/sem token/ID; sucesso → `persistInstagramSnapshot` + persist token renovado (reuso dos privados? **não** — os `persist*` atuais são module-private do `instagramFeed.ts`; exportar `persistInstagramSnapshot`/`persistInstagramAccessToken` do `instagramFeed.ts` para o sync module — o dono do persist é o feed, o sync é o orquestrador) + status de sucesso; falha → status de erro via `describeInstagramError`; nunca lança (status é o resultado).
- **`getInstagramFeed`** (`instagramFeed.ts`, estender): no try (sucesso) persiste também o status de sucesso; no catch persiste o status de erro ANTES do fallback snapshot (a ordem não importa para o board; importa para o painel). Nada mais muda no caminho público.
- **`SocialFeedSettings`** (`src/globals/SocialFeedSettings.ts`, estender): campo `instagramSyncStatus` (json, `admin.hidden: true` — o painel renderiza, o dado não aparece cru); campo `ui` `instagramSyncStatusPanel` (após `instagramEnabled`, antes do token — status no topo do bloco IG, fiel ao rascunho; `admin.components.Field` → painel); `afterChange` = `[revalidateFeed, syncInstagramAfterChange]` (gate da Decisão 4, try/catch total). Copy do campo token/`admin.description`: ajuste de 1 linha para apontar o painel de status ("veja o estado da sincronização abaixo/ao lado") — opcional, não bloquear.
- **`src/app/(frontend)/api/social-feed/sync/route.ts`** (novo): `POST`; `isSameOriginRequest` (401 se cross-origin); `payload.auth({ headers })` exigindo `user.collection === 'users'` (401 sem sessão admin); lê a global, IG desconfigurado → `400 { ok: false, error }`; `syncInstagramFeed` com `AbortSignal.timeout(10_000)`; `revalidateTag(REVALIDATE_SOCIAL_FEED_TAG)`; `200 { ok, status }`. Erro interno → `500` (sem stack).
- **`src/components/admin/InstagramSyncStatusPanel.tsx`** (novo, `'use client'`): estados da Decisão 5; fetch para a rota no clique; `aria-busy` no pending; sem escrita no form (status é persistido no servidor).
- **`tests/e2e/instagram-stub.mjs`** (estender): estado `'invalid-token'` → `400 { error: { type: 'OAuthException', message: 'Error validating access token' } }` em `/user` e `/media` (e refresh → 400); `POST /__stub/state` aceita o novo estado.
- **Migration:** `add_instagram_sync_status` (jsonb nullable). **Types/importmap:** `pnpm generate:types` + `pnpm generate:importmap` (com envs `S3_*` dummy setadas — OPS69). **Access/Consent:** sem consent (estado operacional interno, não PII); global segue admin-only; a rota exige sessão admin. **UI:** Impeccable C (encaixe no admin; shape → craft → critique → polish).
### Dados → forma
- O painel mostra **estado operacional**, não métrica: contagem de posts sincronizados (cru, da API), tempo desde a última boa, motivo da última falha. Nada de histórico, alertas ou engajamento (corte da intenção). Forma: caixas do rascunho (verde/zinc neutro sucesso, red falha, zinc neutro não-configurado) — contraste e semântica visuais primeiro, sem cor inventada.
## Fases verificáveis
1. **Schema + servidor** (~metade do appetite): migration + `generate:types`; `InstagramApiError` + parse do body + `signal` no `loadInstagramFeed`; `instagramSync.ts` (status builders + `describeInstagramError` + persist SQL + `syncInstagramFeed`); persistência de status no `getInstagramFeed`; rota `/api/social-feed/sync`. Unit tests: `describeInstagramError` (OAuthException→token, invalid-user→ID, 5xx→indisponível, TypeError→rede, unknown→inesperado), `loadInstagramFeed` com fetchImpl 400+body de erro lançando `InstagramApiError`, repasse de `signal`, builders de status, gate `instagramCredentialsChanged` (doc/previousDoc: token mudou, ID mudou, enabled false→true, exclusão não dispara). Gates parciais: tsc + `pnpm test`.
2. **Painel + hook + runbook** (restante): `InstagramSyncStatusPanel` + campo `ui` + hook `afterChange` com sync awaited; stub `invalid-token`; runbook `docs/ops/instagram-feed-token-runbook.md` (o que o painel mostra; validação do token via refresh/me com curl; geração pelo Instagram Login vs Facebook Login; a provável causa da ocorrência atual e como o painel confirma em 1 clique pós-deploy); alinhar a copy do vazio do picker com o rascunho ("Nenhum post sincronizado. Após uma sincronização bem-sucedida, os últimos posts aparecem aqui…"); `generate:importmap` + AGENTS.md (1 linha: painel de status + rota de sync na seção do `social-feed`). Polish no navegador (admin, 3 cenas).
3. **e2e + gates finais**: admin smoke — seed com token+ID → painel "Aguardando a primeira sincronização" → clique → "Sincronizado" com contagem + assert do `instagramSyncStatus` via REST; stub `invalid-token` → falha com copy do token + botão; stub `fail` → falha 500; reset do helper `resetSocialFeedSettings` ganha `instagramSyncStatus: null` (determinismo do serial); smoke do picker existente deve continuar verde (o hook passa a sincronizar no seed com credenciais — stub ok, determinístico). Gates: `pnpm gate:fast`, `pnpm test`, `pnpm build` local, e2e no CI; changelog `docs/changelog/2026-08-19-s11.md` + `pnpm changelog:build`.
## Rabbit holes / Não escopo (engenharia)
- Não sincronizar no salvar de exclusões/YouTube (gate por credencial — Decisão 4).
- Não fazer histórico/alerta/dashboard de saúde; não tocar no board público nem nos cards.
- Não mexer no fluxo de refresh de token existente (o `loadInstagramFeed` já refresca e persiste; o sync só reporta).
- Não paginar o feed, não buscar stories/métricas.
- Não backfill de status retroativo (a primeira sincronização após o deploy escreve o estado).
- Não acesso ao DB de prod para o diagnóstico (fora do alcance do worktree); o painel + runbook cobrem o aceite.
- Não mudar a pin de `codebaseConventions` (o módulo novo fica no subfolder `socialFeed/` já documentado).
## Riscos e mitigação
- **Rota auth no admin:** `payload.auth({ headers })` com cookie `payload-token` — precedente `campaignAuth`; sem sessão → 401 silencioso (o painel mostra o erro como falha de sincronização? **não** — o painel trata 401 como "sessão expirada": recarrega a página para o admin re-autenticar; copy honesta). e2e cobre.
- **Hook sync com latência no save:** cap `AbortSignal.timeout(10_000)`; sync nunca lança (try/catch); save de credenciais é raro (assessoria configura uma vez).
- **`revalidateTag` dentro de cache:** a persistência de status no render usa SQL cru (sem hooks) — mesma restrição documentada do snapshot (Next 15.4 lança `revalidateTag` dentro de `unstable_cache`); a rota/hook revalidam fora do cache (permitido).
- **e2e serial com hook novo:** todo seed de credenciais via REST dispara o sync (stub determinístico, ok/fail por estado); resets incluem `instagramSyncStatus: null`; orçamento de polling do serial mantido.
- **Formato do body de erro da Graph API:** documentado (`{ error: { message, type } }`); parse defensivo — body inesperado → mensagem genérica por status (nunca quebra o fluxo).
- **Painel com estado stale após save:** o sync awaited no hook resolve (status escrito antes do reload); botão cobre o resto; "há X min" é honesto (última boa real).
## Aceite de engenharia
- [ ] Aceite de produto da intenção coberto: painel de status na global (sincronizado/falha com motivo+não configurado), botão "Tentar sincronizar de novo", home pública inalterada e fail-closed, runbook curto com validação/geração de token, token nunca fora do admin
- [ ] Invariantes AGENTS/engineering-standards: migration não destrutiva; sem consent novo; identificadores em inglês; copy/admin em pt; access admin-only preservado; rota com auth de sessão + CSRF same-origin; tag `social-feed` já cobre revalidação; SQL cru fora de cache
- [ ] Testes de domínio previstos: unit (mapeamentos de erro, `InstagramApiError`, signal, builders, gate do hook) + e2e (painel ok/falha token/falha 500, status persistido via REST, serial determinístico)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

View file

@ -1,315 +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 — Feed do Instagram configurado não aparece na home</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">
Assessoria de comunicação configura o feed do Instagram no admin (global "Feed de redes
sociais") e precisa ver, no mesmo lugar, por que os posts não aparecem na home pública.
</p>
<!-- Cena: falha na sincronização (o caso real em produção) -->
<section data-shot="admin-falha-token" class="mt-10">
<p class="mb-3 text-sm font-medium text-zinc-300">Cena 1 — token recusado pela Graph API</p>
<div class="rounded-xl border border-zinc-700 bg-zinc-100 text-zinc-900">
<div class="flex items-center justify-between border-b border-zinc-300 px-6 py-4">
<div class="flex items-center gap-2">
<span class="rounded bg-zinc-900 px-2 py-0.5 text-xs font-semibold text-zinc-100">
Configurações
</span>
<span class="text-sm text-zinc-600">/ Feed de redes sociais</span>
</div>
<span class="rounded border border-zinc-400 bg-white px-3 py-1 text-xs text-zinc-600">
Draft · Salvar · Cancelar
</span>
</div>
<div class="px-6 py-5">
<h1 class="text-lg font-bold">Feed de redes sociais</h1>
<p class="mt-1 max-w-2xl text-sm text-zinc-600">
Fonte automática do board "Acompanhe de perto" da home de campanha. Sem chave
configurada, os cards da plataforma não aparecem.
</p>
<div class="mt-5 rounded-lg border border-red-300 bg-red-50 p-4">
<p class="text-sm font-bold text-red-800">Falha na última sincronização</p>
<p class="mt-1 max-w-2xl text-sm text-red-700">
A Graph API recusou o token configurado (token inválido/expirado ou emitido via
Facebook Login — a Graph API só aceita tokens gerados pelo Instagram Login).
Enquanto isso, nenhum post do Instagram aparece na home.
</p>
<p class="mt-1 max-w-2xl text-sm text-red-700">
<strong>Correção:</strong> gere um token de longa duração pelo Instagram Login
(conta Business/Creator vinculada à página) e atualize o campo abaixo.
</p>
<button
class="mt-3 rounded-md bg-red-600 px-3 py-1.5 text-sm font-semibold text-white"
>
Tentar sincronizar de novo
</button>
</div>
<div class="mt-6 grid max-w-2xl grid-cols-1 gap-4">
<div>
<label class="text-xs font-semibold text-zinc-700"
>Token de acesso (Instagram Graph API)</label
>
<div
class="mt-1 rounded-md border border-zinc-400 bg-white px-3 py-2 text-sm text-zinc-700"
>
IGAA...abc123
</div>
</div>
<div>
<label class="text-xs font-semibold text-zinc-700"
>ID do usuário (conta Business/Creator)</label
>
<div
class="mt-1 rounded-md border border-zinc-400 bg-white px-3 py-2 text-sm text-zinc-700"
>
17841400000000000
</div>
</div>
<div>
<label class="text-xs font-semibold text-zinc-700">Máximo de posts</label>
<div
class="mt-1 w-24 rounded-md border border-zinc-400 bg-white px-3 py-2 text-sm text-zinc-700"
>
3
</div>
</div>
</div>
<div class="mt-6 max-w-2xl rounded-lg border border-zinc-300 bg-white p-4">
<p class="text-sm font-bold text-zinc-800">Instagram — posts recentes</p>
<p class="mt-1 text-sm text-zinc-500">
Nenhum post sincronizado. Após uma sincronização bem-sucedida, os últimos posts
aparecem aqui para marcar os que não devem ir para a home.
</p>
</div>
<div class="mt-6 flex items-center justify-end gap-2 border-t border-zinc-300 pt-4">
<button
class="rounded-md border border-zinc-400 bg-white px-4 py-1.5 text-sm font-semibold text-zinc-700"
>
Cancelar
</button>
<button class="rounded-md bg-red-600 px-4 py-1.5 text-sm font-semibold text-white">
Salvar
</button>
</div>
</div>
</div>
</section>
<!-- Cena: sincronizado, picker com posts -->
<section data-shot="admin-ok" class="mt-12">
<p class="mb-3 text-sm font-medium text-zinc-300">Cena 2 — sincronizado com sucesso</p>
<div class="rounded-xl border border-zinc-700 bg-zinc-100 text-zinc-900">
<div class="flex items-center justify-between border-b border-zinc-300 px-6 py-4">
<div class="flex items-center gap-2">
<span class="rounded bg-zinc-900 px-2 py-0.5 text-xs font-semibold text-zinc-100">
Configurações
</span>
<span class="text-sm text-zinc-600">/ Feed de redes sociais</span>
</div>
<span class="rounded border border-zinc-400 bg-white px-3 py-1 text-xs text-zinc-600">
Draft · Salvar · Cancelar
</span>
</div>
<div class="px-6 py-5">
<h1 class="text-lg font-bold">Feed de redes sociais</h1>
<div class="mt-5 rounded-lg border border-zinc-300 bg-white p-4">
<p class="text-sm font-bold text-zinc-800">Sincronizado · há 2 min · 4 posts</p>
<p class="mt-1 max-w-2xl text-sm text-zinc-500">
Próxima atualização automática em ~5 min. Os 4 posts mais recentes estão no board
"Acompanhe de perto" da home.
</p>
</div>
<div class="mt-6 grid max-w-2xl grid-cols-1 gap-4">
<div>
<label class="text-xs font-semibold text-zinc-700"
>Token de acesso (Instagram Graph API)</label
>
<div
class="mt-1 rounded-md border border-zinc-400 bg-white px-3 py-2 text-sm text-zinc-700"
>
IGAA...abc123
</div>
</div>
<div>
<label class="text-xs font-semibold text-zinc-700"
>ID do usuário (conta Business/Creator)</label
>
<div
class="mt-1 rounded-md border border-zinc-400 bg-white px-3 py-2 text-sm text-zinc-700"
>
17841400000000000
</div>
</div>
<div>
<label class="text-xs font-semibold text-zinc-700">Máximo de posts</label>
<div
class="mt-1 w-24 rounded-md border border-zinc-400 bg-white px-3 py-2 text-sm text-zinc-700"
>
3
</div>
</div>
</div>
<div class="mt-6 max-w-2xl rounded-lg border border-zinc-300 bg-white p-4">
<p class="text-sm font-bold text-zinc-800">Instagram — posts recentes</p>
<p class="mt-1 text-sm text-zinc-500">
Marque "Não exibir" para tirar um post do board (ex.: posts de grade). O board pula
para o próximo elegível.
</p>
<ul class="mt-3 divide-y divide-zinc-200">
<li class="flex items-center gap-3 py-2.5">
<span class="h-10 w-10 shrink-0 rounded bg-zinc-300"></span>
<span class="min-w-0 flex-1">
<span class="block truncate text-sm font-medium text-zinc-800">
Caravana pelo interior: chegada em Vitória da Conquista
</span>
<span class="block text-xs text-zinc-500"
>há 2 h · ID 17841400000000000_001</span
>
</span>
<span class="rounded bg-zinc-200 px-2 py-1 text-xs font-semibold text-zinc-700">
Exibir
</span>
</li>
<li class="flex items-center gap-3 py-2.5">
<span class="h-10 w-10 shrink-0 rounded bg-zinc-300"></span>
<span class="min-w-0 flex-1">
<span class="block truncate text-sm font-medium text-zinc-800">
Foto do plenário: votação da pauta da saúde
</span>
<span class="block text-xs text-zinc-500"
>ontem · ID 17841400000000000_002</span
>
</span>
<span class="rounded bg-zinc-200 px-2 py-1 text-xs font-semibold text-zinc-700">
Exibir
</span>
</li>
<li class="flex items-center gap-3 py-2.5">
<span class="h-10 w-10 shrink-0 rounded bg-zinc-300"></span>
<span class="min-w-0 flex-1">
<span class="block truncate text-sm font-medium text-zinc-800">
Post de grade (sem legenda) — para marcar "Não exibir"
</span>
<span class="block text-xs text-zinc-500"
>ontem · ID 17841400000000000_003</span
>
</span>
<span class="rounded bg-red-100 px-2 py-1 text-xs font-semibold text-red-700">
Não exibir
</span>
</li>
<li class="flex items-center gap-3 py-2.5">
<span class="h-10 w-10 shrink-0 rounded bg-zinc-300"></span>
<span class="min-w-0 flex-1">
<span class="block truncate text-sm font-medium text-zinc-800">
Agenda da semana: agendas abertas em Salvador
</span>
<span class="block text-xs text-zinc-500"
>há 2 dias · ID 17841400000000000_004</span
>
</span>
<span class="rounded bg-zinc-200 px-2 py-1 text-xs font-semibold text-zinc-700">
Exibir
</span>
</li>
</ul>
</div>
<div class="mt-6 flex items-center justify-end gap-2 border-t border-zinc-300 pt-4">
<button
class="rounded-md border border-zinc-400 bg-white px-4 py-1.5 text-sm font-semibold text-zinc-700"
>
Cancelar
</button>
<button class="rounded-md bg-red-600 px-4 py-1.5 text-sm font-semibold text-white">
Salvar
</button>
</div>
</div>
</div>
</section>
<!-- Cena: ainda não configurado (primeiro acesso) -->
<section data-shot="admin-nao-configurado" class="mt-12">
<p class="mb-3 text-sm font-medium text-zinc-300">
Cena 3 — sem token nem ID (primeiro acesso)
</p>
<div class="rounded-xl border border-zinc-700 bg-zinc-100 text-zinc-900">
<div class="flex items-center justify-between border-b border-zinc-300 px-6 py-4">
<div class="flex items-center gap-2">
<span class="rounded bg-zinc-900 px-2 py-0.5 text-xs font-semibold text-zinc-100">
Configurações
</span>
<span class="text-sm text-zinc-600">/ Feed de redes sociais</span>
</div>
<span class="rounded border border-zinc-400 bg-white px-3 py-1 text-xs text-zinc-600">
Draft · Salvar · Cancelar
</span>
</div>
<div class="px-6 py-5">
<h1 class="text-lg font-bold">Feed de redes sociais</h1>
<div class="mt-5 max-w-2xl rounded-lg border border-zinc-300 bg-white p-4">
<p class="text-sm font-bold text-zinc-800">Instagram ainda não configurado</p>
<p class="mt-1 text-sm text-zinc-500">
Informe o token de acesso e o ID da conta para publicar posts do Instagram na home.
Sem configuração, o board funciona com artigos e YouTube.
</p>
</div>
<div class="mt-6 grid max-w-2xl grid-cols-1 gap-4">
<div>
<label class="text-xs font-semibold text-zinc-700"
>Token de acesso (Instagram Graph API)</label
>
<div
class="mt-1 rounded-md border border-zinc-400 bg-white px-3 py-2 text-sm text-zinc-400"
>
(vazio)
</div>
</div>
<div>
<label class="text-xs font-semibold text-zinc-700"
>ID do usuário (conta Business/Creator)</label
>
<div
class="mt-1 rounded-md border border-zinc-400 bg-white px-3 py-2 text-sm text-zinc-400"
>
(vazio)
</div>
</div>
</div>
<div class="mt-6 flex items-center justify-end gap-2 border-t border-zinc-300 pt-4">
<button
class="rounded-md border border-zinc-400 bg-white px-4 py-1.5 text-sm font-semibold text-zinc-700"
>
Cancelar
</button>
<button class="rounded-md bg-red-600 px-4 py-1.5 text-sm font-semibold text-white">
Salvar
</button>
</div>
</div>
</div>
</section>
</main>
</body>
</html>

View file

@ -1,93 +0,0 @@
# Feed do Instagram configurado não aparece na home pública
Status: aprovado
Atualizado em: 2026-08-19
Issue: #115
Priority: P1
Model: cursor-grok-4.5-medium
Impeccable: C — encaixe na global do admin (painel de status de sincronização)
Rascunho UI: docs/plans/feed-instagram-nao-aparece-home-ui-draft.html + PNGs embutidos abaixo
Appetite: ~1 dia eng + diagnóstico da causa em produção
Responsável: —
## Intenção
A assessoria preencheu o token e o ID do Instagram na configuração do feed (global "Feed de redes sociais", entregue na S3) e os posts **não aparecem** na seção "Acompanhe de perto" da home pública — em produção, o board mostra YouTube e artigos, nenhum card do Instagram e nenhum link "Seguir no Instagram →" (verificado em 2026-08-19 em jorgesolla1313.com.br). O desenho da S3 é fail-closed **silencioso**: qualquer falha da API (token recusado, ID inválido, rede) degrada para "sem cards" sem deixar rastro — o único indício hoje é o picker do admin vazio ("Nenhum post recente"), que não diz **por quê**. A assessoria fica num ciclo cego de tentativa-e-erro de credenciais.
## Persona e fluxo
- **Persona / contexto:** assessoria de comunicação, configurando a global no admin do Payload (grupo "Configurações"), sem acesso ao código nem a logs.
- **Job principal:** saber, no mesmo lugar onde configurou o feed, se a sincronização do Instagram funcionou — e, se não, **por quê** e **o que fazer**.
- **Fluxo desejado:**
1. A assessoria preenche token + ID e salva a global.
2. O admin mostra o estado da sincronização: "Sincronizado · há X min · N posts" (e os posts aparecem no picker, prontos para exclusão), ou "Falha na última sincronização" com o motivo e a correção esperada (ex.: token inválido/expirado ou emitido via Facebook Login — gerar pelo Instagram Login).
3. Com sincronização OK, os posts aparecem na home pública em minutos (cache revalidado no salvar).
4. A causa da ocorrência atual fica diagnosticada e documentada num runbook curto para a assessoria não repetir o ciclo.
- **Anti-goals de produto:** nada de wizard OAuth "conectar com Facebook" dentro do admin; nada de dashboard de saúde de feeds; nada de embeds/scripts do Instagram no site público; nada de métricas de engajamento.
### Esboço de fluxo (B/C/D)
```text
[admin: global Feed de redes sociais]
→ salvar token+ID → [status: Sincronizado · N posts] → [home pública: cards IG em minutos]
→ [status: Falha + motivo + correção] → [assessoria corrige credencial e salva] → [status OK]
```
### Rascunho UI (B/C/D)
Painel de status de sincronização por plataforma na global do admin, embutido junto aos campos do Instagram (e reaproveitável para YouTube depois, se a paridade for pedida).
![Rascunho UI — admin, falha de token](feed-instagram-nao-aparece-home-ui-draft-admin-falha-token.png)
![Rascunho UI — admin, sincronizado com picker](feed-instagram-nao-aparece-home-ui-draft-admin-ok.png)
![Rascunho UI — admin, não configurado](feed-instagram-nao-aparece-home-ui-draft-admin-nao-configurado.png)
## Objetivo e aceite
- Com credenciais válidas (token gerado pelo Instagram Login + ID da conta Business/Creator), os posts do Instagram aparecem na seção "Acompanhe de perto" da home em até alguns minutos após salvar a configuração — badge "Instagram", legenda, data relativa, clique abre na plataforma. Sem ação manual além da config.
- Quando a Graph API recusa as credenciais ou a sincronização falha, o admin mostra o motivo em linguagem de produto e a correção esperada (ex.: "token inválido/expirado ou emitido via Facebook Login — gere pelo Instagram Login"). A assessoria deixa de ficar cega.
- A home pública continua fail-closed: API fora ou credenciais inválidas nunca quebram a página nem vazam erro — o board segue com artigos + YouTube (comportamento atual preservado).
- A causa da ocorrência em produção fica diagnosticada e registrada em runbook curto (passos para gerar/validar o token), para o ciclo de tentativa-e-erro não se repetir.
- Guardrails: token nunca exposto fora do admin (read da global admin-only, como hoje); zero embeds/scripts do Instagram no site público (LCP); identificadores em inglês, copy em pt.
## Dados (intenção)
- **Vou apresentar dados?** Não — o painel mostra estado operacional (última sincronização, nº de posts, motivo de erro), não métricas. Nenhuma decisão de análise desbloqueada.
- **Forma:** adiada ao plano de implementação — sem contadores, sem engajamento.
## Direção no codebase (hipótese)
- **Áreas prováveis:** `src/utilities/socialFeed/instagramFeed.ts` (onde o erro é engolido no fail-closed — fonte do "porquê"), `src/globals/SocialFeedSettings.ts` (superfície do status), `src/components/admin/InstagramPostExclusionPicker.tsx` (pickers/superfície admin já existente, mesma família de componente), `src/utilities/revalidateRequest.ts` (tag `social-feed` — o salvar já revalida; não espera-se tocar).
- **Precedente a olhar:** plano/impl da S3 (`docs/plans/secao-conteudos-home-instagram*.md`), irmão `youtubeFeed.ts`, e2e do describe serial da seção (stub IG) e smoke do picker no admin.
- **Risco de acoplamento:** o fetch roda dentro de `unstable_cache` durante render — qualquer persistência de status precisa respeitar a restrição de não chamar `revalidateTag` dentro de cache (precedente documentado da S2/S3). A home pública é ISR; revalidação via tag já cobre o salvar do admin.
## Dependências
- **S3** (pai, `done`/`in-prod` — imutável; este é item **sucessor**).
## Fora de escopo
- Setup OAuth / "conectar com Facebook" dentro do admin (o fluxo Meta fica com a assessoria; runbook documenta).
- Redesenho do board público ou dos cards (superfície aprovada na S3 não muda).
- Paridade de status para o YouTube (pode virar followup se a plataforma pedir; o desenho do painel é por plataforma).
- Backfill retroativo de snapshot.
## Rabbit holes de produto
- **Dashboard de saúde de feeds.** Se alguém "só completar" a ideia de status, vira painel de monitoramento com histórico e alertas. **Corte neste item:** um painel de status por plataforma na própria global, só estado atual + motivo; sem histórico, sem alertas.
- **Wizard OAuth no admin.** Completar o desejo com o fluxo completo da Meta explode o escopo (app Meta, permissões, redirects). **Corte neste item:** credenciais continuam preenchidas manualmente; o valor é diagnóstico e correção, não aquisição de token.
- **Investigação infinita da causa em produção.** A causa pode ser credencial da assessoria (troca fora do nosso controle). **Corte neste item:** diagnóstico com prazo — o que não for descoberto num período curto vira instrução de validação no runbook, não bloqueio do item.
## Questões em aberto (produto)
- **Onde a assessoria vê o "porquê"?** **Opções:** A) painel de status na própria global do feed (ao lado dos campos do Instagram) — o lugar onde a assessoria já está quando configura | B) só logs/exceção no servidor (exige acesso técnico — não resolve para a persona) | C) nada além do picker vazio (status quo). **Recomendação:** A — o fracasso acontece onde a configuração acontece; o sinal pertence a esse mesmo lugar. _(assumido — validar com produto)_
- **O item cobre a ocorrência atual em produção?** **Opções:** A) diagnóstico da causa atual + painel de status (a correção de credencial fica com a assessoria, documentada) | B) só painel de status; a causa atual fica para followup | C) só runbook de credenciais. **Recomendação:** A — sem o diagnóstico, não sabemos se há também um defeito de código por trás do sintoma; sem o painel, o sintoma se repete cegamente. _(assumido — validar com produto)_
- **Paridade de status para YouTube agora?** **Opções:** A) só Instagram nesta fatia (o YouTube funciona em produção; o painel nasce desenhado por plataforma) | B) YouTube e Instagram juntos. **Recomendação:** A — fatia mínima útil; paridade vira followup barato se pedida. _(assumido — validar com produto)_
## Referências
- Issue pai S3 (#20, `done`/`in-prod`) + `docs/plans/secao-conteudos-home-instagram.md` e `-impl.md`
- Rascunho UI (gate): `docs/plans/feed-instagram-nao-aparece-home-ui-draft.html` + PNGs acima
- Primeiras pistas para o executor: `src/utilities/socialFeed/instagramFeed.ts` (caminho de falha silenciosa), `src/globals/SocialFeedSettings.ts`, `src/components/admin/InstagramPostExclusionPicker.tsx`, `tests/e2e/frontend.e2e.spec.ts` (describe serial da seção)
- `docs/AGENT-OPS.md` (serializes: nenhum recurso compartilhado — `serializes: []`)

View file

@ -1,138 +0,0 @@
# Impl: OPS62-followup — flake do municipalitiesWithoutUpdateTool: fixture com relógio ancorado
Status: aprovado
Atualizado em: 2026-08-19
Issue: #81
Intenção: body da Issue é a spec (sem plano de intenção linkado)
Appetite restante: P3 — correção de teste, 1 linha de produção de fixture + verificação
## Leitura da intenção
- **Outcome:** eliminar o flake do teste `orders never-updated first (by name), then oldest
to newest (by name as tie-break)` (`municipalitiesWithoutUpdateTool.unit.spec.ts`), que
recebeu `[Beta, Alpha]` no lugar de `[Alpha, Beta]` em 2026-08-18 (gate local 19:22 e CI
run 779 23:53), tornando o spec deterministicamente estável sob carga.
- **O que NÃO negociar:** nenhum — o corpo da Issue não impõe lockdowns de produto; a
semântica de ordenação B189 (nunca atualizados por nome no topo, depois do mais velho ao
mais novo com tie-break por nome) é contrato do teste e do tool e **não muda**.
- **O que reavaliar:** a hipótese da Issue ("ordem recebida é a do fixture — sort não atuou;
suspeita de locale/ICU do ambiente"). **Refutada por reprodução mecânica** — a causa raiz
é do próprio fixture.
## Causa raiz (reproduzida)
`tests/unit/municipalitiesWithoutUpdateTool.unit.spec.ts:44`:
```ts
const daysAgo = (days: number): string => new Date(Date.now() - days * DAY_MS).toISOString()
```
O teste de ordenação constrói `Beta` e `Alpha` com **duas chamadas separadas** `daysAgo(40)`
(linhas 142 e 144). Se ≥1 ms decorrer entre as duas chamadas (GC pause, preempção do fork do
vitest sob suíte full em máquina compartilhada), o timestamp ISO de `Alpha` fica
**estritamente mais novo** que o de `Beta` → em `sortStale`
(`getMunicipalitiesWithoutUpdate.ts:198-205`), `leftAt !== rightAt` vence e o tie-break por
nome nunca dispara → `['Mike','Zulu','Beta','Alpha','Gama']` em vez de
`['Mike','Zulu','Alpha','Beta','Gama']`. É exatamente o diff reportado (só o par trocado,
resto correto).
Medições (`node /tmp/opencode/ops62-followup-repro.mjs`, sort de produção copiado verbatim):
- Fixture atual, sem pausa, 200 000 runs: **0.082% de falhas** (fronteira de ms cruzada).
- Fixture atual com pausa de 1 ms injetada entre as duas chamadas, 2000 runs: **100% de
falhas**, amostra `["Mike","Zulu","Beta","Alpha","Gama"]`.
- Fixture ancorado com a mesma pausa, 2000 runs: **0 falhas**.
20 runs isolados passarem é consistente: em máquina ociosa a dupla chamada quase sempre
cai no mesmo milissegundo; sob carga, preempções ≥1 ms são rotina. Locale/ICU não é o
fator decisivo — `localeCompare('pt-BR')` é determinístico; quem decide o par errado são
timestamps acidentalmente desiguais.
## Abordagem recomendada
**Opções consideradas:** A | B | C
- **A — Ancorar o relógio do fixture uma vez no load do módulo:** `const fixtureNowMs =
Date.now()` no topo do spec e `daysAgo = (days) => new Date(fixtureNowMs - days *
DAY_MS).toISOString()`. As duas `daysAgo(40)` produzem timestamps **exatamente iguais** →
o tie-break por nome é o que o teste realmente exercita, deterministicamente, qualquer
que seja a carga.
- **B — Data literal fixa** (padrão `suggestionCatalog.unit.spec.ts:364`, ex.
`new Date('2026-08-18T12:00:00.000Z')`).
- **C — Reescrever o teste para não depender de timestamps iguais** (ex.: assert por ids,
ou remover o par empatado).
**Recomendação: A** — porque é a mudança mínima (1 linha + âncora), elimina **toda**
dependência de relógio do spec, e segue o precedente local exato
(`tests/unit/prioritiesTool.unit.spec.ts:12` tem `const nowMs = Date.now()` no escopo do
módulo). A âncora no load garante que os timestamps empatados sejam **exatamente** iguais
(ms a ms), o que nenhuma outra opção faz com tanta simplicidade.
**Rejeitadas:**
- **B** porque introduz uma data mágica no fixture sem necessidade; o âncora de load faz o
mesmo com menos atrito e alinha com o precedente do `prioritiesTool`. (O `suggestionCatalog`
precisa da data fixa porque injeta `now` na função sob teste — aqui a função sob teste usa
`new Date()` interno, então a âncora de load é suficiente.)
- **C** porque o teste existe para cobrir o tie-break por nome do contrato B189; reescrevê-lo
sem o par empatado **remove cobertura** em vez de estabilizá-la.
- **Mexer no código de produção** (`getMunicipalitiesWithoutUpdate.ts`) porque não há bug de
produção: os timestamps reais vêm do DB (valores fixos) e o sort é determinístico. O bug é
exclusivamente do fixture do teste.
### Componentes / mudanças
- **`tests/unit/municipalitiesWithoutUpdateTool.unit.spec.ts`**: âncora `const fixtureNowMs =
Date.now()` no escopo do módulo + `daysAgo` derivando dela; comentário curto explicando o
porquê (a classe de flake é sutil — sem o comentário, um editor pode re-inliner o
`Date.now()` e reintroduzir o flake). No teste de ordenação, o timestamp empatado é
**hoisted** (`const stale40 = daysAgo(40)` usado em `Beta` e `Alpha`): a igualdade vira
estrutural, não probabilística — mesmo uma regressão da âncora não reativa o flake nesse
par.
- **Migration:** sem migration.
- **Access / Consent:** não se aplica (arquivo de teste).
- **UI:** não se aplica.
### Scan da mesma classe (feito, não escopo)
- `prioritiesTool.unit.spec.ts:12` — já ancorado no load do módulo (seguro).
- `suggestionCatalog.unit.spec.ts:364` — `now` fixo (seguro).
- `campaignComponents.unit.spec.ts:545` — chamada única, sem assunção de igualdade (seguro).
- Nenhum outro spec combina `Date.now()` por chamada + assunção de igualdade entre duas
chamadas.
## Fases verificáveis
1. **Fixture** — âncora de relógio no spec (1 linha + comentário). Quota: mínima.
2. **Reprodução & estabilidade** — rodar o spec 30×: `for i in $(seq 1 30); pnpm test:unit
-- --reporter=dot municipalitiesWithoutUpdateTool` (todas verdes). Rodar o script
`/tmp/opencode/ops62-followup-repro.mjs` (não commita) para o registro do mecanismo.
3. **Gates** — `pnpm test:unit` full, `pnpm test:int` (int), `pnpm exec tsc --noEmit`,
`pnpm lint`, `pnpm format:check`, `pnpm exec knip`, `pnpm check:cycles`.
4. **Changelog da entrega (OPS44)** — `docs/changelog/2026-08-19-ops62-followup.md` +
`pnpm changelog:build` + `pnpm changelog:check` (guards do CI exigem).
## Rabbit holes / Não escopo (engenharia)
- Investigação de locale/ICU do container — refutada pela reprodução; não seguir.
- Mudança no sort de produção — não há bug lá.
- Instrumentar o CI para capturar a 3ª ocorrência — o mecanismo está provado; a correção
remove a classe inteira, e a 3ª ocorrência deixa de ser possível por este caminho.
- Ancorar/limpar os outros specs da mesma classe — já verificados seguros.
## Riscos e mitigação
- **A âncora muda a semântica de idade relativa** (timestamps ficam relativos ao load do
módulo, não à execução): os asserts de limiar usam dias distintos (40/31/30/5 e 20/16/15/14)
e a execução ocorre sempre **depois** do load → idades só crescem; nenhum assert depende do
valor exato de `diasSemAtualizacao` (o teste do limiar asserta ids, não idades). Mitigação:
rodar a suíte full para confirmar.
- **Alguém reverter a âncora no futuro**: mitigado pelo comentário no spec.
## Aceite de engenharia
- [x] Aceite de produto da intenção ainda coberto (teste verde determinístico, contrato B189 intacto)
- [x] Invariantes AGENTS/engineering-standards (test-only; sem migration/access/URL)
- [x] Testes de domínio previstos (spec existente — a própria correção é dele)
- [x] Self-score decision-quality ≥4 (opções com rejeitadas; rabbit holes nomeados; depth
check: zero código novo, reusa padrão local `prioritiesTool`; appetite P3 respeitado)

View file

@ -1,103 +0,0 @@
# Impl: OPS64 — forgejo-pr-automerge: waitForChecks conta o próprio status pendente (deadlock → timeout, merge nunca acontece) + rollup `checks` sem agregação de shards
Status: em execução (entregue — ver desfecho no final)
Atualizado em: 2026-08-18
Issue: #62
Intenção: body da Issue (sem plano de intenção separado — body é a spec)
Appetite restante: herdado (~1 dia de ops)
## Leitura da intenção
- **Outcome:** (primário) o safety net `ready-automerge` deixa de se auto-bloquear no próprio status `pending` e mergea PRs com CI verde; (secundário) o rollup `checks` do `ci-pr.yml` (e o do `ci.yml`, que gateia o deploy) deixa de reportar SUCCESS com um shard de e2e vermelho — o gate do servidor (branch protection) e o UI param de mentir.
- **O que NÃO negociar:** merge só com CI verde de verdade (a classe OPS52 — merge manual/automático com shard vermelho — não pode voltar); `waitForChecks` continua ignorando o próprio contexto; nada de fork rodando código com o PAT do repo (guarda do OPS61); sem edição de Issues `in-progress` de terceiros; nada de prod DB.
- **O que reavaliar:** a intenção lista três correções possíveis para o primário (filtrar próprio context / `merge_when_checks_succeed` nativo / timeout > 40 min) — **a evidência mostra que o primário já foi entregue pelo OPS61** (issue criada antes do OPS61 subir); o trabalho real do OPS64 é o **secundário** (agregação de shards no rollup), que está vivo e medido.
## Achados da exploração (evidência ao vivo 2026-08-18)
1. **Primário (deadlock do próprio pending) — JÁ ENTREGUE pelo OPS61** (`8b989306`, em `main`): `waitForChecks` filtra contextos `PR Ready + auto-merge` (`verdicts`), gateia no rollup `CI (PR) / checks` presente + success, e o merge POST usa `FORGEJO_API_TOKEN` (409 do pre-receive). **Provado vivo:** runs 710 (PR #67) e 712 (PR #70) do `ready-automerge` terminaram `success` e os PRs foram mergeados no momento do fim do run (21:32:24/21:35:24 vs fim 21:32:27/21:35:31) — o CLI mergeou. O timeout já subiu para 45 min (CLI) / 55 min (workflow). A opção "timeout > 40 min" da intenção também já está entregue.
2. **Secundário (rollup `checks` mente) — VIVO e medido em 5 runs:** o resultado do job-pai de matrix no contexto `needs` do Forgejo 9.0.3 (gitea-1.22.0) é o resultado do **último shard a reportar**, não um AND sobre os shards:
- runs 604/607/610 (PR #55/#56/#57, ci-pr pré-OPS61 — mesmo jq de hoje): e2e (1)=failure, e2e (2)=success → `checks`=SUCCESS (shard vermelho mascarado);
- run 716 (PR #71, pós-OPS61): e2e (1)=success, e2e (2)=failure → `checks`=failure (o vermelho era o último — pegou por acaso);
- run 638: e2e (1)=failure, e2e (2)=success → `checks`=failure **só porque int e build também falharam** (o e2e vermelho continuou mascarado).
Ou seja: o gate `CI (PR) / checks*` do servidor e o rollup jq (`all(.value.result == "success" or "skipped")`) passam com um shard vermelho quando o último shard é verde.
3. **Por que o CLI ainda é seguro hoje:** `waitForChecks` varre **todos** os statuses individuais (`failed` inclui `CI (PR) / e2e (1)` = failure → throw). Foi por isso que no run 607 "só o waitForChecks pegou o vermelho". O buraco é o **gate do servidor** (branch protection satisfeita por rollup verde) + o UI — e o `deploy` do `ci.yml` (mesmo jq) rodaria com shard vermelho em main.
4. **Não existe `needs.<matrix>.results` no runner do Forgejo 9.0.3:** o `Needs` do act (gitea/act `pkg/exprparser/interpreter.go`) tem só `Outputs` e `Result` — o contrato `.results` do GitHub não existe aqui. A correção do rollup **não pode** vir do contexto `needs`.
5. **`merge_when_checks_succeed` existe neste servidor** (swagger + fonte gitea 1.22: `ScheduleAutoMerge` → `IsPullCommitStatusPass`), mas o OPS50/61 escolheu poll+merge e ele **funciona** (achado 1). Além disso o auto-merge nativo gateia nos contextos exigidos da branch protection — o mesmo rollup mentiroso — então **não** corrigiria o secundário; e o `mergeable` do servidor também usa o mesmo `MergeRequiredContextsCommitStatus`. Decisão: manter poll+merge; corrigir o rollup.
6. **Shards já postam status individuais:** cada shard do e2e posta `CI (PR) / e2e (N) (pull_request)` (e `CI / e2e (N) (push)` no main) — o dado confiável é o status do commit, não o parent do `needs`. É a mesma fonte que o `waitForChecks` usa (`listCommitStatuses`).
7. **Risco RCE ao colocar PAT no ci-pr.yml:** o `ci-pr.yml` roda em PRs de fork (repo público, sem guard no workflow). Injetar `FORGEJO_API_TOKEN` no job `checks` **sem** o guard same-repo seria código do fork rodando com o PAT (a mesma classe que o OPS61 guardou nos pós-merge). Qualquer uso de PAT no `ci-pr.yml` exige `if: head.repo.full_name == github.repository` no job.
## Abordagem recomendada
```mermaid
flowchart LR
A[rollup checks: jq sobre needs parent de matrix MENTE] --> B[novo script ci-rollup-check.mjs lê statuses do sha via API]
C[waitForChecks já varre statuses individuais - seguro] --> D[nada a mudar no CLI]
B --> E[ci-pr: fork guard + FORGEJO_API_TOKEN + script]
B --> F[ci.yml: FORGEJO_API_TOKEN + script - gate do deploy honesto]
G[testes unit: veredito do script + waitForChecks com rollup verde e shard vermelho] --> H[PR Closes #62]
```
**Opções consideradas:**
- **A — rollup lê os statuses do commit via API (`listCommitStatuses`), com o mesmo prefixo do workflow (`CI (PR) / `, `CI / `) e excluindo o próprio contexto (`…/ checks`):** veredito honesto por construção, mesma fonte do `waitForChecks`, funciona para o `ci-pr.yml` e o `ci.yml` (gate do deploy).
- B — `needs.<matrix>.results` (contrato GitHub): **inexistente** no act do Forgejo 9.0.3 (fonte, achado 4).
- C — `merge_when_checks_succeed` nativo (opção da intenção): existe no servidor, mas gateia nos contextos exigidos (rollup mentiroso) — não corrige o secundário; poll+merge atual já funciona (achado 1).
- D — quebrar a matrix em jobs explícitos (e2e-1/e2e-2): mexe no contrato OPS34 (`e2eShardConfig` pinado por unit test `shard: [1, 2]`), refatora o classificador de escopo; não é o dono do problema (o dono é o veredito do rollup).
- E — timeout > 40 min (opção da intenção): já entregue (45/55); não corrige a mentira do rollup.
- F — deixar como está (rollup pode mentir, "o CLI pega"): deixa o gate do servidor e o `deploy` vulneráveis à classe OPS52.
**Recomendação:** A — porque é o único caminho que (a) usa a mesma fonte que o CLI já confia (statuses do commit — não o parent de matrix), (b) corrige os dois rollups (PR e main/deploy), (c) não mexe no contrato OPS34, (d) é testável como função pura. O PAT no `ci-pr.yml` entra **com o fork guard** no job `checks` (achado 7).
**Rejeitadas:** B (não existe no runner — falha silenciosa ou dependência de upgrade); C (não corrige o secundário — o auto-merge nativo confiaria no rollup mentiroso; e o poll+merge provado continua); D (refatora contrato OPS34 sem necessidade); E (já entregue, não é fix); F (classe OPS52 no gate do servidor + deploy vermelho em main).
### Componentes / mudanças
- **`scripts/lib/ci-rollup-check.mjs`** (novo, puro, zero-dep): `rollupVerdict(statuses, { prefix, ownPrefix })` → `{ ok, failed: string[], pending: string[] }` — falha (exit 1) se qualquer contexto com o prefixo do workflow (excluindo o próprio rollup) for `failure`/`error`; falha também se houver `pending` (cascata não assentada — fail-closed); falha se a lista vier vazia (API indisponível/estranho — nunca "verde por falta de dado"); success apenas quando todos os demais contextos do workflow estiverem `success` (skipped posta como success, observado). Reusa `listCommitStatuses` do `forgejo-api.mjs`.
- **`scripts/ci-rollup-check.mjs`** (novo, CLI plain Node, padrão `forgejo-pr-automerge.mjs`): `--sha <head-sha> --prefix "CI (PR) / " --own-prefix "CI (PR) / checks"` (no `ci.yml`: `"CI / "` / `"CI / checks"`); imprime os contextos que falharam e sai 1; verde → 0.
- **`.forgejo/workflows/ci-pr.yml`** (job `checks`): `if:` ganha o fork guard (`github.event.pull_request.head.repo.full_name == github.repository`, pré-checkout — mesmo padrão do `ready-automerge`); `env: FORGEJO_API_TOKEN`; steps ganham `actions/checkout@v5` + `actions/setup-node@v5` (node 24, `package-manager-cache: false`) + `node scripts/ci-rollup-check.mjs --sha "${{ github.event.pull_request.head.sha }}" --prefix "CI (PR) / " --own-prefix "CI (PR) / checks"`; o passo jq do `needs` sai (o script o substitui — fonte única). `needs` e `if: always()` permanecem (o job só roda depois da cascata toda — a ordenação que garante "assentou").
- **`.forgejo/workflows/ci.yml`** (job `checks`): idem, sem fork guard (evento push/dispatch não tem fork); `--sha "${{ github.sha }}"`, prefixo `CI / `. Com isso o `deploy` (que `needs: [checks]`) nunca roda com shard de e2e vermelho em main.
- **`tests/unit/forgejoApi.unit.spec.ts`**: novo caso pinando o cenário run-607 — rollup `CI (PR) / checks` = success **com** `CI (PR) / e2e (1)` = failure → `waitForChecks` lança "Checks falharam" (o CLI nunca confia no rollup sozinho).
- **`tests/unit/ciRollupCheck.unit.spec.ts`** (novo): veredito puro — (a) shard vermelho + rollup próprio pending → fail (o run 607 do passado, agora no rollup); (b) shard (2) vermelho → fail (run 716); (c) tudo success → ok; (d) contexto do safety net (`PR Ready + auto-merge`) pending → ignorado (não casa o prefixo); (e) próprio contexto pending → ignorado; (f) lista vazia → fail; (g) variante `CI / ` do main; (h) `skipped` postado como success → ok.
- **Docs:** `.agents/rules/agent-pr-workflow.mdc` (seção "Required checks": rollup agora verifica os statuses individuais via API — o parent de matrix do Forgejo não agrega; nota do fork guard: PRs de fork ficam sem o status `checks` → merges de fork bloqueados pelo servidor, hardening intencional), `docs/AGENT-OPS.md` (linha da branch protection + tabela de workflows), header dos dois workflows, `docs/changelog/2026-08-18-ops64.md` + `pnpm changelog:build`.
- **Migration:** sem migration (não toca schema/DB).
- **Access / Consent:** N/A.
- **UI:** N/A (A).
## Fases verificáveis
1. **Script + veredito puro** — `scripts/lib/ci-rollup-check.mjs` + CLI + unit tests; `pnpm test:unit` local.
2. **Workflows** — `ci-pr.yml` e `ci.yml` com o novo passo; diff completo no PR da entrega.
3. **Gates** — `pnpm gate:fast` local (lint/format/typecheck/knip/cycles/unit); e2e roda no CI (OPS59). Push via `pnpm push`; PR `Closes #62`. **Verificação ao vivo na própria PR:** o run do `ready-automerge` da PR prova o primário de novo (já provado no OPS61); o novo rollup proof na PR — se um shard falhar, `checks` fica vermelho (e o servidor bloqueia), se tudo verde, mergea.
## Rabbit holes / Não escopo (engenharia)
- Não trocar poll+merge por `merge_when_checks_succeed` nativo (achado 5 — não corrige o secundário; poll+merge provado; trocar é item futuro se algum dia o upgrade do Forgejo mudar o contrato).
- Não consertar o parent de matrix do Forgejo (bug upstream do runner; a correção é viver sem ele).
- Não refatorar a matrix e2e (OPS34) nem o classificador de escopo.
- Não adicionar o fork guard aos demais jobs do `ci-pr.yml` (não têm segredos; fora do escopo).
- Não mexer no `waitForChecks` além do teste novo (o CLI já está correto — achado 3).
## Riscos e mitigação
- **PRs de fork perdem o status `checks`** (fork guard no job): merges de fork ficam bloqueados pelo servidor (contexto exigido nunca posta). Hardening intencional e alinhado à política atual ("a merged fork PR would deploy" — o `ready-automerge` já exclui forks); documentado no changelog. Se um dia um merge de fork for legítimo, é o escape documentado do OPS61 (ajustar a regra temporariamente).
- **API indisponível no job `checks` → rollup vermelho:** fail-closed (nunca verde por falta de dado); o job vermelha e o PR fica bloqueado até a infra voltar — visível, não silencioso. O mesmo runner self-hosted que roda os jobs serve a API (mesma rede), e o `ready-automerge` já depende dela.
- **Race status×rollup (status do último shard postado depois do `checks` começar):** o job `checks` só roda após `needs` da cascata toda (a ordenação do workflow garante "assentou"), e o runner posta o status no fim de cada job — o window é o mesmo que o jq atual já tinha; o `waitForChecks` (poll) continua sendo o backstop do merge.
- **O script roda o código do head no job `checks` (same-repo):** mesmo nível de confiança do `ready-automerge` (que também roda script do head com o PAT) — same-repo tem write de qualquer forma.
## Desfecho (gate com o humano — OPS62 mergeou em main durante a execução)
O OPS62 (mergeado às 23:32Z enquanto este item rodava) reescreveu ci-pr.yml e
ci.yml como **jobs únicos sequenciais** — sem matrix, sem shards. O bug de
agregação do parent de matrix que este item atacava **deixou de existir
estruturalmente**: o status do job `checks` agora É o veredito honesto da
cascata. Decisão do gate: **descartar** os edits de workflow e o script
`ci-rollup-check.mjs` (redundantes pós-OPS62), **manter** o teste-pino do
`waitForChecks` (run 607: rollup verde + shard vermelho → CLI lança) e as docs
honestas. Entregue: pin + docs + changelog.
## Aceite de engenharia
- [x] Aceite de produto da intenção ainda coberto (primário já entregue pelo OPS61; secundário eliminado estruturalmente pelo OPS62; o CLI segue com a defesa individual de statuses, pinada por teste)
- [x] Invariantes AGENTS/engineering-standards (sem código morto — script descartado; docs honestas — a mentira do parent de matrix documentada)
- [x] Testes de domínio previstos (waitForChecks run-607 pinado em `forgejoApi.unit.spec.ts`)
- [x] 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)

View file

@ -1,71 +0,0 @@
# Impl: OPS67 — Forgejo: retry com backoff nos fetchs do forgejo-api (fetch failed transitório mata o run do safety net)
Status: aprovado
Atualizado em: 2026-08-19
Issue: #73
Intenção: body sem plano linkado — o título + frontmatter são a spec. Sem plano de intenção separado.
Appetite restante: herdado do tamanho da Issue (P3, mudança pontual de infra/scripts).
## Leitura da intenção
- **Outcome:** os fetchs do cliente Forgejo API (`scripts/lib/forgejo-api.mjs`) retentam com backoff as falhas transitórias — um `fetch failed` de rede não mata mais o run do safety net (`agent-pr-ready-automerge.yml`) nem nenhum outro consumidor do cliente.
- **O que NÃO negociar:** nenhum contrato de produto aqui; os invariantes são os testes existentes do cliente (403 → throw com corpo da API, shape normalizado gh-flavored, fail-closed sem token). O retry **não pode mudar o contrato de erro visível dos callers**: falha permanente continua `exit 1` com a mesma mensagem (ex.: flip da `forgejo-issue-transition.mjs` continua vermelhando — OPS61).
- **O que reavaliar:** o escopo do "transitório". O título cobre o fetch reject de rede (`fetch failed`); a decisão de engenharia é o que mais retentar (5xx? 429?) e com que política.
## Decisões de engenharia
### D1 — Onde vive o retry
**Opções:** A) no `request()` de `scripts/lib/forgejo-api.mjs` (choke point único — 19 call sites: safety net, claim/ready/status, issue-transition, branch-protection, pool, worktree); B) wrapper só no `forgejo-pr-automerge.mjs`; C) helper genérico `withRetry` novo + aplicar nos call sites.
**Recomendação: A** — o cliente é o deep module certo do transporte Forgejo (zero-dep, plain Node, roda no Actions sem pnpm); um único ponto dá resiliência a todos os runs de uma vez. **Rejeitadas:** C porque criaria pass-through raso e tocaria 19 call sites (DRY de conhecimento violado — "edit the owner, don't twin"); B porque deixa claim/status/pool vulneráveis ao mesmo flake e o safety net voltaria a morrer por outro call site.
### D2 — O que retentar (classe de falha)
**Opções:** A) só falha de rede (fetch reject: `fetch failed`, ECONNRESET/ETIMEDOUT/DNS) em todos os métodos; B) A + 5xx (502/503/504) **apenas em GET**; C) A + 5xx em qualquer método (padrão Octokit).
**Recomendação: B** — o sintoma do título é exatamente o fetch reject; o 5xx-em-GET custa 3 linhas e protege o caminho dominante do safety net: o poll do `waitForChecks` (GET PR + GET statuses a cada 15 s por até 45 min) — um 502 do Cloudflare no meio da espera é a mesma classe de morte de run. **Rejeitadas:** C porque retentar POST 5xx pode duplicar efeitos colaterais (createIssue duplicado no `agent-register`, comentário duplicado, merge re-enviado em corrida — o `autoMerge` já tem guard de re-read, mas o resto não) sem necessidade observada. 429 não entra (não observado; gatilho de revisitação). AbortError não existe no cliente hoje (sem AbortSignal) — se um dia entrar, não retentar.
### D3 — Política de backoff
**Opções:** A) exponential: base 300 ms, fator 2, jitter ±20%, `retries: 3` (4 tentativas, pior caso ~2,1 s); B) delay fixo 1 s × 3; C) retry imediato.
**Recomendação: A** — padrão de livro; o delay total é irrelevante frente ao budget de 55 min do workflow. **Rejeitadas:** B e C (sem espalhamento; rajada curta de blips derrubaria tentativas consecutivas).
Configuração: opções flat no `createApi` (`retries`, `backoffMs`, `jitter`, `sleepImpl`) espelhando o `fetchImpl` injetável que já existe — defaults prod preservam o comportamento sem nenhum caller mudar; `sleepImpl` + `jitter: false` + `backoffMs` pequeno dão determinismo aos testes (zero espera real).
### D4 — Visibilidade
`console.warn` pt-BR no retry, estilo das mensagens do lib (`Sem token…`): `[forgejo-api] GET <path> falhou (tentativa 1/4): <motivo> — retry em <ms>ms`. O log do run do safety net fica diagnóstico; sem plumbing novo de logger.
## Componentes / mudanças
- **`request()`** (`scripts/lib/forgejo-api.mjs`): loop de tentativas em volta do `fetch`; checagem de `response.status` **antes** de `response.text()` (necessário para decidir 5xx-GET sem consumir o body); `sleepImpl` default `setTimeout`. Sem migration, access, UI, DB.
- **`tests/unit/forgejoApi.unit.spec.ts`**: 12 specs novas (após /simplify) — rede GET/POST→sucesso, body-drop em GET retenta + em escrita fail-closed, rede persistente→throw após `retries+1` calls, `retries: 0`, 4xx não retenta, 5xx GET retenta/exaure, 5xx POST não retenta, backoff cresce (`sleepImpl` spy: [base, base×2] com jitter off), jitter ±20% com `Math.random` pinado. Spec 403 existente ganha pin de 1 fetch por invocação.
- **`docs/changelog/2026-08-19-ops67.md`** + `pnpm changelog:build`.
- **Docs:** conferir `docs/AGENT-OPS.md` / `.agents/rules/agent-pr-workflow.mdc` por menção ao cliente sem retry (toque só se houver drift real).
## Fases verificáveis
1. **Cliente + specs** — `forgejo-api.mjs` + specs unit novas; `pnpm test:unit` (arquivo), lint, format, typecheck, knip, cycles.
2. **Changelog + docs** — entrada `ops67`, build do agregado, retoque de doc se houver drift.
3. **PR → CI** — `scripts/` conta como produção no `.dockerignore`, então o CI do PR roda a suíte completa; merge → deploy na janela.
## Rabbit holes / Não escopo
- Retry em outros fetchs (`wpArticles`, `seed-posts`, `recover-media`, `check-push-chain`): domínios diferentes, políticas próprias — não tocar.
- Retry 429 / retry de POST 5xx: não observados — revisitar só se aparecerem.
- AbortSignal no cliente: não existe hoje; não introduzir.
- Timeout 30 min do `waitForChecks` vs 55 min do job (débito anotado no OPS62): não escopo.
## Riscos e mitigação
- Retry adiciona ~2 s no pior caso antes do erro real: irrelevante; o erro final é o mesmo (exit 1).
- Duplicação de POST quando a conexão cai no meio (server aplicou, resposta não voltou): residual padrão de toda SDK HTTP (Octokit idem); os endpoints de escrita do cliente são idempotentes na prática (labels/comentário) ou têm guard de re-read (`autoMerge`); `createIssue` é o único sensitivo e o risco é residual — aceito.
- Testes lentos: impossível — `sleepImpl` injetado e backoff configurável.
## Aceite de engenharia
- [x] Aceite do título mantido: fetch transitório não mata mais run de nenhum consumidor do cliente
- [x] Invariantes: zero-dep mantido (stdlib), contrato de erro dos callers preservado, mensagens pt-BR
- [x] Testes unit cobrindo as 4 classes (rede / 4xx / 5xx-GET / 5xx-POST) + crescimento do backoff

View file

@ -1,73 +0,0 @@
# Impl: Admin do Payload abre em branco em produção (importMap sem o handler do storage S3)
Status: aprovado
Atualizado em: 2026-08-19
Issue: #77
Intenção: docs/plans/ops69-admin-branco-importmap-s3.md
Appetite restante: herdado (~0,25–0,5 dia eng)
## Leitura da intenção
- **Outcome:** o `/admin` renderiza o formulário de login em produção; o importMap commitado volta a ser consistente com o config que as envs de produção ativam; dev/test sem `S3_*` seguem com storage local.
- **O que NÃO negociar:** não mexer no contrato `/api/media/file/...`, não mudar o storage (Garage S3), não mudar o comportamento do plugin em dev/test, não tocar em nada além do fix mínimo do P0.
- **O que reavaliar:** a hipótese "regenerar com envs S3 + diff só de storage" — verificado em evidência da intenção (reprodução local); a mecânica do `payload generate:importmap` foi confirmada no código (gera sem tocar DB; plugin só registra o handler quando ativo; env inline no processo vence os env-files via `loadEnvConfig` do `@next/env`).
## Abordagem recomendada
```mermaid
flowchart LR
A[generate:importmap com S3_* dummy inline] --> B[Diff: só +2 linhas do S3ClientUploadHandler]
B --> C[Gates locais: tsc/lint/format/knip/cycles/unit+int/build]
C --> D[Smoke: dev local com S3_* dummy → /admin renderiza]
D --> E[changelog + linha AGENTS.md + item sucessor do guard]
E --> F[PR em main → CI verde → deploy automático]
```
**Opções consideradas:** A | B | C | D
**Recomendação:** **A — regenerar o importMap com as 4 envs `S3_*` (valores dummy inline) e commitar o diff**, porque é o fix mínimo que elimina exatamente a divergência dev/prod: o `generate:importmap` walka o config via `iterateConfig` (sem DB), o plugin `s3Storage` registra o `ClientUploadHandler` da collection `media` só quando `mediaStorage.enabled`, e o CLI carrega env-files com `@next/env` **sem sobrescrever** envs já no processo — valores dummy inline garantem o enable sem depender de `.env.local`/secrets.
**Alternativas rejeitadas:**
- **B — editar `importMap.js` à mão:** arquivo gerado; o próximo `pnpm generate:importmap` apaga a edição e a divergência volta sem aviso; nenhuma verificação de consistência.
- **C — tornar o `s3Storage` incondicional (sempre ativo, inclusive sem envs):** quebra dev/test (anti-goal da intenção — storage local sem `S3_*`), exigiria Garage local em todo dev e muda o aceite OPS52.
- **D — guard de CI embutido neste item (rodar `generate:importmap` com envs dummy no gate e falhar se o arquivo commitado divergir):** é a resposta certa para a classe de bug, mas a intenção cortou explicitamente melhorias de build/deploy do fix mínimo (questão em aberto → "C como item sucessor"). Registra-se como Issue sucessora no fechamento (`depends` em #77).
### Componentes / mudanças
- **`src/app/(payload)/admin/importMap.js`** (gerado, commitado): ganha 2 linhas — import + entrada `@payloadcms/storage-s3/client#S3ClientUploadHandler` (export confirmado em `node_modules/@payloadcms/storage-s3/dist/exports/client.js`). Nenhuma outra entrada muda.
- **`package.json` / scripts:** nada muda; usa-se o `pnpm generate:importmap` existente com envs inline.
- **`AGENTS.md`:** uma linha na checklist de verificação local — `generate:importmap` deve rodar com as envs `S3_*` setadas (dummy ok) enquanto o plugin de storage for condicional; sem elas o importMap fica órfão do handler (classe OPS69).
- **Migration:** sem migration. **Access / Consent:** não se aplica. **UI:** Impeccable A — sem UI nova.
### Guardas de consistência da regeneração
Antes de commitar: `git diff` do `importMap.js` deve conter **exclusivamente** as 2 linhas do storage S3. Se aparecer qualquer outra entrada (drift de componentes admin desde a última regen, `a638a8ac`), **parar e investigar**: se o drift é real (componente admin novo sem regen posterior), incluí-lo é consistência correta, mas exige conferir o commit que o introduziu; nunca commitar um diff não compreendido.
## Fases verificáveis
1. **Regeneração** — `S3_BUCKET=teqo-media S3_ENDPOINT=http://localhost:3900 S3_ACCESS_KEY_ID=test S3_SECRET_ACCESS_KEY=test pnpm generate:importmap`; diff restrito às 2 linhas do S3. (Quota: minutos)
2. **Verificação de consistência + smoke** — `git grep` do handler no importMap; `pnpm dev` local com as mesmas envs dummy: `/admin/login` renderiza o formulário (o repro da intenção documenta que com S3 ativas + importMap órfão o admin fica branco; com o handler presente, renderiza). O e2e `admin.e2e.spec.ts` roda sem `S3_*` (CI) e continua passando — a entrada extra fica inerte.
3. **Gates** — `tsc --noEmit`, `pnpm lint`, `pnpm format:check`, `pnpm exec knip`, `pnpm check:cycles`, `pnpm test`, `pnpm build` (DB local). E2e: CI (PR).
4. **Fechamento** — `docs/changelog/2026-08-19-ops69.md` + `pnpm changelog:build`; linha no AGENTS.md; registrar Issue sucessora do guard via capture-review-debts (`depends` #77).
## Rabbit holes / Não escopo (engenharia)
- **Regenerar e ver drift** — acima: diff não compreendido = parada obrigatória.
- **"Aproveita e roda o guard no CI já"** — item sucessor, não este P0.
- **Perseguir o erro de beacon do Cloudflare** — ruído de console, fora de escopo (já cortado na intenção).
- **Testar upload real contra Garage local no dev** — o smoke só precisa que a view renderize; o fluxo de upload S3 já é coberto pelo OPS52 em produção.
## Riscos e mitigação
- **Regeneração traz drift além do S3** (componente admin adicionado desde `a638a8ac` sem regen): parar, conferir o commit que introduziu o componente, incluir só se for consistência legítima.
- **Alguém regenera sem envs no futuro e reintroduz o bug:** mitigado pela linha do AGENTS.md + Issue sucessora do guard de CI (que falha o gate na divergência).
- **Deploy em produção com o importMap novo:** verificação pós-deploy pelo runbook `docs/ops/teqo-1313-deploy.md` — `/admin` abre o login e o log do container não re-emite `getFromImportMap: PayloadComponent not found in importMap` para `@payloadcms/storage-s3/client#S3ClientUploadHandler`.
## Aceite de engenharia
- [ ] Aceite de produto da intenção ainda coberto (admin renderiza em prod; upload S3 intacto; dev/test sem `S3_*` intactos)
- [ ] Invariantes AGENTS/engineering-standards (nenhum schema/access/UI tocado; importMap é arquivo gerado e commitado)
- [ ] Diff do importMap restrito às 2 linhas do handler S3, verificado e compreendido
- [ ] Gates verdes (tsc/lint/format/knip/cycles/unit+int/build; e2e no CI)
- [ ] Changelog + AGENTS.md + Issue sucessora do guard registrada

View file

@ -1,130 +0,0 @@
# Impl: CI/PR do Forgejo Actions de volta para o GitHub Actions — tracker de Issues permanece no Forgejo
Status: aprovado
Atualizado em: 2026-08-19
Issue: #97
Intenção: docs/plans/ops71-ci-github-actions-tracker-forgejo.md
Appetite restante: herdado (~2–3 dias eng; este impl é o item inteiro — OPS72 roda depois com `depends`)
## Leitura da intenção
- **Outcome:** código/PR/CI no GitHub Actions (mesma cascata de verificações,
job único `checks`), tracker de Issues no Forgejo por API, deploy **manual**
(`workflow_dispatch`) com dois jobs (`verify` hosted full → `deploy`
self-hosted no homeserver), rollup `CI (PR) / checks` como required check do
GitHub, workstation livre da suíte.
- **O que NÃO negociar:** tracker (Issues/labels/claims) permanece no Forgejo;
estrutura de job único (OPS62) preservada — nada de matrix; deploy NÃO volta
a ser automático; sem migrar Issues; sem segundo deploy (nada de Vercel);
nunca merge com CI vermelho; `pnpm push` continua sendo o caminho canônico.
- **O que reavaliar:** a intenção sugere "auto-merge nativo do GitHub ou
safety net equivalente" e "`gh workflow run` (ou API GitHub)". Reavaliado:
`gh` **não existe** em nenhuma máquina (OPS50) e a filosofia do repo é
plain-Node stdlib — os helpers novos são scripts Node zero-dep sobre a REST
do GitHub, no mesmo molde de `forgejo-api.mjs`. O auto-merge é o **nativo do
GitHub** (GraphQL `enablePullRequestAutoMerge`, rebase) — o servidor espera o
required check; o OPS64 pin (varrer statuses individuais) era defesa contra o
rollup mentiroso do runner Forgejo, que **não existe** no GitHub (o check-run
`CI (PR) / checks` de um job único é o veredito honesto, e o auto-merge nativo
só mergeia com required checks verdes — garantia do servidor).
## Abordagem recomendada
```mermaid
flowchart LR
W[worktree local] -->|pnpm push -u origin HEAD| GH[github.com/fsolla/teqo]
GH -->|PR Ready base main Closes #N| CI["ci-pr.yml — job único checks (mesma cascata OPS62)"]
CI -->|required check CI (PR) / checks| AM["agent-pr-ready-automerge.yml — auto-merge nativo (GraphQL, rebase)"]
AM -->|merge| MAIN[main no GitHub]
MAIN -->|PR closed merged| FLIP["issue-done / plan-issue-ready — flip no Forgejo via FORGEJO_API_TOKEN"]
MAIN -->|dispatch manual| V[deploy.yml verify hosted full suite]
V -->|needs| D["deploy runs-on self-hosted homeserver — deploy-homeserver.sh local"]
D --> P[(teqo-1313 produção)]
```
**Opções consideradas:**
- **A — origin vira GitHub; Forgejo como remote secundário congelado; TEQO_REPO_URL → GitHub (repo público, sem auth).** `origin/main` é o base dos gates locais (ci-scope/gate-ci) — precisa apontar para o repo que recebe os merges, senão o diff local compara contra um main defasado. O repo GitHub é público (verificado: `private: false`) → o homeserver clona sem credencial.
- **B — auto-merge via GraphQL nativo.** GitHub espera o required check no servidor; nenhum poll; sem semáforo portado. Draft `cursor/*` → Ready via PATCH; draft não-`cursor/*` = veto (OPS57, preservado).
- **C — deploy.yml com `verify` (hosted, suíte full sem skips) → `deploy` (needs, self-hosted homeserver) executando `deploy-homeserver.sh <sha>` localmente (sem SSH).**
- **D — `forgejo-issue-transition.mjs` / `agent-promote-related-on-merge.mjs` ganham `PR_BODY` env** (mecanismo que `agent-promote-related` já usa): o body do PR vem do evento GitHub; o script flipa no Forgejo por API sem ler o PR do Forgejo (PR lá não existe mais).
- **E — `configure-branch-protection.mjs` reescrito para a REST do GitHub** com lib pura `github-branch-protection.mjs` (regra desejada + drift), no mesmo molde de `branch-protection.mjs`; `enforce_admins: true` preserva o invariante "nunca merge com CI vermelho" inclusive para admin (o rule-based do Forgejo valia para todos).
**Recomendação:** A+B+C+D+E — porque cada peça reusa o molde existente (plain Node stdlib, libs puras unit-testadas, drift idempotente) e o aceite da intenção exige exatamente esse desenho; nada disso exige binário novo (`gh`) nem semáforo reimplementado.
**Rejeitadas:**
- **Instalar `gh` e usar `gh pr create/merge/run`** — binário novo em todas as máquinas + workflows, contra a convenção zero-dep do repo; a REST cobre tudo.
- **Poll-and-merge portado (`waitForChecks` do Forgejo) como mecanismo único** — reimplementa o semáforo que a intenção manda cortar; o OPS64 pin era contra o bug do rollup do Forgejo, inexistente no GitHub com job único + auto-merge nativo.
- **Mirror ativo do Forgejo (`pnpm push` para dois remotes / sync pós-merge)** — nada lê o git do Forgejo em runtime (deploy usa TEQO_REPO_URL=GitHub; flips são API); o repo Forgejo congela em main no ponto do cutover (tracker intacto por API, anti-goal "nunca fecha o tracker" preservado).
- **Deletar os arquivos do Forgejo nesta entrega** — decisão do gate (2026-08-19): o sistema GitHub Actions nasce PRIMEIRO e é validado ao vivo (PR real com CI verde → auto-merge → flips → deploy manual); a remoção de `.forgejo/workflows/` + scripts mortos é **entrega sucessora**, só depois de tudo funcionando. O repo Forgejo congelado no main pré-cutover mantém os workflows como rollback (reversível: religar runner + PRs lá); apagar na primeira entrega queimaria a via de volta.
- **Manter `ci-classify-production.mjs` como check informativo no dispatch** — o hosted não alcança o homeserver (sem SSH) e o `verify` roda a suíte full sem skips; a classificação não muda nada → removido só na entrega de remoção.
- **`TEQO_REPO_URL` → Forgejo local com mirror** — dependência extra de sync; o repo GitHub é público, clone direto sem auth.
- **Fork-PR no CI** — repo público: fork PR rodaria a suíte full em minutes hosted de graça; `if:` same-repo no job `checks` (mesma guarda de RCE dos outros workflows).
## Cutover em duas fases (gate 2026-08-19)
O GitHub é **só** o host do Actions; o Forgejo permanece o **tracker principal** (Issues, labels, claims, flips — tudo via API, intacto). A remoção dos arquivos do Forgejo só acontece após validação ao vivo do fluxo GitHub:
1. **Fase 1 (esta entrega — OPS71):** cria todo o sistema GitHub Actions (workflows, scripts, libs, docs). **Nada do Forgejo é apagado** — `.forgejo/workflows/`, `forgejo-pr-automerge.mjs`, `forgejo-dispatch.mjs`, `ci-classify-production.mjs` + specs permanecem no repo (dormentes: o repo Forgejo não recebe mais push).
2. **Cutover manual (humano, nesta ordem — (a)–(c) ANTES do push do OPS71, senão o PR mergearia sem required check):** (a) remotes locais (`origin` → GitHub, `forgejo` secundário); (b) PAT `GITHUB_TOKEN` local + secrets no GitHub (`FORGEJO_API_TOKEN`, `CURSOR_API_KEY`) + `allow_auto_merge: true`; (c) `pnpm configure:branch-protection` (GitHub); (d) **desligar o runner do Forgejo** (reversível — é o que para o schedule do `ci.yml` antigo, que não tem mais sentido e disputaria a workstation; pode ser logo após o primeiro PR GitHub validado); (e) runner self-hosted no homeserver (deploy; não bloqueia PRs — pode ficar para depois); (f) push do OPS71 → PR no GitHub → CI verde → auto-merge → flip da #97 (valida os flips no merge do próprio OPS71) → deploy manual dispatch quando o runner do homeserver estiver pronto.
3. **Fase 2 (entrega sucessora, registrada no fechamento desta Issue):** remove `.forgejo/workflows/*`, scripts mortos e docs residuais do fluxo antigo. Só depois do fluxo GitHub comprovado de ponta a ponta.
### Componentes / mudanças
- **`.github/workflows/ci-pr.yml`** (novo): port do `.forgejo/workflows/ci-pr.yml` — job único `checks`, mesmos steps/guards/services (`postgres-int`/`postgres-build` por nome na rede do job), skips via `ci-scope.mjs` (env `GITHUB_BASE_REF` idêntico). Deltas GitHub: `concurrency: { group: ci-pr-${{ github.head_ref }}, cancel-in-progress: true }` (fail-fast nativo em push novo — o OPS62 não cancelava só porque o Forgejo não tem API; aqui é grátis), `if:` same-repo no job (fork não queima minutes), `permissions: contents: read` (default), e2e selected preservado (OPS72 ajusta a política depois — `depends`).
- **`.github/workflows/deploy.yml`** (novo): `workflow_dispatch` (sem inputs — ref default main). `verify` (ubuntu-latest, timeout 50): `check-test-locations` → lint → format → typecheck → knip → cycles → unit → int (migrate+seed nos services) → build → e2e **full** (4 workers; guardas de contrato de PR ficam de fora — não são verificação de produção). `deploy` (`needs: [verify]`, `runs-on: [self-hosted, homeserver]`, timeout 60, `permissions: contents: read`): checkout → `bash scripts/deploy-homeserver.sh "$GITHUB_SHA"`.
- **`scripts/deploy-homeserver.sh`**: `TEQO_REPO_URL` default → `https://github.com/fsolla/teqo.git`; no workspace existente, `git remote set-url origin "$TEQO_REPO_URL"` idempotente antes do fetch (o clone velho aponta para o Forgejo local); doc de invocação: execução local no runner (não mais pipe via ssh) — o `</dev/null` no `run --rm` do migrate permanece. Fluxo interno intacto (HEAD guard, flock, already-deployed, migrator→migrate→runner→rollout, smoke).
- **`.github/workflows/agent-pr-ready-automerge.yml`** (novo) + **`scripts/github-pr-automerge.mjs`** (novo, plain Node) + **`scripts/lib/github-api.mjs`** (novo, REST+GraphQL zero-dep): lê PR (REST); base != main → skip; draft `cursor/*` → `PATCH draft:false`; draft outro → skip (veto OPS57); senão GraphQL `enablePullRequestAutoMerge(mergeMethod: REBASE)` no `node_id` do PR. Erro do GraphQL → exit 1 (job vermelho, visível). `permissions: { contents: write, pull-requests: write }`.
- **`scripts/github-pr.mjs`** (novo, plain Node): criação de PR para agentes/humano — `GITHUB_TOKEN` env; `--head <branch> --title <t> --body-file <f>` (Ready, base `main` — sem opção draft, regra do repo). Substitui o `forgejo_create_pull_request` do MCP no fluxo de entrega.
- **`scripts/lib/github-branch-protection.mjs`** (novo, puro): `DESIRED_RULE` GitHub (`required_status_checks.checks = [{ context: 'CI (PR) / checks' }]`, strict=false, 0 reviews, `enforce_admins: true`, sem restrictions) + `ruleMatches`/`planBranchProtectionRule` (mesmo contrato do atual).
- **`scripts/configure-branch-protection.mjs`**: reescrito para a REST do GitHub (`PUT /repos/{owner}/{repo}/branches/main/protection`), idempotente (read → plan → apply → verify), `--dry-run` preservado.
- **`scripts/forgejo-issue-transition.mjs`**: aceita `PR_BODY` env (body do PR vem do evento GitHub); sem PR_BODY mantém a leitura via API do Forgejo (fallback — os workflows do Forgejo ainda estão vivos até a Fase 2); comentário do flip atualizado ("deploy manual via dispatch, ver AGENT-OPS").
- **`.github/workflows/issue-done-on-main-merge.yml` / `plan-issue-ready-on-main-merge.yml`** (novos): `pull_request: closed` + `if:` merged && base main && **same-repo** (guarda RCE: checkout de fork + PAT = RCE — preservada); `PR_BODY: ${{ github.event.pull_request.body }}`; `FORGEJO_API_TOKEN` secret; sem `GITHUB_TOKEN` de Issues do GitHub (flip é no Forgejo).
- **`.github/workflows/archive-cursor-agent.yml`** (novo): port direto do `.forgejo/workflows/` (CURSOR_API_KEY).
- **`docs/AGENT-OPS.md`, `.agents/rules/agent-pr-workflow.mdc`, `.agents/skills/work-issue/` (SKILL + execution-pipeline), `.agents/skills/agent-work-issue/SKILL.md`, `.agents/skills/capture-review-debts/SKILL.md` (linha do fechamento), `AGENTS.md`, `docs/ops/teqo-1313-deploy.md`**: fluxo de PR/CI/merge/deploy reescrito para GitHub (canônico); seção de transição nomeando a Fase 2; tabela de secrets (GitHub agora: `FORGEJO_API_TOKEN`, `CURSOR_API_KEY`, local `GITHUB_TOKEN` PAT); passos manuais do cutover na ordem (incl. desligar o runner do Forgejo e por quê — o schedule do `ci.yml` antigo pararia de ter sentido).
- **Preservados nesta entrega (Fase 2 remove):** `.forgejo/workflows/*` (6 arquivos), `scripts/forgejo-pr-automerge.mjs`, `scripts/forgejo-dispatch.mjs`, `scripts/ci-classify-production.mjs`, `scripts/lib/dockerignore.mjs`, `scripts/lib/branch-protection.mjs` + specs (`ciClassifyProduction`, `dockerignoreMatch`, `branchProtection`) — dormentes, documentados como rollback e alvo da entrega de remoção.
- **Testes novos:** `tests/unit/githubApi.unit.spec.ts` (normalização de shapes + endpoints REST/GraphQL, molde do `forgejoApi.unit.spec.ts`), `tests/unit/githubBranchProtection.unit.spec.ts` (regra desejada + drift, molde do `branchProtection.unit.spec.ts`), e pure-functions da política de draft/skip do automerger (`scripts/lib/github-pr-flow.mjs` + spec — o pin OPS57/OPS64 do jeito GitHub).
- **Migration:** sem migration (nenhum schema).
- **Access / Consent:** nenhum.
- **UI:** Impeccable N/A.
## Fases verificáveis
1. **Libs + scripts base** — `github-api.mjs`, `github-pr-flow.mjs` (puro), `github-branch-protection.mjs` (puro), `github-pr.mjs`, `github-pr-automerge.mjs`, `forgejo-issue-transition.mjs` (PR_BODY), `configure-branch-protection.mjs` (GitHub) + specs unit. Gate: `pnpm test:unit`.
2. **Workflows** — `.github/workflows/*` (6). Nada do `.forgejo/workflows/` é tocado nesta fase (Fase 2 do cutover). Gate: `pnpm gate:fast` + `pnpm check:cycles` + knip.
3. **Docs/skills + changelog** — AGENTS.md, AGENT-OPS, agent-pr-workflow.mdc, skills, runbook (com seção de transição), `docs/changelog/2026-08-19-ops71.md` + `pnpm changelog:build`.
4. **Gates finais + entrega** — `pnpm gate:ci` completo (unit+int+build), format, knip, cycles; `pnpm push -u origin HEAD` → PR no GitHub via `node scripts/github-pr.mjs` → CI verde no GitHub → auto-merge → flip da #97. **Passos manuais do cutover listados para o humano** (Fase 1 do cutover: remotes, secrets, branch protection, runner homeserver, shutdown runner Forgejo) — o PR em si não depende deles (CI roda no GitHub com secrets de teste), mas a ordem recomendada é: remotes → secrets → configure → push → PR.
## Fase 2 — entrega sucessora (pós-validação)
Registrada no fechamento desta Issue (capture-review-debts): remove `.forgejo/workflows/*`, `forgejo-pr-automerge.mjs`, `forgejo-dispatch.mjs`, `ci-classify-production.mjs`, `dockerignore.mjs` (+ specs órfãs) e qualquer doc residual do fluxo Forgejo. Critério de entrada: um ciclo completo validado no GitHub (PR → CI → auto-merge → flip → deploy manual).
## Rabbit holes / Não escopo (engenharia)
- Portar `waitForChecks`/statuses para o GitHub — não há; auto-merge nativo.
- Re-engenharia do `deploy-homeserver.sh` (fluxo docker/compose/migrate) — só a origem do clone e o modo de invocação mudam.
- Sincronizar o repo Forgejo com main — congelado (decidido, anti-goal do tracker preservado).
- Migrar Issues/labels/claims — anti-goal explícito.
- OPS72 (e2e local afetado) — item sucessor com `depends: [OPS71]`.
- Pool/`agent-pool*` — dormente (OPS65); o texto `gh pr` em `agent-pool-prompt.mjs` é de feature morta — deixo como está (fora do serializes).
- Secrets de GitHub (`FORGEJO_API_TOKEN`, `CURSOR_API_KEY`, `GITHUB_TOKEN` PAT local) — passo manual documentado, não código.
## Riscos e mitigação
- **Nome do check-run `CI (PR) / checks`:** o workflow `name: CI (PR)` + job `checks` geram exatamente esse check-run no GitHub; o required check usa o mesmo literal — pin no spec da branch protection e verificação ao vivo no primeiro PR (se o nome divergir, o configure aponta o drift e o PR não mergea — fail-closed, nunca merge sem verificação).
- **Auto-merge GraphQL com `GITHUB_TOKEN`:** permissões declaradas (`contents`+`pull-requests: write`) cobrem PATCH draft e a mutation; se o GraphQL recusar (ex.: auto-merge desabilitado no repo), o job fica vermelho com a mensagem — mitigação: passo manual `allow_auto_merge: true` no repo (Settings ou API) listado no cutover.
- **PR com conflito:** auto-merge fica armado e o PR espera — igual ao hoje (waitForChecks lançava "não mergeável"); nenhum merge errado é possível (servidor).
- **Repo público → fork PR:** `if:` same-repo no job `checks` + setting de fork workflows no repo (passo manual) — fork não queima minutes nem acessa secrets.
- **Runner do homeserver ainda não instalado:** o `deploy` fica indisponível até o passo manual; PRs seguem mergeando (CI hosted não depende do runner). O PR deste item não publica nada sozinho — o deploy é dispatch manual.
- **Schedule do `ci.yml` antigo rodando na workstation durante a transição:** com o repo Forgejo congelado, o gate do Forgejo lerá um deployed SHA inexistente no clone → fail-open vermelho → suíte full a cada 30 min na workstation. Mitigado: o cutover desliga o runner do Forgejo logo após o merge (passo (d), reversível) — antes do primeiro deploy via GitHub.
- **`gate:ci` local vs `origin/main`:** o remote muda para GitHub; sem o passo manual dos remotes, o gate local compara contra o Forgejo defasado → risco de diff falso. Mitigado: o passo de remotes é o PRIMEIRO do cutover e o runbook avisa; o `*-impl.md` documenta o comando exato.
- **TEQO_REPO_URL no homeserver:** clone velho com origin Forgejo → `set-url` idempotente no script cobre; repo público → sem credencial.
- **Knip com os scripts do Forgejo preservados:** nada órfão é criado nesta entrega (todos os scripts seguem referenciados pelos YAMLs do `.forgejo/workflows/`, que permanecem) — sem risco de knip flagrar a deleção adiada.
## Aceite de engenharia
- [x] Aceite de produto da intenção ainda coberto (CI/PR GitHub, tracker Forgejo, deploy manual verify→self-hosted, rollup required check)
- [x] Cutover em duas fases (gate 2026-08-19): sistema GitHub criado e validado primeiro; arquivos do Forgejo preservados nesta entrega, remoção em entrega sucessora pós-validação
- [x] Invariantes AGENTS/engineering-standards (sem migration, sem schema; plain Node stdlib nos helpers de CI; zero-dep nos workflows)
- [x] Testes de domínio previstos: github-api (normalização/endpoints), github-branch-protection (drift), github-pr-flow (draft policy/skips — pin OPS57/OPS64 no jeito GitHub)

View file

@ -1,241 +0,0 @@
---
id: OPS71
depends: []
serializes:
[
'.github/workflows/',
'.forgejo/workflows/',
'docs/AGENT-OPS.md',
'.agents/skills/work-issue/',
'.agents/rules/agent-pr-workflow.mdc',
]
priority: P1
model: cursor-grok-4.5-high
---
# CI/PR do Forgejo Actions de volta para o GitHub Actions — tracker de Issues permanece no Forgejo
Status: rascunho
Atualizado em: 2026-08-19
Issue: #97
Priority: P1
Model: cursor-grok-4.5-high
Impeccable: A — N/A (sem UI)
Rascunho UI: N/A — sem UI
Appetite: ~2–3 dias eng; um outcome verificável (CI verde no GitHub, tracker vivo no Forgejo, deploy preservado)
Responsável: —
## Intenção
O Forgejo Runner roda na workstation (Ryzen 7 7800X3D) e **está pedindo muito
da máquina**: a suíte inteira (build + int + e2e) roda localmente a cada PR e
na janela de main, disputando CPU com o dev server, o Docker local, o browser
e os worktrees paralelos. O OPS65 já tentou mitigar (janela de 30 min + matar
o pool), mas o custo por PR continua pesado — e ele cresce com o número de PRs
abertos, que é exatamente o que queremos aumentar.
A decisão: **migrar o CI de volta para o GitHub Actions** (runners hosted,
custo zero na workstation, um runner por PR — PRs avançam em paralelo de
verdade). **O tracker de Issues permanece no Forgejo local** (`git.solla.dev`,
já público via Cloudflare — confirmado HTTP 200 hoje): o CI no GitHub fala com
o tracker por API (`FORGEJO_API_TOKEN` + `FORGEJO_API_URL`), como os scripts
locais já fazem. Não é voltar ao mundo pré-OPS50 — é um novo meio-termo
deliberado: **código/PR/CI no GitHub, Issues/labels no Forgejo**.
Mantemos a **estrutura de verificações** construída nos OPS61/OPS62/OPS64
(job único `checks` sequencial, guards no início, skips de escopo via
`ci-scope.mjs`, rollup `CI (PR) / checks` como gate de merge) — o que muda é o
host onde ela roda. E a política de e2e muda junto (ver OPS72: e2e local
apenas para os testes afetados, não full no CI de PR). **O deploy deixa de ser
automático** (OPS53/OPS65 viram histórico): vira uma **action manual
(`workflow_dispatch`)** que roda **todas as verificações full** antes de
publicar — ver "Questões em aberto" (questão 1) e o fluxo abaixo.
## Persona e fluxo
- **Persona / contexto:** o dono da máquina (dev/coordenador do repo) rodando
dev local, worktrees e múltiplos PRs; os agentes (humanos e pool) que abrem
PRs e acompanham o CI até o merge; o coordenador que dispara o deploy quando
decide publicar.
- **Job principal:** cada PR custa ~0 da workstation; o CI roda no GitHub em
paralelo para todos os PRs; o merge continua automático e seguro; publicar
em produção é uma ação manual com verificação full.
- **Fluxo desejado:**
1. Agente claima Issue no Forgejo (igual hoje — tracker intocado).
2. Worktree/branch local; `pnpm push` para o **GitHub**; PR aberto no
**GitHub** (Ready, base `main`, `Closes #N`).
3. GitHub Actions roda o job único `checks` (mesma cascata de hoje, com e2e
apenas nas áreas afetadas — OPS72) e posta o status no PR.
4. Merge automático quando o rollup `CI (PR) / checks` fica verde
(auto-merge nativo ou safety net equivalente).
5. Pós-merge: Issue flipada para `done`/`in-prod` no **Forgejo** (workflow
GitHub Actions → API do Forgejo); `Related #N` promove `blocked` →
`ready` no Forgejo.
6. Deploy **manual** (workflow_dispatch): rodar verificações full → o job
`deploy` executa `scripts/deploy-homeserver.sh` no homeserver (runner
self-hosted lá, ou via SSH da workstation como fallback — questão 1).
- **Anti-goals de produto:** NÃO migrar o tracker (Issues/labels/claims ficam
no Forgejo); NÃO re-introduzir jobs paralelos com matrix no CI (a estrutura
de job único do OPS62 vale no GitHub também); NÃO tocar em schema/migrations
de produto; NÃO re-publicar Vercel ou qualquer segundo deploy; deploy NÃO
volta a ser automático a cada merge.
## Objetivo e aceite
- O CI de PR (`ci-pr.yml`) roda no GitHub Actions com a **mesma cascata de
verificações** de hoje (guards → lint → format → typecheck → knip → cycles →
unit → int → build → e2e **selected/afetado**, com skips de escopo), como
**um job único `checks`**.
- O **deploy** vira uma action **manual** (`workflow_dispatch` no GitHub
Actions) com **dois jobs no mesmo workflow**: `verify` (hosted
`ubuntu-latest`, suíte **full** incl. e2e full) → `deploy`
(`needs: [verify]`, `runs-on: [self-hosted, homeserver]` — runner do GitHub
no homeserver executa `scripts/deploy-homeserver.sh` localmente, sem SSH).
O hosted nunca toca o homeserver; o self-hosted não conta minutos hosted.
Sem verificador de main (`ci.yml`) — só o pre-deploy roda full
(decisão do usuário, gate 2026-08-19).
- O rollup `CI (PR) / checks` continua sendo o required check de `main` e o
gate do merge automático (agora no GitHub).
- PRs são abertas no GitHub; agentes acompanham o CI até o merge em `main`
(skills atualizadas — work-issue/agent-work-issue/execution-pipeline,
AGENT-OPS, agent-pr-workflow).
- As Issues **não migram**: labels, claims, `pnpm issue`/`agent:*` continuam
falando com o Forgejo; flips pós-merge funcionam via API (verificado: o
Forgejo é alcançável publicamente).
- A workstation não roda mais a suíte de CI; o Forgejo runner é desligado.
- Deploy manual com verificação full preservado (ver questão 1).
- Um repo GitHub sincronizado (histórico atual de `main` presente — hoje o
repo GitHub está parado em 2026-08-13).
## Dados (intenção)
- **Vou apresentar dados?** Não.
- **Decisões desbloqueadas:** nenhuma KPI nova. (O "custo da workstation" é
observável pela própria máquina; não precisa de métrica nova.)
## Direção no codebase (hipótese)
- **Áreas prováveis:**
- `.github/workflows/` (novo): `ci-pr.yml` (job único `checks`, e2e
selected — OPS72), `deploy.yml` (**`workflow_dispatch` manual, dois jobs:
`verify` hosted full → `deploy` `runs-on: [self-hosted, homeserver]`**),
`agent-pr-ready-automerge.yml` (safety net), `issue-done-on-main-merge.yml`,
`plan-issue-ready-on-main-merge.yml`, `archive-cursor-agent.yml`. Sem
`ci.yml` (verificador de main eliminado — decisão do gate).
- `scripts/`: adaptar `forgejo-pr-automerge.mjs` (ou substituir por
auto-merge nativo do GitHub / API `merge_when_checks_succeed`),
`forgejo-issue-transition.mjs` e `agent-promote-related-on-merge.mjs`
(leem o PR — agora do GitHub — e flipam no Forgejo),
`forgejo-dispatch.mjs` → `gh workflow run` (ou API GitHub), novos helpers
GitHub (`scripts/lib/`); `configure-branch-protection.mjs` → proteção no
GitHub.
- `scripts/deploy-homeserver.sh` — revisar o modo de invocação (runner no
homeserver: sem `ssh`, execução local; via workstation: SSH mantido) e o
`TEQO_REPO_URL` (hoje `localhost:3000` do Forgejo no homeserver — com o
código no GitHub, apontar para o GitHub ou manter o espelho local).
- `.agents/skills/**`, `.agents/rules/agent-pr-workflow.mdc`,
`docs/AGENT-OPS.md`, `AGENTS.md` — o paradigma de PR passa a apontar para
o GitHub (PR/CI), tracker Forgejo.
- **Precedente a olhar:** `docs/plans/ops50-ci-github-para-forgejo.md` (o
espelho inverso — o que foi migrado para o Forgejo e como), `ops62`,
`ops65`, `ops61` (branch protection/contratos de merge), `docs/ops/teqo-1313-deploy.md`,
e o projeto **`infra-solla`** (ver "Nota para o executor").
- **Risco de acoplamento:** o deploy (OPS53) hoje depende do runner local da
workstation (SSH para o homeserver) e o build de produção **não pode sair do
homeserver** (migrate no banco de prod antes do build — OPS66, estático na
rede do compose, registry local `localhost:5000`). O GitHub hosted não
alcança nada disso — por isso o job `deploy` roda no runner self-hosted do
homeserver (decisão do gate). O `ci-classify-production.mjs` (gate OPS65)
morre com o deploy manual (sem janela automática) — pode ser removido ou
reutilizado no dispatch como check informativo de "produção mudou desde o
último deploy".
## Nota para o executor — projeto `infra-solla`
Para detalhes do homeserver (stack, registry, tunel, runner, rede, `ssh
homeserver` via tailnet), **leia o projeto `infra-solla`** (fora deste repo):
`STATE.md` e `plano-infra-final.md` §"Arquitetura de deploy". É a fonte usada
pelo OPS53 para corrigir a premissa de onde rodava o runner, e é onde o
passo manual "instalar o runner self-hosted do GitHub no homeserver" deve ser
registrado/anotado. Não invente topologia: confirme lá (homeserver 8c/16GB,
laptop; `~/stack/docker-compose.yml`; registry `localhost:5000` com htpasswd;
cloudflared; `ssh homeserver` pela tailnet) antes de decidir como o runner
self-hosted se instala e como o `deploy.yml` o alcança.
## Dependências
- OPS72 (e2e local afetado + CI de PR sem e2e full) — **serializa com este
item** (ambos tocam `ci-pr.yml` e a skill work-issue); OPS72 declara
`depends: [OPS71]` para rodar depois e evitar rebase cruzado. Decisão de
gate: CI de PR mantém e2e **selected** (afetado) — confirmado.
- #85 (OPS70, gate skipped no schedule do Forgejo) — **absorvida**: com o
deploy manual, o gate de schedule deixa de existir; fechar como superada
quando este item mergear.
- #87 (OPS70, guard de importMap) — independente, permanece na fila; o
executor dela deve mirar os workflows novos do GitHub.
- #72 (S3-FOLLOWUP, e2e local `--no-deps` colide no seedTestUser) — não é
duplicada; o OPS72 (e2e local afetado) cita a limitação existente.
## Fora de escopo
- Migrar Issues/labels do Forgejo (anti-goal explícito).
- Mudar a estrutura de verificações (job único, guards, skips) — preservada.
- Alterar o `e2e-affected`/`ci-scope` em si — mecânica já existe; só muda
onde/quando roda (OPS72).
- Repositório do Forgejo: continua como tracker; se virar mirror read-only de
main ou parar de receber push — decisão do impl, mas nunca fecha o tracker.
- Upgrade do Forgejo no homeserver (decisão de infra separada).
## Rabbit holes de produto
- **Re-engenharia do deploy.** O job `deploy` dependia do runner local; sem
decisão, o "preservar deploy" vira um projeto de infra. **Corte neste item:**
uma das opções da questão 1, fechada no gate, sem inventar terceiro caminho.
- **Reescrever o contrato de merge.** Auto-merge do GitHub é nativo, mas o
wait-por-checks/rollup do OPS64 foi calibrado para o Forgejo; portar
errado = merge com CI vermelho. **Corte:** usar o mecanismo nativo do GitHub
(o rollup `CI (PR) / checks` vira o required check lá) e pin unit-testado no
script, sem reimplementar semáforos novos.
- **"Já que estamos no GitHub, migra as Issues também".** O usuário foi
explícito: tracker fica no Forgejo. **Corte:** qualquer proposta de migrar
o tracker é rejeitada neste item.
## Questões em aberto (produto)
- **Como fica o deploy?** **Decisão do usuário (gate 2026-08-19):** deploy
**manual** via `workflow_dispatch` no GitHub Actions — não é mais automático
a cada merge. O job de deploy roda **todas as verificações full** antes de
publicar. **Formato (confirmado):** **dois jobs no mesmo `deploy.yml`** —
`verify` no hosted `ubuntu-latest` (suíte full) e `deploy` com
`needs: [verify]` rodando em **runner self-hosted do GitHub no homeserver**
(conecta outbound ao GitHub — funciona atrás do Cloudflare tunnel; o job
executa `scripts/deploy-homeserver.sh` localmente, sem SSH; não conta
minutos hosted). O build de produção **não pode rodar fora do homeserver**
(migrate pré-build no banco de prod — OPS66, estático na rede do compose,
registry local `localhost:5000`) — por isso o hosted só verifica e o
self-hosted só build/deploy. Fallbacks registrados (não escolhidos): runner
na workstation via SSH (B) ou runbook manual no homeserver (C).
- **E o ci.yml (verificador de main)?** **Decidido (gate 2026-08-19):**
**eliminar** — fica só o pre-deploy (`verify` do dispatch manual) como
verificação full. Motivo: custo mínimo de Actions; o deploy é deliberado e
roda full na hora; se um dia o merge em main quiser rede de regressão, o
`ci.yml` volta com 1 linha de trigger (sem deploy).
- **Fatiamento do lote?** **Opções:** A) OPS71 (este, CI+PR+merge+deploy) e
OPS72 (e2e local afetado) separados com `depends`; B) um único item com tudo.
**Recomendação:** A — cada um tem outcome verificável sozinho e o OPS72
(skill work-issue + ci-pr sem e2e full) é pequeno. _(assumido — validar)_
- **Repo GitHub defasado.** Hoje parado em 2026-08-13 (pré-OPS50). **Opções:**
A) re-sincronizar via push do estado local (histórico é ancestral comum —
fast-forward limpo); B) import novo (histórico duplicado/PRs antigas órfãs).
**Recomendação:** A. _(assumido — validar)_
## Referências
- GitHub Issue: #97 (OPS71)
- Rascunho UI (gate): N/A
- `.forgejo/workflows/` (a remover após cutover), `docs/plans/ops50-ci-github-para-forgejo.md`,
`docs/plans/ops62-ci-fail-fast-forgejo.md`, `docs/plans/ops65-ci-main-janela-30min-e-matar-pool.md`,
`docs/plans/ops61-forgejo-contratos-merge-labels-e-branch-protection.md`,
`scripts/forgejo-pr-automerge.mjs`, `scripts/forgejo-issue-transition.mjs`,
`scripts/forgejo-dispatch.mjs`, `scripts/configure-branch-protection.mjs`,
`scripts/ci-classify-production.mjs`, `docs/ops/teqo-1313-deploy.md`

View file

@ -1,83 +0,0 @@
# Impl: OPS71 Fase 2 — remover arquivos do Forgejo (workflows/scripts) após validação do GitHub Actions
Status: aprovado
Atualizado em: 2026-08-19
Issue: #111
Intenção: docs/plans/ops71-ci-github-actions-tracker-forgejo.md (§"Fase 2 — entrega sucessora", linha 99-101 do impl da Fase 1)
Appetite restante: pequeno — remoção + fixups de consumidores + docs (1 sessão)
## Leitura da intenção
- **Outcome:** o fluxo GitHub Actions (OPS71 Fase 1, validado ao vivo: PRs #742/#743 — CI verde, auto-merge nativo, flips via API) é a única via de CI/PR/deploy no repo; os arquivos do fluxo Forgejo saem do tree; docs residuais atualizados; nada do tracker é tocado.
- **O que NÃO negociar:** tracker no Forgejo (Issues/labels/claims/flips via API) — `forgejo-api.mjs`, `agent-*.mjs`, `issue.mjs`, `forgejo-issue-transition.mjs`, `agent-promote-related-on-merge.mjs` **ficam**; `.forgejo/worktree.env` fica (fora do escopo `workflows/*`, consumido pelo provisionamento de worktree); desligar o runner do Forgejo é passo manual do runbook (fora do código); `ci.yml` de main não volta (deploy = dispatch manual).
- **O que reavaliar:** a hipótese da intenção dizia "grep `forgejo-pr-automerge|forgejo-dispatch|ci-classify|dockerignore` como saída" — a varredura achou **dois consumidores funcionais** além de docs, não listados no escopo literal: `scripts/lib/test-affected-core.mjs` (HIGH_RISK_EXACT com os 2 paths mortos) e `tests/unit/ciSkipInvariants.unit.spec.ts` (lê `.forgejo/workflows/ci.yml`+`ci-pr.yml` e pina o gate OPS65). Ambos **precisam ser editados** — são o contrato que quebraria se os arquivos sumissem. Também achado: `README.md` ainda descreve o fluxo Forgejo pré-Fase 1 (janela de 30 min, deploy automático, linha de CI apontando `.forgejo/workflows/`).
## Abordagem recomendada
```mermaid
flowchart LR
D[14 arquivos deletados] --> C1[test-affected-core: -2 entries HIGH_RISK]
D --> C2[ciSkipInvariants: -2 paths .next-e2e, -2 high-risk, teste OPS65 reescrito para deploy.yml dispatch-only]
D --> C3[gate-ci: comentário .github/workflows/ci-pr.yml]
D --> C4[github-pr-automerge + agent-pr-ready-automerge: comentário sem referência morta]
D --> DOC[AGENTS.md + runbook + README — era GitHub só]
DOC --> CL[changelog ops71-fase2 + changelog:build]
```
**Opções consideradas:**
- **A — Remoção seca + fixups mínimos dos consumidores** (o escopo do body + os 2 consumidores funcionais + 3 docs). Nada de novo é criado; invariantes de CI re-ancorados no `.github/workflows/`.
- **B — Reescrever `ciSkipInvariants` para pinar "`.forgejo/` não existe"** — redundante: a deleção é o próprio diff do PR; e o teste que importa é o invariante _vivo_ (deploy nunca automático), que re-pino no `deploy.yml`.
- **C — Deletar o teste OPS65 inteiro** (remover o teste da janela de 30 min e pronto) — perderia o pin "nunca deploy automático" sem reposição; o GitHub manteve o espírito (dispatch manual), o teste deve continuar existindo apontando para o arquivo certo.
**Recomendação:** A — porque os consumidores são pequenos e bem delimitados, a substituição do pin OPS65 pelo pin equivalente da era GitHub (`deploy.yml` sem trigger `push`/`schedule`; `ci-pr.yml` sem o gate morto) preserva a invariante de deploy manual, e nada além do escopo declarado é tocado.
**Rejeitadas:**
- **Manter os arquivos como rollback inerte** — o propósito da Issue é a remoção; rollback passa a ser git history (o main congelado do Forgejo é uma cópia íntegra da era Forgejo; o PR é um commit reversível).
- **Deletar `forgejo-issue-transition.mjs`/`agent-promote-related-on-merge.mjs`/`forgejo-api.mjs`/`agent-*.mjs`** — anti-goal explícito (tracker vivo).
- **Deletar `.forgejo/worktree.env`** — fora do escopo (`workflows/*`); consumido pelo provisionamento de worktree.
- **Editar `docs/plans/*`, `docs/changelog/*`, `docs/CHANGELOG-AGENTS.md`** — história congelada/insert-only; a narrativa de "preservados até Fase 2" vive nos changelogs antigos e permanece.
- **Tocar `docs/AGENT-OPS.md`** — já é era-GitHub (grep não acha referência a nenhum arquivo removido).
### Componentes / mudanças
- **Deletados (14):** `.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`; specs `tests/unit/ciClassifyProduction.unit.spec.ts`, `tests/unit/dockerignoreMatch.unit.spec.ts`, `tests/unit/branchProtection.unit.spec.ts`. Nenhuma importação viva aponta para eles (grep: só `test-affected-core` + `ciSkipInvariants` + docs).
- **`scripts/lib/test-affected-core.mjs`**: remove `scripts/ci-classify-production.mjs` e `scripts/lib/dockerignore.mjs` de `HIGH_RISK_EXACT` (paths mortos nunca aparecem em diff; listas devem espelhar o tree).
- **`tests/unit/ciSkipInvariants.unit.spec.ts`**: (1) tira `ci.yml`+`ci-pr.yml` do loop `.next-e2e`; (2) tira os 2 paths do pin de high-risk; (3) reescreve o teste "main CI 30-min window (OPS65)" para a era GitHub: lê `.github/workflows/deploy.yml` e pina `workflow_dispatch:` presente, `push:`/`schedule:` ausentes (deploy nunca automático) e `.github/workflows/ci-pr.yml` sem `ci-classify-production.mjs` (gate morto).
- **`scripts/gate-ci.mjs`** (comentário do header): "Local mirror of `.forgejo/workflows/ci-pr.yml`" → `.github/workflows/ci-pr.yml`.
- **`scripts/github-pr-automerge.mjs` + `.github/workflows/agent-pr-ready-automerge.yml`**: comentários com o parêntese "(forgejo-pr-automerge.mjs)" → referência genérica à era Forgejo (arquivo não existe mais).
- **`AGENTS.md`**: frase "The Forgejo-era files (...) are preserved as rollback until the OPS71 Fase 2 removal" → removidos na Fase 2; rollback via git history (main congelado do Forgejo / revert do PR).
- **`docs/ops/teqo-1313-deploy.md`** (§"Rollback do cutover"): "os workflows de `.forgejo/workflows/` seguem no repo até a Fase 2" → arquivos removidos; restauração via git history do main congelado do Forgejo.
- **`README.md`**: bloco de fluxo stale (janela 30 min, deploy automático, linha `CI: .forgejo/workflows/ci-pr.yml + ci.yml`) → fluxo era-GitHub (PR → `CI (PR) / checks` → auto-merge nativo → deploy manual dispatch).
- **Changelog:** `docs/changelog/2026-08-19-ops71-fase2.md` + `pnpm changelog:build`.
- **Migration:** sem migration (nenhum schema).
- **Access / Consent / UI:** N/A.
## Fases verificáveis
1. **Deleção + fixups de código** — `git rm` dos 14 arquivos; edita `test-affected-core.mjs`, `ciSkipInvariants.unit.spec.ts`, comentários de `gate-ci.mjs`/`github-pr-automerge.mjs`/`agent-pr-ready-automerge.yml`. Gate: `pnpm test:unit` (ciSkipInvariants/testAffected/github\*).
2. **Docs + changelog** — AGENTS.md, runbook, README, `docs/changelog/2026-08-19-ops71-fase2.md` + `pnpm changelog:build`.
3. **Gates finais** — `pnpm exec tsc --noEmit`, `pnpm lint`, `pnpm format:check`, `pnpm exec knip`, `pnpm check:cycles`, `pnpm test` (unit+int), `pnpm build`; grep de saneamento (`forgejo-pr-automerge|forgejo-dispatch|ci-classify|dockerignore.mjs|branch-protection.mjs` → só histórico/docs/changelogs); push → PR GitHub → CI (PR) / checks → auto-merge.
## Rabbit holes / Não escopo (engenharia)
- Re-engenharia do `ciSkipInvariants` além do re-pino — não; só o que o arquivo lê muda.
- `pnpm configure:branch-protection`/github libs — intocados (era GitHub).
- Pool/`agent-pool*` dormente (OPS65) — intocado.
- Desligar o runner do Forgejo — passo manual no runbook, não código.
- `.dockerignore` (o arquivo) — **permanece**: o `docker build` do `deploy-homeserver.sh` o consome; só o lib `dockerignore.mjs` (parse de regras, gate OPS65) morre.
## Riscos e mitigação
- **`ciSkipInvariants` quebrar por leitura de arquivo removido** — os 3 pontos são editados na mesma fase da deleção; gate local `pnpm test:unit` antes de qualquer push.
- **Pin perdido do "deploy manual"** — mitigado pelo teste reescrito (dispatch-only, sem `push:`/`schedule:`); se um dia o `ci.yml` voltar com trigger, o teste acende.
- **Referência morta residual fora do grep** — saneamento final com o grep do escopo + knip (dead exports/deps é CI-blocking); docs históricos (plans/changelogs antigos) são allowlist consciente.
- **Knip acusar `branch-protection.mjs`/`dockerignore.mjs` como órfãos na Fase 1→Fase 2** — a deleção é atômica com a remoção das referências (mesma fase); nada fica órfão no diff.
- **PR de plano vs `check-plans-only-pr-closes`** — este PR tem código (não é plans-only); guard não se aplica.
## Aceite de engenharia
- [x] Aceite de produto da intenção ainda coberto (remoção completa + docs; tracker intacto)
- [x] Invariantes AGENTS/engineering-standards (sem migration/schema; nada do tracker; docs históricos intocados; changelog insert-only)
- [x] Testes de domínio previstos: ciSkipInvariants re-ancorado na era GitHub (deploy dispatch-only); unit suíte verde

View file

@ -1,97 +0,0 @@
# Impl: OPS71-FLIP — Flip pós-merge falhou/silencioso no fluxo GitHub — validar e endurecer issue-done-on-main-merge
Status: aprovado
Atualizado em: 2026-08-20
Issue: #119
Intenção: body da Issue (sem plano linkado — o body é a spec)
Appetite restante: herdado (chore P2, sem appetite declarado — escopo = as 4 ações sugeridas + fix de raiz)
## Leitura da intenção
- **Outcome:** todo PR mergeado em `main` com `Closes #N` deve flipar a Issue no Forgejo para `done`+`in-prod` + comentário + **close** — de forma **não silenciosa** (falha visível) e com **caminho de recuperação manual** quando o evento do GitHub não criar run.
- **O que NÃO negociar:** tracker continua no Forgejo (flips via `FORGEJO_API_TOKEN`); fork guard (RCE) preservado; flips nunca mentem (OPS61: job vermelho se qualquer flip falhar); CI green é o único gate de merge.
- **O que reavaliar:** a hipótese da Issue ("tipos de evento / entrega do GitHub") estava certa só pela metade — a causa real é a **anti-recursão do GITHUB_TOKEN** (merge atribuído a `github-actions[bot]` não cria runs); a hipótese "410 é transitório, só pin/retry" é insuficiente — o 410 quebrou o run do #742 **depois** do evento ter criado o run; os dois problemas são independentes.
## Investigação (root cause — evidência ao vivo, GitHub API pública)
Comparação dos dois merges (timeline `issues/{n}/timeline` + `actions/runs`):
| | PR #742 (Fase 1) | PR #746 (Fase 2) |
| ------------------------ | ----------------------------------------------------------------- | ----------------------------------------------------------------------- |
| `auto_rebase_enabled` | `github-actions[bot]` 21:20:38Z | `github-actions[bot]` 00:02:32Z |
| `merged` / `closed` | **`fsolla`** 22:17:20Z | **`github-actions[bot]`** 00:20:11Z |
| Runs criados no `closed` | 32308114529 (falhou 410) + 32308114530 (skipped), 3s após o merge | **0 runs** (nenhum evento, nenhum head — janela 00:00–00:45 verificada) |
**Causa raiz:** o auto-merge nativo armado via `enablePullRequestAutoMerge` (GraphQL) com o **GITHUB_TOKEN** do workflow executa o merge como `github-actions[bot]`. O GitHub **documenta** ("Automatic token authentication", anti-recursão): eventos acionados por ações feitas com o `GITHUB_TOKEN` **não criam workflow runs** (exceção: `workflow_dispatch`, `repository_dispatch`). O `closed` fica no timeline do PR, mas o Actions não gera run nenhum — nem `pull_request`, nem `push` (0 runs na janela do merge). Toda auto-merge por bot vai pular os dois flips pós-merge **em silêncio** (run nem é criado — nunca vai aparecer vermelho).
O 410 é falha **separada**: o run do #742 foi criado normalmente (merge por humano) e o `GET /issues/97/labels` respondeu 410 Gone (nginx) — transitório, endpoint ok às 00:3x. O retry do OPS67 cobre fetch-rejection + 5xx-no-GET; 410 (4xx) nunca retenta; o corpo nginx HTML entra no erro só como slice cru de 400 chars (imprestável para log).
## Abordagem recomendada
```mermaid
flowchart LR
M["PR mergea em main"] -->|"auto-merge armado com PAT real (AUTOMERGE_PAT)"| E["closed event → runs criados<br/>(merge atribuído ao dono do PAT)"]
M -. "legado: bot merge (GITHUB_TOKEN)" .-> X["anti-recursão: 0 runs<br/>(causa raiz #746)"]
E --> F["issue-done / plan-issue-ready flipam no Forgejo"]
F --> C["labels done+in-prod + comentário + close<br/>(forgejo-issue-transition.mjs)"]
H["humano: dispatch manual (recovery)"] --> G["gh pr view → PR_BODY → mesmo script"]
C -->|"410/5xx transitório"| R["retry 410 no forgejo-api<br/>(proxy respondeu — origem intocada)"]
```
**Opções consideradas (fix de raiz):** A | B | C
**Recomendação:** **A + recuperação manual + close + retry 410** — a trinca atende os 4 pontos da Issue e conserta a causa que a torna _silenciosa_.
**Rejeitadas:**
- **B — manter arming com GITHUB_TOKEN e só adicionar dispatch manual:** o flip continua quebrado em todo merge de bot (o "silencioso" do título); o humano vira o flip de todo PR. Rejeitada porque a Issue é exatamente sobre o silêncio.
- **C — poller (schedule) que varre PRs mergeados:** disparo a cada N minutos com API lendo PRs fechados = latência, races, e um "verificador" novo que o OPS71 eliminou de propósito; PAT + evento nativo é mais simples e imediato.
**Opções consideradas (recuperação manual):** A | B
**Recomendação:** **A — `workflow_dispatch` com input `pr`** (o script já aceita `PR_BODY`; o step de dispatch busca body+merged via `gh` e repassa). B (rodar o script local com `PR_BODY`) é o fallback final do runbook, sem YAML novo.
**Rejeitadas:** C — GitHub App só para isso (peso de registrar app; o repo já usa PATs).
**Opções consideradas (410):** A | B
**Recomendação:** **A — 410 entra no retry do `forgejo-api.mjs` para QUALQUER método** (não só GET): 410 vem do proxy (nginx/Cloudflare) — a origem **nunca viu** o request, então retentar escrita não duplica efeito colateral (diferente de 5xx, onde a origem pode ter aplicado o write — esse continua fail-closed). Corpo não-JSON vira marker compacto no erro.
**Rejeitadas:** B — 410 só no GET (o flip faz `removeLabels` GET → `addLabels` POST → `addComment` POST; um 410 no POST continuaria quebrando o flip apesar da classificação "transitório conhecido").
### Componentes / mudanças
- **`.github/workflows/agent-pr-ready-automerge.yml`**: passa `AUTOMERGE_PAT: ${{ secrets.AUTOMERGE_PAT }}` ao step; comentário do porquê (anti-recursão).
- **`scripts/github-pr-automerge.mjs`**: **fail-closed** — se `AUTOMERGE_PAT` ausente, `exit 1` com mensagem nomeando o secret e o motivo (nunca fallback silencioso para GITHUB_TOKEN); `enableAutoMerge` passa a usar `createApi({ token: AUTOMERGE_PAT })` (o `markPullRequestReady` continua com o token do run — PATCH draft:false não precisa gerar evento: nada consome `ready_for_review` além do próprio workflow, e o CI corre no `synchronize` do push do agente).
- **`.github/workflows/issue-done-on-main-merge.yml`** + **`plan-issue-ready-on-main-merge.yml`**: `on: workflow_dispatch` com input `pr` (required); job `if:` = evento (`closed` + merged + main + same-repo) **ou** dispatch com input; permission `pull-requests: read` (gh); step de dispatch: `gh pr view` → valida `merged == true` + `baseRefName == main` + `headRepository.nameWithOwner == github.repository` (mesmo contrato do evento — um dispatch num PR de fork/aberto é skip) → `PR_BODY` via `GITHUB_OUTPUT`; step do node usa `steps.pr.outputs.* || github.event.pull_request.*` (um caminho só, sem override vazio). O script já no-ops sem `Closes`/`Related`.
- **`scripts/forgejo-issue-transition.mjs`**: no loop do flip, após labels+comentário → **`api.closeIssue(number)`** (era-Forgejo o close era nativo do merge do PR; era-GitHub o PR não toca o tracker — sem o PATCH toda Issue mergeada fica `OPEN` com `done`+`in-prod`). Falha de close = `flipFailed` (OPS61: vermelho, nunca mentir). Comentário ganha menção ao close.
- **`scripts/lib/forgejo-api.mjs`**: `RETRYABLE_BEFORE_ORIGIN = new Set([410])` retentado em qualquer método (comentário: proxy respondeu — origem intocada, sem risco de efeito duplicado; 5xx segue GET-only); erro `!response.ok` com corpo HTML → marker compacto `corpo não-JSON (proxy/nginx — HTTP <status>): <120 chars>` em vez do slice cru.
- **`scripts/lib/github-api.mjs`**: **intocado** (o 410 observado é do proxy do Forgejo; github-api não recebe o mesmo tratamento por escopo — política documentada nos headers de ambos).
- **`docs/AGENT-OPS.md`**: tabela de workflows (rows dos dois flips ganham "dispatch manual de recuperação"), tabela de secrets (`AUTOMERGE_PAT`), parágrafo do flip com o porquê bot-merge não cria run + runbook de recuperação (dispatch com número do PR; fallback local `PR_BODY`).
- **`AGENTS.md`**: frase curta no parágrafo do OPS71 (auto-merge armado com `AUTOMERGE_PAT` — merge por bot não cria runs).
- **Migration:** sem migration. **Access/Consent:** n/a. **UI:** n/a.
- **Changelog:** `docs/changelog/2026-08-20-ops71-flip.md` + `pnpm changelog:build`.
## Fases verificáveis
1. **Lib + script do flip** — `forgejo-api.mjs` (410 + closeIssue + marker HTML), `forgejo-issue-transition.mjs` (close). Unit specs novas em `tests/unit/forgejoApi.unit.spec.ts` (410 GET retry, 410 write retry, 410 esgotado → marker HTML, closeIssue PATCH pin). Verificação: `pnpm test` (spec), `pnpm exec tsc --noEmit`, lint/format.
2. **Auto-merge com PAT** — `github-pr-automerge.mjs` fail-closed + workflow env. Verificação: tsc/lint; comportamento real no próprio PR desta entrega (CI verde → auto-merge armado com o PAT).
3. **Workflows de recuperação** — `workflow_dispatch` nos dois flips. Verificação: YAML válido (CI do PR), gate do humano.
4. **Gates + docs + changelog** — `pnpm gate:fast`; `pnpm changelog:build`; AGENT-OPS/AGENTS.
5. **Pós-merge (validação ao vivo, humano):** criar o secret `AUTOMERGE_PAT` **antes** do merge deste PR (senão o auto-merge do próprio PR falha fail-closed); conferir o flip da #119 (run criado pelo `closed` do merge — agora atribuído ao humano) e, como recovery proof, disparar o dispatch manual do `issue-done` uma vez.
## Rabbit holes / Não escopo (engenharia)
- Retry 410 no `github-api.mjs` (simetria): sem evidência de 410 no GitHub API; política documentada, gatilho = ocorrência real.
- GitHub App dedicado para o merge (B do fix de raiz): rejeitado no gate — PAT fine-grained basta.
- `agent-promote-related-on-merge.mjs` (`setLabels` do `agent-forgejo.mjs`): intocado — só o workflow dele ganha o dispatch.
- Poller/`schedule` de verificação de merges (C): rejeitado — o evento nativo + dispatch cobre.
- Drift do `ci-pr.yml` / branch protection: fora de escopo.
## Riscos e mitigação
- **Secret `AUTOMERGE_PAT` ainda não existe no merge deste PR** → o fail-closed derruba o auto-merge de todo PR até o humano criar. Mitigação: passo 5 da fase pós-merge declara criar o secret **primeiro**; sem o secret, merges manuais continuam funcionando (o bot não arma, o humano mergea — `closed` por humano gera runs, comportamento #742).
- **Fine-grained PAT sem permissão de merge** → `enablePullRequestAutoMerge` GraphQL falha → job vermelho (visível, não silencioso). Mitigação: runbook documenta escopos (`pull-requests: write` + `contents: write` no repo fsolla/teqo).
- **Dispatch manual num PR não mergeado / fork** → skip (validação `merged` + `baseRefName` + `headRepository` no step), mesma semântica do evento.
- **`gh` no runner hosted**: pré-instalado no `ubuntu-latest`; `GITHUB_TOKEN` injetado automaticamente; sem dependência nova.
## Aceite de engenharia
- [x] Aceite de produto da intenção ainda coberto (flip sempre acontece; falha é visível; recovery existe)
- [x] Invariantes AGENTS/engineering-standards (fork guard nos dois workflows, OPS61 exit-1, zero-dep plain Node, DRY <3 call sites)
- [x] Testes de domínio previstos (unit: retry 410 GET/write, marker HTML, closeIssue, fail-closed PAT)
- [x] Self-score decision-quality: 5/5 (decisões caras com rejeitadas; abordagem no appetite; rabbit holes nomeados; depth check ok — reusa createApi/PR_BODY; intenção preservada)

View file

@ -1,135 +0,0 @@
# Impl: Runner self-hosted do GitHub no homeserver (deploy do dispatch manual) + CURSOR_API_KEY
Status: aguardando aprovação
Atualizado em: 2026-08-19
Issue: #113
Intenção: o body da Issue #113 (OPS71-INFRA) é a spec — sem plano de intenção separado
Appetite: horas de operação (não é Issue de código — nada de schema/UI/access)
## Leitura da intenção
- **Outcome:** o job `deploy` do `.github/workflows/deploy.yml` (dispatch manual)
roda no **runner self-hosted do GitHub instalado no homeserver** (labels
`self-hosted` + `homeserver`), executando `scripts/deploy-homeserver.sh <sha>`
localmente (sem SSH). O hosted (`verify`) nunca toca o homeserver.
- **O que NÃO negociar:** runner só no `deploy.yml` (nunca no `ci-pr.yml`);
usuário com docker group + leitura de `~/stack/`; outbound-only (funciona
atrás do Cloudflare tunnel); registro do runner em infra-solla `STATE.md`.
- **Já validado na sessão (pre-flight):**
- SSH ao homeserver OK (LAN `192.168.15.142`, user `fsolla`).
- `fsolla` tem `docker` no groups + `sudo`; lê `~/stack/teqo-1313.env` e
`~/stack/.env` (modo 600, dono = fsolla). Arch `x86_64` → tarball linux-x64.
- **Runner ainda NÃO existe** no homeserver (sem `~/actions-runner`, sem unit
systemd do runner; o `runner-watch.*` é outra coisa — WoL/shutdown da
workstation).
- **PAT do OPS71 funciona** (local, `~/.bashrc`): `GET /repos/fsolla/teqo` 200
e `POST …/actions/runners/registration-token` 201 → admin no repo OK.
- Versão estável atual do runner: **v2.336.0** linux-x64, 226 MB,
sha256 `04cf0be1aff4c3ec3554466c39124ca250e3effd8873bb7e8d68535aa9505d5d`
(digest da API de releases do GitHub).
- **Linger do homeserver já ativo** (`loginctl show-user fsolla` →
`Linger=yes`) → systemd **user** funciona sem sudo (o `runner-watch` já é
user service persistente lá — prova do padrão).
- Workspace `~/teqo-deploy` existe mas ainda aponta origin → Forgejo
(`http://localhost:3000/…`): o `set-url` idempotente do
`deploy-homeserver.sh` re-aponta para `TEQO_REPO_URL` (GitHub) no primeiro
deploy — nada a fazer, mas é o que o log do primeiro deploy deve mostrar.
## Abordagem recomendada
Sequência **operacional no homeserver** (nenhum commit no repo teqo — o código
do OPS71 já foi mergeado; o único artefato commitado desta Issue é o registro no
`STATE.md` do infra-solla, repo separado):
1. **Download** (homeserver, `~/actions-runner`): tarball v2.336.0 linux-x64 da
release oficial, **sha256 conferido** contra o digest da API de releases.
2. **Registration token** (workstation, PAT local): `POST
/repos/fsolla/teqo/actions/runners/registration-token` (TTL 1 h — gerar e
configar na sequência, um único passo encadeado).
3. **config.sh** (homeserver, user fsolla):
`./config.sh --url https://github.com/fsolla/teqo --token <token> --labels
self-hosted,homeserver --name teqo-1313-runner --work _work`.
4. **svc.sh como systemd USER** (`./svc.sh install` sem root → unit
`~/.config/systemd/user/actions.runner.fsolla-teqo-1313-runner.service`) +
`systemctl --user enable --now …` (start). Linger já ativo → persiste sem
login; roda como fsolla (docker + `~/stack` envs).
5. **Verificação do runner**: `systemctl --user status …` ativo; runner listado
em `GET /repos/fsolla/teqo/actions/runners` como online.
6. **Registro em infra-solla** (`~/Code/infra-solla/STATE.md`): entrada na
seção do Teqo + nota na tabela de topologia (runner GitHub no homeserver,
labels, dir, systemd user) — e no runbook `docs/ops/teqo-1313-deploy.md` do
teqo (a tabela "Onde roda cada coisa" já o menciona prospectivamente; só
confirmar o texto com o estado real). Changelog `docs/changelog/2026-08-20-ops71-infra.md` + `pnpm changelog:build`.
7. **Aceite (verificação ao vivo)**: dispatch manual do `deploy.yml` (ref main)
→ `verify` full verde → job `deploy` roda no runner do homeserver → log com
o `set-url` do workspace (Forgejo → GitHub) + smoke ok → **segundo dispatch
do mesmo SHA** → guard "already deployed".
**Decisões:**
- **systemd user + linger (já ativo) vs. `svc.sh install` via root:** user — sem
senha sudo interativa, mesmo padrão do `runner-watch` (persistência provada no
próprio homeserver); o unit system-level não adicionaria nada (o unit user
roda com `User=fsolla` de qualquer forma).
- **`~/actions-runner` + workdir `_work`:** convenção oficial; `~/teqo-deploy`
permanece como workspace do deploy (o script seta `TEQO_REPO_URL` nele).
- **Sem mudanças em código/workflows:** `runs-on: [self-hosted, homeserver]`
já está no `deploy.yml`; o `deploy-homeserver.sh` já tem o `set-url`
idempotente. O runner vem do GitHub com o token de registro — sem secrets
novos no repo (o repo é público; as envs de produção ficam no homeserver).
**Rejeitadas:**
- **`svc.sh install` como root** — exigiria sudo interativo; nada ganho sobre o
unit user (mesmo usuário efetivo, linger já garante auto-start no boot).
- **Rodar o runner em container/docker** — complexidade extra; o padrão oficial
do GitHub é o binário com svc; o runner-watch local prova o user service.
- **Instalar agora o helper `archive-cursor-agent` / criar o secret
`CURSOR_API_KEY` no GitHub** — item secundário da Issue, dormente (pool morto,
OPS65): a key não existe no ambiente; fica **registrado como pendência** no
fechamento (pedir a key ao humano quando quiser reativar o helper), sem ação
agora.
## Fases verificáveis
1. Runner baixado + sha256 conferido.
2. Runner configurado (`.runner` + `.credentials` no `~/actions-runner`),
labels corretas, nome `teqo-1313-runner`.
3. Unit systemd user ativo e **persistente** (`systemctl --user is-enabled …`),
runner online na API do GitHub.
4. `STATE.md` (infra-solla) + runbook + changelog registrados.
5. Dispatch manual de validação (verify → deploy → already-deployed no segundo
dispatch) — **aceite final, exige o humano** (dispara no GitHub UI).
## Rabbit holes / Não escopo (engenharia)
- Não re-engenharia do `deploy-homeserver.sh` (fluxo compose/migrate intacto).
- Não mexer no stack/registry/env do homeserver.
- Não criar secret `CURSOR_API_KEY` (dormente — só com a key do usuário).
- Não editar o `ci-pr.yml` nem a branch protection.
## Riscos e mitigação
- **Token de registro expira em 1 h** → o `config.sh` roda na mesma sequência
do POST (script encadeado da workstation → homeserver).
- **`svc.sh install` gerar unit system em vez de user** (se detectar root):
rodar sem sudo garante o user unit; se algo criar system unit, desfazer com
`svc.sh uninstall` e repetir como user.
- **Runner offline no dispatch** → falha conhecida já documentada no runbook
("job deploy nunca roda / runner offline"): checar `systemctl --user status`,
o runner-watch local não cuida deste runner (não é o forgejo-runner).
- **verify full ~50 min hosted antes do deploy** → aceite da Issue é um ciclo
completo; nada do homeserver é tocado até `verify` verde.
- **Rollback**: `~/actions-runner/./svc.sh uninstall` + `rm -rf ~/actions-runner`
(desregistra no GitHub); o deploy volta a ficar indisponível até o runner ser
religado — comportamento conhecido, documentado no runbook.
## Aceite de engenharia
- [ ] Aceite de produto da intenção coberto (deploy self-hosted no homeserver,
verify hosted nunca toca o homeserver)
- [ ] Runner instalado como fsolla (docker + `~/stack`), systemd user com
linger, outbound-only
- [ ] Registrado em infra-solla `STATE.md` + runbook + changelog
- [ ] Validação ao vivo: dispatch manual → deploy no runner → "already
deployed" no segundo dispatch

View file

@ -1,76 +0,0 @@
# Impl: E2E local apenas para os testes afetados — CI de PR sem e2e full
Status: aprovado
Atualizado em: 2026-08-19
Issue: #98
Intenção: docs/plans/ops72-e2e-local-afetado-ci-sem-full.md
Appetite restante: ~0,5–1 dia eng (herdado)
## Leitura da intenção
- **Outcome:** (1) skill `work-issue` (e irmãs do fluxo) exige que o agente rode localmente **os e2e que criou + os da mesma superfície afetada** — **discricionário**: o agente decide quais; `pnpm test:e2e:affected` é a ferramenta, não um gate mecânico; (2) CI de PR roda o **blast-radius** (e2e que o classifier detecta como tocados — modo `selected`, nunca `full`); (3) e2e **full** roda na **main, antes do deploy** (`verify` do deploy manual — rede de segurança pré-publicação); (4) limitação da #72 documentada na skill.
- **O que NÃO negociar:** full no deploy/verify de main; gate local (`gate:push`) sem e2e (passo explícito da skill, não hook); mecânica do classifier/manifest intocada; CI selected (blast radius) mantido como rede — o local é discricionário por design, o CI não.
- **O que reavaliar:** hipótese de "`.github/workflows/ci.yml` (verificador de main)" — **não existe mais**: OPS71 eliminou o `ci.yml`; a suíte full vive só no job `verify` do `deploy.yml` (confirmado). A hipótese do ci-pr.yml já ter os dois steps e2e (`full` + `selected`) dirigidos por `ci-scope.mjs` se confirma — o trabalho é **remover o full** e reorientar o texto.
## Abordagem recomendada
```mermaid
flowchart LR
A[Diff do PR] --> B[ci-scope.mjs / e2e-affected.mjs]
B -->|selected| C[CI PR: E2E blast-radius manifest-selected specs]
B -->|full| D[CI PR: sem e2e - full vive no verify do deploy]
B -->|none| E[CI PR: sem e2e - sem blast radius]
F[Agente antes do push] --> G[e2e local dos specs criados + mesma superfície - discricionário]
G -->|decisão do agente| H[pnpm test:e2e:affected ou specs diretas]
I[main antes do deploy] --> J[verify: e2e full]
```
**Opções consideradas:** A | B | C
**Recomendação:** A — remover o step e2e `full` do `ci-pr.yml`; quando o classifier emitir `full` (diff high-risk: schema/lockfile/harness), o CI de PR simplesmente não roda e2e (o step `selected` tem `if: e2e_mode == 'selected'` e é pulado; build-for-e2e segue rodando). O espelho local (`run-e2e-affected.mjs`) roda **full localmente** nesse mesmo caso — mesma mecânica, sem gap: o único caminho para "full" é diff high-risk, e aí o agente viu o full na máquina dele e o `verify` do deploy roda full antes de publicar. Ciência: um diff high-risk sem e2e no CI é um risco deliberado (rabbit hole "full só no deploy cria gap de regressão" já aceito no gate).
**Rejeitadas:** B — manter o step `full` no CI (status quo): viola o gate "nunca full por PR" e devolve o custo 40+ min por PR de infra. C — mapear `full` → falha/aviso no CI: criaria bloqueio artificial em PRs legítimos de infra/schema (o diff high-risk é exatamente o que o local roda full); o verify do deploy já é o gate honesto.
### Componentes / mudanças
- **`.github/workflows/ci-pr.yml`**: remover o step `E2E tests (full suite, single job, 4 workers)`; atualizar o comentário do header ("e2e policy: selected (affected) specs on PRs — never full; full lives in deploy verify — OPS72"); o step `E2E tests (manifest-selected specs)` fica funcionalmente intacto.
- **`scripts/gate-ci.mjs`**: mensagens informativas do escopo e2e (texto-only, ~linhas 153–162) — "e2e verified in CI" vira "e2e selected no CI (PR); full só no `verify` do deploy; local: `pnpm test:e2e:affected` (passo da skill)". Comportamento intocado (e2e fora do gate — aceite). Mensagens não são pinadas por unit tests (verificado).
- **`.agents/skills/work-issue/execution-pipeline.md`** (corpo compartilhado das duas skills): seção "Executar" item 3 (Gates) ganha o passo **e2e local (discricionário)** antes do `pnpm push`; nova seção curta "E2E local afetado" documentando: o agente roda **os specs e2e que criou + os da mesma superfície afetada**, decidindo quais (discricionário); a ferramenta (`pnpm test:e2e:affected` — espelho do classifier; ou specs diretas via `--no-deps`), e a **limitação da #72** (`--no-deps` + projetos paralelos colidem no `seedTestUser` → `--workers=1` ou a cadeia padrão de projetos; só conveniência local, CI é sequencial por construção).
- **`.agents/skills/work-issue/SKILL.md`**: checklist item 4 e Passo 4 ganham o passo (humano): "rode localmente os e2e que você criou + os da mesma superfície afetada — você decide quais (`pnpm test:e2e:affected`)".
- **`.agents/skills/agent-work-issue/SKILL.md`**: delta pool — mesmo passo **quando o worktree tiver browsers Playwright**; Cursor Cloud sem browser → registra justificativa e segue (CI selected é a rede) — opção B da intenção com registro, não silêncio.
- **`AGENTS.md`**: reescrever o parágrafo "O e2e não roda mais no gate local (OPS59)" → política OPS72: e2e afetado local = passo obrigatório da skill; CI de PR = selected; full = `verify` do deploy manual.
- **`docs/AGENT-OPS.md`**: item "Agente faz sozinho" ganha o passo e2e local afetado; linha do `ci-pr.yml` na tabela CI passa a "e2e selected (nunca full — OPS72)".
- **`.agents/rules/agent-pr-workflow.mdc`**: linha 41 (`pnpm gate:push` descrição) reflete a política nova.
- **Migration:** sem migration. **Access / Consent:** n/a. **UI:** Impeccable A — sem UI.
- **Changelog (OPS44):** `docs/changelog/2026-08-19-ops72.md` + `pnpm changelog:build` + `changelog:check`.
### Dados → forma (se aplicável)
- N/a — não apresenta dados; apenas política/CI/skills.
## Fases verificáveis
1. **CI** — `.github/workflows/ci-pr.yml`: step full removido + comentários; `scripts/gate-ci.mjs`: mensagens (texto-only). Verificação: `pnpm exec prettier --check` nos arquivos; diff limpo.
2. **Skills** — `execution-pipeline.md` (passo + seção #72), `work-issue/SKILL.md`, `agent-work-issue/SKILL.md`.
3. **Docs** — `AGENTS.md`, `AGENT-OPS.md`, `agent-pr-workflow.mdc`.
4. **Changelog** — entrada OPS72 + build + check.
5. **Gates** — `pnpm gate:fast`; `pnpm format:check`; `pnpm test:unit --changed` (nenhum pin quebra — mensagens de console não são pinadas); e2e local afetado: o diff toca `scripts/gate-ci.mjs` (HIGH_RISK) → modo `full` → suíte full rodada (154 passed em dev; 8 flakes de ambiente dev triados — passam em `E2E_PROD=1`, o modo do CI); CI do PR não roda e2e (full por design desta política); `pnpm push`; PR Ready `Closes #98`.
## Rabbit holes / Não escopo (engenharia)
- Não tocar `ci-scope.mjs`, `e2e-affected.mjs`, `e2e-affected-manifest.mjs`, `test-affected-core.mjs`, `run-e2e-affected.mjs` — mecânica funciona; só muda onde/em que modo roda (fora de escopo da intenção).
- Não implementar o fix da #72 (fica na fila; só documentação).
- Não adicionar e2e ao `gate:push`/`gate:ci` (aceite explícito: passo da skill, não hook).
- Não mexer no `deploy.yml` (já roda full no `verify`).
- Não criar script novo — `pnpm test:e2e:affected` já existe.
## Riscos e mitigação
- **Diff high-risk no CI sem e2e** (ex.: PR de schema): o PR não roda e2e no CI. Mitigação: espelho local roda full no mesmo diff; `verify` do deploy roda full antes de publicar; documentado no workflow e no gate-ci.
- **Agente pula o e2e local**: discricionário por design (decisão do usuário) — o agente decide quais specs rodar e pode decidir não rodar. Mitigação: CI selected (blast radius) pega a superfície quando o diff é selecionável; full no `verify` do deploy fecha o ciclo antes de publicar.
- **Mensagem do gate-ci desatualizada vs CI**: texto ajustado na mesma entrega; nada pinado.
- **Serialização**: a intenção serializa `ci-pr.yml` e `.agents/skills/work-issue/`; OPS71 já mergeou — sem concorrência pendente (OPS72 é o primeiro a tocar no ci-pr.yml novo pós-OPS71).
## Aceite de engenharia
- [x] Aceite de produto da intenção ainda coberto (e2e local discricionário documentado na skill; CI/PR = blast-radius selected, nunca full; full na main antes do deploy; #72 documentada; gate local intocado)
- [x] Invariantes AGENTS/engineering-standards (edita o dono — execution-pipeline é o corpo compartilhado; nada duplicado)
- [x] Testes de domínio previstos: nenhum pin quebra (mensagens de console não pinadas); `test:e2e:affected` com modo `none` valida o fluxo novo

View file

@ -1,146 +0,0 @@
---
id: OPS72
depends: [OPS71]
serializes: ['ci-pr.yml', '.agents/skills/work-issue/']
priority: P1
model: composer-2.5
---
# E2E local apenas para os testes afetados — CI de PR sem e2e full
Status: rascunho
Atualizado em: 2026-08-19
Issue: #98
Priority: P1
Model: composer-2.5
Impeccable: A — N/A (sem UI)
Rascunho UI: N/A — sem UI
Appetite: ~0,5–1 dia eng; um outcome verificável (skill work-issue exige e2e afetado local; CI de PR não roda e2e full)
Responsável: —
## Intenção
O e2e é a etapa mais cara do CI de PR (build `.next-e2e` + playwright +
suíte inteira). Com o CI voltando para o GitHub Actions (OPS71), o custo por
PR deixa de ser a CPU da workstation — mas o tempo de veredito por PR continua
importando: e2e full é a maior fatia do job único `checks` (OPS62), e a
política de hoje (OPS59: "e2e roda UMA vez, no CI; local opcional") significa
que cada PR paga a suíte e2e inteira, inclusive specs em superfícies que o PR
não tocou.
Queremos: **e2e no CI de PR apenas para os testes afetados** (novos testes
criados + specs da mesma superfície trabalhada — a mecânica `e2e selected` já
existe via `ci-scope.mjs`/manifest do OPS59), e o **restante da cobertura e2e
roda localmente** — também apenas nos afetados — como passo do fluxo do agente
antes do PR (atualizar a skill `work-issue` para exigir isso). O e2e **full**
fica para o **deploy manual** (workflow_dispatch do OPS71) e para o verificador
de main — como rede de segurança antes de publicar — sem nunca rodar full por
PR.
**Decisão do gate (2026-08-19):** o CI de PR **continua rodando o e2e das
áreas afetadas** (modo `selected`) — confirmado pelo usuário; o e2e local
afetado é o passo da skill; o full fica no deploy/verificador de main.
## Persona e fluxo
- **Persona / contexto:** o agente (humano no worktree ou worker do pool) que
implementa uma Issue e abre o PR; o dono da máquina que não quer o CI
ocupando o runner por 40+ min por PR.
- **Job principal:** saber que os testes novos/afetados do seu PR passam,
com custo mínimo de CI e feedback local rápido.
- **Fluxo desejado:**
1. Agente implementa; roda os gates locais de sempre (`pnpm gate:push`).
2. **Novo passo obrigatório:** roda `pnpm test:e2e:affected` (ou o modo
selecionado equivalente) — cobre os novos testes criados + specs da
mesma superfície trabalhada.
3. CI de PR roda a cascata **sem e2e full**: apenas o conjunto afetado
(modo `selected` — já suportado pelo classifier; decisão do gate).
4. Deploy manual (OPS71) e verificador de main rodam e2e **full** antes de
publicar — a rede de segurança final inegociável.
- **Anti-goals de produto:** NÃO remover o e2e do deploy/verificador de main;
NÃO deixar o agente pular o e2e local sem justificativa; NÃO reintroduzir
"e2e é só no CI" (OPS59 era isso e custa caro por PR).
## Objetivo e aceite
- Skill `work-issue` (e `execution-pipeline.md`, `agent-work-issue` onde
aplicável) passam a exigir, antes do `pnpm push`/PR: **e2e local afetado**
(`pnpm test:e2e:affected`), com o critério "novos testes + mesma superfície"
documentado.
- CI de PR: e2e roda em modo `selected` (manifest afetado) — nunca `full`. O
classificador de escopo (`ci-scope.mjs`) é a fonte da decisão.
- Deploy manual e verificador de main: e2e **full** preservado (rede de
segurança pré-deploy/pré-publicação).
- A skill `work-issue` documenta a limitação da #72 (e2e local com `--no-deps`
- projetos paralelos colide no `seedTestUser` — usar `--workers=1` ou a
cadeia padrão de projetos).
- Gate local (`pnpm gate:push`) intocado — e2e continua fora do pre-push; o
e2e local é passo **explícito do fluxo da skill**, não do hook.
## Dados (intenção)
- **Vou apresentar dados?** Não.
- **Decisões desbloqueadas:** o agente decide o conjunto afetado com base no
diff da superfície — critério documentado na skill, não métrica nova.
## Direção no codebase (hipótese)
- **Áreas prováveis:**
- `.agents/skills/work-issue/SKILL.md` e `execution-pipeline.md` — novo
passo "e2e local afetado" antes do push; delta para `agent-work-issue`
quando fizer sentido.
- `.forgejo/workflows/ci-pr.yml` → (OPS71) `.github/workflows/ci-pr.yml` —
etapa e2e apenas `selected`; remover `full` do modo PR.
- `.github/workflows/ci.yml` (verificador de main) e `deploy.yml` (dispatch
manual) — e2e `full` preservado neles.
- `docs/AGENT-OPS.md`, `AGENTS.md` (política "e2e = CI" do OPS59 vira
"e2e afetado = local + CI selected; full = deploy/verificador de main").
- **Precedente a olhar:** `docs/plans/ops59-gate-local-sem-e2e.md` (política
atual), `ci-scope.mjs` (modos `full/selected/none`), `e2e-affected-manifest.mjs`,
`docs/plans/ops62-ci-fail-fast-forgejo.md`.
- **Risco de acoplamento:** toca a mesma superfície do OPS71 (workflows + a
skill work-issue é citada pelo fluxo de PR) — por isso `depends: [OPS71]` e
serialização explícita (OPS71 serializa `ci-pr.yml` e `work-issue`).
## Dependências
- OPS71 (CI no GitHub) — este item edita o `ci-pr.yml` novo e a skill que o
OPS71 já aponta para o GitHub; `depends: [OPS71]`.
- #72 (S3-FOLLOWUP) — citada como limitação documentada; não é duplicada nem
absorvida (fica na fila).
## Fora de escopo
- Mudar a mecânica do classifier/manifest (`ci-scope.mjs`,
`e2e-affected-manifest.mjs`) — funciona; só muda onde/em que modo roda.
- Implementar o fix da #72.
- Remover o e2e do deploy manual / verificador de main.
## Rabbit holes de produto
- **"Se é afetado, nem precisa do CI e2e".** O CI selected é a rede de
segurança quando o agente erra o critério (esqueceu um spec da mesma
superfície) — e é o que o usuário pediu para manter. **Corte:** CI selected
permanece; local é complementar, não substituto.
- **"Full só no deploy cria gap de regressão".** Aceito e deliberado: o full
gateia a publicação (deploy manual) e o verificador de main; PRs afetados
cobrem a superfície do diff. **Corte:** não reintroduzir full por PR.
## Questões em aberto (produto)
- **CI de PR: e2e selected ou nenhum e2e?** **Decidido no gate (2026-08-19):**
e2e **selected** (manifest afetado) no CI de PR — o usuário confirmou
("O CI PR deve continuar fazendo o e2e das áreas afetadas").
- **O e2e local afetado vale também para o pool (`agent-work-issue`)?**
**Opções:** A) sim, mesmo passo obrigatório; B) pool roda só o CI selected
(workers Cloud sem worktree local com browsers instalados).
**Recomendação:** A quando o worktree local tiver browser; senão B com
justificativa registrada. _(assumido — validar)_
## Referências
- GitHub Issue: #98 (OPS72)
- Rascunho UI (gate): N/A
- `.agents/skills/work-issue/SKILL.md`, `.agents/skills/work-issue/execution-pipeline.md`,
`scripts/ci-scope.mjs`, `scripts/lib/e2e-affected-manifest.mjs`,
`docs/plans/ops59-gate-local-sem-e2e.md`, `docs/plans/ops62-ci-fail-fast-forgejo.md`

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

View file

@ -1,77 +0,0 @@
# Impl: Admin do Payload segue em branco em produção — re-home S8/S9 no GitHub + fix importMap + deploy + verificação
Status: entregue (deploy `dba75ff8` em prod + verificação pós-deploy completa)
Atualizado em: 2026-08-20
Issue: #114
Intenção: docs/plans/ops73-deploy-admin-prod.md
Appetite restante: herdado (~0,5 dia); ajuste: +1 PR de re-home (premissa da intenção se provou falsa)
## Leitura da intenção
- **Outcome:** `/admin` de produção renderiza o formulário de login (tela preta some); editor loga e abre uma coleção; log do container sem `getFromImportMap: PayloadComponent not found in importMap` para `@payloadcms/storage-s3/client#S3ClientUploadHandler`.
- **O que NÃO negociar:** nada de schema/migration/Consent/UI nova nesta entrega; não reabrir o diagnóstico OPS69 (#77); não introduzir features novas; o deploy é do que já está aprovado.
- **O que reavaliar (premissa FALSA):** a intenção afirma que "o fix OPS69 está em main desde 7162c65e e produção nunca o recebeu — basta deployar o main". **Verificado ao vivo (2026-08-19 23:56–2026-08-20 00:40), a premissa está errada em dois pontos:**
1. **Produção JÁ recebeu um deploy às 23:09 UTC** (revision `8c734ba`, "docs(S8)", build 23:09:47, container up 23:09:48) — mas essa revision **não tem o handler no importMap**: o commit S9 (`8d2352c0`, captura de novidades na home) regenerou o importMap **sem** as envs `S3_*` e removeu as 2 linhas do fix OPS69. O erro `getFromImportMap` persiste no log desde o start do container e `/admin` continua em branco (0 inputs/botões/links; shell HTML ok, título "Painel de Controle - Payload"). A revision ANTERIOR (`fbfbfb1`) **tinha** o handler (2 matches) — confirma o relato "funcionou e no deploy seguinte parou de funcionar de novo".
2. **O main do GitHub diverge do main do Forgejo (pós-cutover OPS71):** GitHub main = `3ea16243` (OPS69 fix com handler ✓, OPS71 infra ✓, plans S11/OPS73 ✓) mas **NÃO contém as features S8 (seção "Nossa história") e S9 (captura de novidades/newsletter)** que produção roda desde o deploy das 23:09 (árvore de 8c734ba tem `CampaignNewsletterForm.tsx`, `CampaignStorySection.tsx`, migration `20260819_192605_add_campaign_newsletter_capture`, e o GitHub main não — 404 via API; `git diff 8c734ba 3ea16243` mostra os arquivos como `D`). O Forgejo main congelou em `e339ceec` (S8/S9/S10); o workspace do homeserver ainda aponta `origin = http://localhost:3000/fsolla/teqo.git` (o Forgejo local) — por isso sua visão de "main" era o e339ceec. **Deployar o main do GitHub como está REGREDIRIA as features S8/S9 em produção** (some a seção "Nossa história" e o form de newsletter).
## Abordagem recomendada
```mermaid
flowchart LR
A[cherry-pick S9 8d2352c0 + S8 488dcf7d sobre main 3ea16243] --> B[resolver conflito importMap: manter handler]
B --> C[regen importMap com S3_* dummy: diff vazio = prova de consistencia]
C --> D[gates locais: tsc/lint/format/knip/cycles/unit/int/build]
D --> E[PR OPS73 em main com auto-merge]
E --> F[confirmar no main: handler + S8/S9 presentes]
F --> G[deploy via ssh homeserver: deploy-homeserver.sh sha]
G --> H[verificar: revision label + log + /admin renderiza + home S8/S9 + editor loga]
```
**Opções consideradas:** A | B | C
**Recomendação:** **A — PR único OPS73** que re-homa no GitHub main as features S8+S9 já aprovadas e mergeadas no Forgejo (cherry-pick dos commits de feature `8d2352c0` + `488dcf7d`), resolve o conflito do importMap **mantendo o handler** e prova consistência rodando `generate:importmap` com envs `S3_*` dummy (diff deve ser vazio) — porque: (1) o aceite "admin no ar" exige o handler no build; (2) o aceite "não regredir prod" exige S8/S9 no main antes do deploy; (3) o main do GitHub é a fonte da verdade do deploy (guard de HEAD + `TEQO_REPO_URL`); (4) cherry-pick do código já aprovado ≠ feature nova (anti-goal preservado: nada de design/schema novo).
**Rejeitadas:**
- **B — PRs separados (re-home S8, re-home S9, depois fix/deploy):** ownership nominalmente mais limpo, mas 3 PRs em fila, admin fora do ar por mais tempo e o deploy continua dependendo do último; sem ganho de risco.
- **C — deploy do main atual aceitando a regressão S8/S9:** regressão pública (seção "Nossa história" + form de novidades somem) — viola o aceite "o time editorial e de campanha volta a trabalhar" e reverteria features `in-prod` no tracker.
### Componentes / mudanças
- **`src/app/(payload)/admin/importMap.js`** (gerado, commitado): estado final = com `@payloadcms/storage-s3/client#S3ClientUploadHandler` (idêntico ao de 3ea16243 — o conflito do cherry-pick do S9 resolve mantendo o handler; a regen com envs `S3_*` dummy fecha a prova de consistência com diff vazio).
- **S9 (cherry-pick `8d2352c0`):** `CampaignNewsletterSection.tsx`, `submitCampaignNewsletter.ts`, `CampaignNewsletterForm.tsx`, `campaignConsentKeys.ts`, `schemas/campaignNewsletter.ts`, mudanças em `Contact.ts`/`Subscription.ts`/`CampaignHero.tsx`/`CitySelect.tsx`/`StateSelect.tsx`/`page.tsx`/`styles.css` + migration `20260819_192605_add_campaign_newsletter_capture` + docs/changelog. **ImportMap do commit NÃO entra (remover as -2 linhas é o que quebrou; manter o handler).**
- **S8 (cherry-pick `488dcf7d`):** `CampaignStorySection.tsx`, `page.tsx` (+2), e2e `frontend.e2e.spec.ts`/`fixtures/e2eTest.ts`, `.gitignore`, plans/changelog.
- **Migration:** o cherry-pick traz a do S9; o deploy aplica **zero migrations novas** vs prod (8c734ba já a tem aplicada — `20260819_192605` rodou no deploy das 23:09). Sem migration nova neste PR.
- **Access / Consent:** não se aplica (S8/S9 já aprovados; `campaignConsentKeys.ts` é parte do S9 já mergeado no Forgejo).
- **UI:** Impeccable A — nenhuma UI nova; features S8/S9 já aprovadas (copy aprovado nos respectivos PRs).
## Fases verificáveis
1. **Cherry-picks + conflito** — `git cherry-pick 8d2352c0` (resolver importMap: manter o handler) e `488dcf7d` sobre `origin/main` (3ea16243); conferir que o diff final vs 3ea16243 contém S8/S9 completos (sem truncamentos) e que o importMap fica com handler. (Quota: ~30 min)
2. **Prova de consistência do importMap** — `S3_BUCKET=teqo-media S3_ENDPOINT=http://localhost:3900 S3_ACCESS_KEY_ID=test S3_SECRET_ACCESS_KEY=test pnpm generate:importmap` → `git diff` do importMap deve ficar **vazio** (estado já consistente). Se aparecer drift não compreendido: parar e investigar (guard do OPS69).
3. **Gates** — `pnpm exec tsc --noEmit`, `pnpm lint`, `pnpm format:check`, `pnpm exec knip`, `pnpm check:cycles`, `pnpm test` (unit+int), `pnpm build` (DB local). E2e: no CI do PR (frontend S8/S9 specs + admin).
4. **PR → merge** — push da branch, PR para main, auto-merge (required check `CI (PR) / checks`); conferir no main pós-merge: `importMap.js` com handler, `CampaignNewsletterForm.tsx`/`CampaignStorySection.tsx` presentes, migration do S9 presente.
5. **Deploy (caminho B — runner #113 ainda não instalado)** — `ssh homeserver 'bash ~/teqo-deploy/scripts/deploy-homeserver.sh <sha-do-main>'` (o script re-pointa o origin do workspace para o GitHub, guard de HEAD, flock, build migrator → migrate (no-op) → build runner → up → healthcheck → smoke). Executado pelo agente via SSH (acesso validado: `ssh homeserver` ok, docker ok, envs em `~/stack/teqo-1313.env`), com o humano acompanhando; alternativa: humano roda o mesmo comando. ~15–20 min.
6. **Verificação pós-deploy** — (a) `docker inspect` revision label == sha deployado; (b) `docker logs teqo-1313 --since <start>` sem `getFromImportMap` para o handler (aceite #3 da intenção); (c) `/admin` de prod renderiza o formulário de login no browser (Playwright + screenshot → `docs/plans/ops73-deploy-admin-prod-fixed-evidence.png`); (d) home pública mantém seção "Nossa história" (S8) e form de novidades (S9); (e) **editor loga e abre uma coleção — passo manual do humano** (credenciais de prod não ficam com o agente).
7. **Fechamento** — changelog `docs/changelog/2026-08-20-ops73.md` + `pnpm changelog:build`; comentários na Issue #114 com evidência; reforçar no AGENTS.md a lição do S9 (regen sem envs re-órfão o handler) e a necessidade do guard #87; registrar débito da divergência de plataformas (workspace homeserver aponta para Forgejo local; S10 re-home pendente — S10 NÃO está em prod nem no GitHub main, e sua Issue está in-prod no tracker).
## Rabbit holes / Não escopo (engenharia)
- **Arqueologia do rewrite do main (S10 force-push/rebase sobre o Forgejo):** a divergência das plataformas está documentada acima; reconstituir quem-mergeou-onde não muda o plano. Corte: verificação por conteúdo (API + árvore), não por linhagem.
- **Re-home do S10 (pixel):** S10 não está em prod (8c734ba só tem o `facebookPixel.ts` antigo do petition pages, `be1106f8`) nem no GitHub main — é PR próprio, fora deste escopo; registrar como débito.
- **Instalar o runner (#113)/deploy automático:** fora de escopo (a intenção já corta; caminho B é o que desbloqueia hoje).
- **Guard de CI do importMap (#87):** segue pendente; este incidente (regen do S9) é a segunda ocorrência da mesma classe — reforça a prioridade, não muda o escopo.
## Riscos e mitigação
- **Main do GitHub anda durante o trabalho (agentes paralelos S10/S11):** re-verificar `git ls-remote origin main` antes do cherry-pick, antes do push e antes do deploy (o guard do script re-checa). Se o main mudar, rebase e re-valida.
- **Conflito no cherry-pick além do importMap (page.tsx, Contact.ts, etc.):** base do S8/S9 = 693d2e48 (comum aos dois mains); o delta GitHub (OPS69/OPS71/plans) não toca os arquivos do S9 exceto importMap — conflitos esperados só no importMap; resolver mantendo o handler.
- **Deploy falhar no meio:** rollback automático do script (restore do compose + up) + runbook `docs/ops/teqo-1313-deploy.md`; zero migrations novas → janela de rollback limpa.
- **Admin continuar branco pós-deploy (fix insuficiente):** verificação por browser na fase 6 é o gate; se persistir, o erro no log apontará o próximo passo (o importMap está correto no main — cenário improvável, mas o log decide).
- **Screenshot de evidência sem ferramenta de visão:** usar Playwright (DOM: inputs/buttons > 0) + `design-vision` para descrever a imagem.
## Aceite de engenharia
- [ ] Aceite de produto da intenção coberto: `/admin` renderiza login em prod; log sem o erro do handler; editor loga e abre coleção (humano)
- [ ] Sem regressão: S8/S9 presentes no main do GitHub e servidos em prod (história + newsletter na home)
- [ ] Invariantes AGENTS/engineering-standards: nada de schema/Consent/UI novo; importMap é arquivo gerado e commitado; gates verdes
- [ ] Prova de consistência do importMap: regen com envs `S3_*` dummy → diff vazio
- [ ] Changelog + Issue #114 documentada com evidência (antes/depois); débitos registrados (S10 re-home, guard #87 reforçado, workspace homeserver origin)

View file

@ -1,74 +0,0 @@
# Admin do Payload segue em branco em produção — deploy do fix OPS69 + verificação pós-deploy
Status: rascunho
Atualizado em: 2026-08-19
Issue: #114
Priority: P1
Model: composer-2.5
Impeccable: A — sem mudança de UI (deploy do que já está em main)
Rascunho UI: N/A
Appetite: ~0,5 dia — deploy + verificação funcional; um outcome verificável
Responsável: —
## Intenção
O time editorial e de campanha está **sem CMS**: `/admin` em produção (jorgesolla1313.com.br) abre em branco (tela preta) — a página carrega, o shell hidrata, mas o formulário de login nunca renderiza. O bug raiz já foi diagnosticado e corrigido em `main` pelo **OPS69** (Issue #77, fechada): o `importMap` commitado ficou sem a entrada `@payloadcms/storage-s3/client#S3ClientUploadHandler` e o admin não monta as views quando o plugin `s3Storage` está ativo (envs `S3_*` de produção). O fix está em main desde `7162c65e` (2026-08-19).
**Mas produção nunca recebeu o fix.** O container roda um build de 2026-08-18 21:49 (`e6bd1d26`), anterior ao merge: o deploy automático antigo morreu (#85 OPS70 — runs agendados skipped) e o caminho novo (dispatch manual do `deploy.yml` no GitHub Actions) depende do runner self-hosted no homeserver, que ainda não está instalado (#113 OPS71-INFRA). A verificação pós-deploy do OPS69 (aceite do próprio plano) nunca rodou.
Verificado ao vivo em 2026-08-19: `/admin` de produção renderiza o shell vazio — **0 inputs, 0 botões, body sem texto** — mesmo sintoma do diagnóstico OPS69. `main` no GitHub hoje (`f5862c9b`) contém o fix.
## Persona e fluxo
- **Persona / contexto:** editor de conteúdo do mandato/campanha (staff que alimenta notícias e campanhas pelo Painel de Controle), bloqueado há ~1 dia.
- **Job principal:** abrir o admin, logar e voltar a editar publicações — sem depender de infra nova.
- **Fluxo desejado:** abre `jorgesolla1313.com.br/admin` → formulário de login aparece (não mais tela preta) → credenciais → dashboard com as coleções → consegue abrir uma coleção.
- **Anti-goals de produto:** não rediagnosticar o bug (OPS69 já fechou o diagnóstico com evidência); não reabrir a Issue #77; não entregar nenhuma mudança de código nova — só publicar o que já está aprovado em main.
## Objetivo e aceite
- `/admin` em produção renderiza o formulário de login para quem tem credencial (tela preta some).
- O container de produção roda uma revisão que contém o fix do importMap (`7162c65e` ou posterior — HEAD atual `f5862c9b`).
- O log do container de produção não re-emite `getFromImportMap: PayloadComponent not found in importMap` para o handler do storage.
- Um editor loga e abre uma coleção sem editar nada (smoke mínimo de leitura).
- Guardrail: nada de schema/migration/Consent/UI nesta entrega — é o deploy do que já está em `main`.
## Dados (intenção)
- **Vou apresentar dados?** Não — a verificação é funcional (render do login + log do container), nenhum número novo.
## Direção no codebase (hipótese)
- **Áreas prováveis:** `scripts/deploy-homeserver.sh` (o script de deploy — fonte da verdade do fluxo, roda no homeserver), `.github/workflows/deploy.yml` (dispatch manual `verify` → `deploy`), runbook `docs/ops/teqo-1313-deploy.md`, `src/app/(payload)/admin/importMap.js` (o fix já commitado — conferir a revision do container, não o arquivo).
- **Precedente a olhar:** OPS69 (`docs/plans/ops69-admin-branco-importmap-s3*.md`, #77) — aceite de verificação pós-deploy pendente; OPS71 (cutover do deploy) e #113 (runner); OPS66 (ordem migrate→build já resolvida no script).
- **Risco de acoplamento:** o deploy de `main` leva junto outras mudanças já aprovadas (ex.: S7, cards da home) — esperado e desejado (deploy do HEAD); o smoke do script cobre `/`, `/campanha/login`, `/admin`; rollback = restore do compose (runbook).
## Dependências
- Nenhuma dura. #113 (runner self-hosted) é o habilitador do caminho A, não pré-requisito — o caminho B desbloqueia sem ele.
## Fora de escopo
- Rediagnóstico do bug e guard de CI do importMap — #87 OPS70 já na fila (P2).
- Instalação do runner self-hosted do GitHub — #113 (Issue própria).
- Deploy contínuo automático — decisão OPS71 é deploy manual; #85 OPS70 (pipeline antigo) parece obsoleto pós-cutover — sugestão de fechamento à parte, não escopo daqui.
## Rabbit holes de produto
- **"A tela preta voltou → reabrir o diagnóstico."** Se alguém só completar o desejo: regressar ao OPS69. **Corte neste item:** conferir a revision do container antes de qualquer diagnóstico — a evidência (build de 2026-08-18 sem o fix, main com o fix desde 2026-08-19) já explica o sintoma.
- **"Já que vou mexer, instalo o runner/automatizo tudo."** **Corte:** #113 (runner) e #87 (guard) são Issues separadas; este item entrega admin no ar.
## Questões em aberto (produto)
- **Caminho do deploy?** **Opções:** A) dispatch manual do `deploy.yml` no GitHub Actions (verify full ~50 min → deploy no runner do homeserver — exige instalar o runner, #113, antes); B) executar `scripts/deploy-homeserver.sh <sha>` direto no homeserver (mesmo script idempotente que o runner rodaria; smoke + rollback documentados; não espera #113); C) instalar o runner primeiro e depois A. **Recomendação:** **B agora** — admin caído = CMS indisponível; o script é o mesmo e o runbook cobre smoke e rollback; depois, o primeiro dispatch via A valida o pipeline OPS71. _(assumido — validar com produto)_
- **Prioridade?** **Opções:** P0 (mesma dor do incidente original) | P1 (o fix já existe em main; resta entrega + verificação). **Recomendação:** **P1** — claimável já, sem precisar de re-P0 da fila. _(assumido — validar com produto)_
- **Quem toca o homeserver?** O agente não tem acesso ao homeserver nem ao GitHub para dispatch sem ação do operador. **Opções:** o executor prepara os comandos do runbook e o humano roda (2 passos, ~5 min) | o executor dispara via GitHub API (`GITHUB_TOKEN` disponível) quando o runner estiver em pé (#113). **Recomendação:** humana para o caminho B imediato; o agente executa o restante (conferir SHA, verificar `/admin` no browser, checar log) e registra a evidência. _(assumido — validar com produto)_
## Referências
- OPS69: `docs/plans/ops69-admin-branco-importmap-s3.md` + `-impl.md`, Issue #77 (fechada — fix `7162c65e` em main)
- Runbook de deploy: `docs/ops/teqo-1313-deploy.md` (smoke, rollback, falhas conhecidas)
- OPS71 cutover: `docs/plans/ops71-ci-github-actions-tracker-forgejo.md`; runner: #113; guard de importMap: #87; pipeline antigo morto: #85
- Evidência (gate): `docs/plans/ops73-deploy-admin-prod-evidence.png` — `/admin` de produção em 2026-08-19 (shell vazio, sem login)
![Admin em branco em produção — 2026-08-19](ops73-deploy-admin-prod-evidence.png)

View file

@ -1,42 +0,0 @@
# Follow-up pós-OPS73 — re-home S10 no GitHub, origin do workspace homeserver + fix da race de seedUser do e2e
Status: rascunho
Atualizado em: 2026-08-20
Issue: OPS73-FOLLOWUP (depends #114)
Intenção: Origem — débitos colhidos no fechamento do OPS73 (#114)
Appetite: ~0,4 dia (fase 1 dominante)
## Contexto
O OPS73 revelou que o cutover OPS71 **divergiu as plataformas**: o Forgejo main congelou em `e339ceec` (com S8/S9/S10) e o GitHub main recebeu OPS69/OPS71/OPS74 — as features S8/S9 foram re-homadas no OPS73 (PR #751), mas o **S10 (pixel Meta) não**. Além disso, duas pendências de infra/teste apareceram no ciclo.
## Já resolvido (não reabrir)
- Re-home do S8 (488dcf7d) e S9 (8d2352c0) no GitHub main → **PR #751** (merged `624126da`/rebase `dba75ff8`), admin fix + deploy verificado.
- ImportMap com handler S3 no main (regen com envs dummy — diff vazio).
## Fases (mesma família: infra/deploy pós-cutover)
### F1 — Re-home do S10 (pixel Meta) no GitHub main + deploy
O S10 está `done`+`in-prod` no tracker (#94, PR #109 merged Forgejo `e339ceec`), mas o código **não está no GitHub main nem em prod** (a revision deployada `dba75ff8` não tem o pixel; `8c734ba` só tem o `facebookPixel.ts` antigo do petition, `be1106f8`). Cherry-pick do commit S10 (`e339ceec`) sobre o main do GitHub (mesma mecânica do OPS73), revisar a migration `20260819_213947_add_site_settings_facebook_pixel_id` (aditiva), rodar gates + e2e e deployar. Verificar `/campanha` com `SiteSettings.facebookPixelId` configurado → `#meta-pixel-*` presente.
### F2 — Apontar o origin do workspace do homeserver para o GitHub
`~/teqo-deploy` ainda tem `origin = http://localhost:3000/fsolla/teqo.git` (Forgejo local). O `deploy-homeserver.sh` já re-aponta para `TEQO_REPO_URL` (https://github.com/fsolla/teqo.git) a cada deploy (idempotente), mas o estado depois de um deploy devia ficar GitHub. Ação: `git -C ~/teqo-deploy remote set-url origin https://github.com/fsolla/teqo.git`. Sem code change.
### F3 — Race de `seedTestUser` no e2e full do GitHub CI
`tests/helpers/seedUser.ts` faz delete-then-create de `dev@payloadcms.com`. Com a suíte full no CI do GitHub (4 workers paralelos; `frontend`, `admin` e `campaignNewsletter` specs chamam `seedTestUser`), dois workers podem intercalar `delete`/`create` → `duplicate key value violates unique constraint "users_email_idx"` reportado como `ValidationError: email`. Mitigar: `on conflict` na criação ou serializar o seed (blocking lock), ou uma chamada com retry. Impacto: é o 2º sinal do e2e full (novo no pipeline GitHub); o deploy `verify` roda full e pode flakear por isso.
## Rabit holes / Explicitamente fora
- **Não** reabrir o diagnóstico do admin (OPS73 fechou).
- **Não** "re-homar" o re-home do S8/S9 de novo (já em main).
- Guard #87 (importMap sem envs S3\_) é Issue própria (já absorve a lição do S9); não duplicar aqui.
## Aceite
- S10 rodando em prod (pixel presente) e no GitHub main.
- `~/teqo-deploy` com origin = GitHub após a mudança.
- e2e full do GitHub CI estável no `seedTestUser` (sem flake de duplicate email).

View file

@ -1,129 +0,0 @@
# Impl: Perfis de permissão por assessor (visão × edição), configuráveis pelo coordenador/candidato
Status: aprovado
Atualizado em: 2026-08-19
Issue: #105 (C141)
Intenção: docs/plans/permissao-granular-assessores.md
Appetite restante: herdado (~2–3 dias eng) — cortes abaixo no "Rabbit holes / Não escopo"
## Leitura da intenção
- **Outcome:** coordenador/candidato define por assessor **Visão** (`Tudo | Carteira`) e **Edição** (`Tudo | Carteira | Somente leitura`) em `/campanha/assessores` (lista + detalhe + novo), com enforcement fail-closed no servidor; default = comportamento atual (Carteira · Edita carteira).
- **O que NÃO negociar:**
- Default Carteira·Carteira para contas novas e existentes — zero mudança até configurar.
- Demandas ficam FORA dos eixos: Visão "Tudo" não abre demandas (regra de responsáveis = C143; aqui: carteira segue sendo o teto).
- Apoiadores (PII) capados na carteira para todo staff abaixo de candidato/coordenador, mesmo com Visão "Tudo" — e por simetria, mesmo com Edição "Tudo".
- Edição "Tudo" não libera escritas de coordenação (contas, `municipality.advisors`, nível de envolvimento E14, decisão de demandas escaladas).
- Combinação incoerente (editar o que não vê) não é oferecida — e é rejeitada no servidor.
- Leader lockdown e assimetria de votos (estimativas staff-only) intactos.
- **O que reavaliar:** a hipótese de direção apontava "perfis na conta + enforcement nos módulos de acesso + gates nas actions + UI nos três pontos" — confirma-se, com duas correções de engenharia: (a) **`getAccessibleMunicipalityIds` NÃO pode virar o escopo de visão** (ele alimenta superfícies PII — apoiadores, telefones, feeds — que o gate capa na carteira); (b) o prologue `resolveActorScopedRead` compartilhado com demandas **não pode alargar em bloco** (vazaria demandas com Visão Tudo) — prologue irmão, profile-aware, para as coleções não sensíveis.
## Abordagem recomendada
```mermaid
flowchart LR
F["CampaignUser.visibility/editing<br/>(default carteira/carteira, hook coerência)"]
L["src/lib/campaignAdvisorProfile.ts<br/>(opções, rótulos, coerência — client-safe)"]
R["Leitura: resolveProfileScopedRead + branches<br/>municípios, lideranças, updates, pledges, atividades, contatos"]
W["Escrita: eixo Edição nos access (canUpdate*/canCreate*)<br/>+ giro check write-scoped"]
A["actions/advisor.ts: updateAdvisorPermission<br/>+ createAdvisor com perfil"]
U["UI: coluna Permissão + popover editor + detalhe + draft row"]
M["Migration: add_campaign_user_advisor_permission_profile"]
F --> R; F --> W
L --> U
A --> U
M --> F
```
**Opções consideradas:** A (perfis sobre o papel `advisor` em dois selects na conta) | B (role novo `viewer`) | C (matriz por coleção) | D (alargar `getAccessibleMunicipalityIds` por visão)
**Recomendação:** A — dois selects (`visibility`, `editing`) em `campaignUser` + eixo de leitura profile-aware nos access de coleção + eixo de escrita por `editing`; UI em três pontos com o mesmo editor. Mantém o modelo de papéis congelado, enforcement centralizado onde o código já decide escopo (`overrideAccess: false` + `user`), e as superfícies PII intactas.
**Rejeitadas:**
- B porque recria seed/modelo de papéis (rabbit hole do gate).
- C porque explode em matriz N×M (anti-goal do gate).
- D porque `getAccessibleMunicipalityIds` alimenta apoiadores, telefones de contas e feeds — alargá-lo abriria PII além da carteira, violando o gate; o escopo "carteira" continua sendo O escopo de operação, e a visão ampla é um eixo separado.
### Componentes / mudanças
**Modelo — `src/collections/CampaignUser.ts`**
- `visibility`: select `'carteira' | 'tudo'`, default `'carteira'`, label "Visão" (pt-BR no admin), `admin.condition` só p/ `role === 'advisor'`, `access.update: canManageCampaignUserRole` (unrestricted/admin).
- `editing`: select `'carteira' | 'tudo' | 'somente_leitura'`, default `'carteira'`, mesma condição/access.
- `beforeValidate` novo `enforceCoherentAdvisorProfile`: `role === 'advisor' && editing === 'tudo' && visibility !== 'tudo'` → APIError (fail-closed também via REST/admin, não só na action).
- `preventSelfServicePrivilegedFields`: adicionar `visibility`, `editing` ao strip.
- **Migration:** `add_campaign_user_advisor_permission_profile` (duas colunas select com default `'carteira'`; sem backfill — default cobre linhas existentes).
**Lib — `src/lib/campaignAdvisorProfile.ts`** (novo, client-safe)
- Tipos `AdvisorVisibility` / `AdvisorEditing`; `ADVISOR_VISIBILITY_OPTIONS` / `ADVISOR_EDITING_OPTIONS` (value + label + description pt-BR do draft: "Tudo — enxerga todos os municípios e lideranças.", "Somente leitura — nenhum controle de edição em lugar nenhum.", …); `advisorProfileLabel(v, e)` ("Carteira · Edita carteira", "Tudo · Somente leitura", "Tudo · Edita tudo"); `isCoherentAdvisorProfile(v, e)` (`e === 'tudo' ⇒ v === 'tudo'`). Combinar com `campaignRoles.ts` (papéis), não duplicar.
**Access — `src/utilities/access/`**
- `shared.ts`:
- `resolveProfileScopedRead(req, scopeField, loadAccessibleIds)` — mesmo prólogo de `resolveActorScopedRead`, mas advisor com `visibility === 'tudo'` → `true`. `resolveActorScopedRead` fica como está (demandas continuam carteira — C143 substitui depois).
- `advisorEditingAccess(currentUser)` → `'none' | 'carteira' | 'tudo'` a partir de `editing` (somente_leitura → none; tudo → tudo; senão carteira; não-assessor → none).
- `resolveAccessibleIds`: permitir `compute` retornar `null` (= "tudo") — necessário para `getAccessibleLeadershipIds`/`getAccessibleContactIds` ampliarem sem virar `true` (que abriria PII).
- `municipalities.ts`: `canReadMunicipality` — advisor com `visibility === 'tudo'` → `true`; `canUpdateMunicipality` — eixo: none → `false`, tudo → `true`, carteira → `{ advisors: { contains: self } }` (where atual inalterado).
- `leaderships.ts`: `canReadLeadership` → `resolveProfileScopedRead`; `getAccessibleLeadershipIds` — advisor com visão tudo → `null`; `canCreateLeadership`/`canManageLeadership` — eixo de escrita.
- `municipalityUpdates.ts` (+ alias `allocationDecisions.ts`): `canReadMunicipalityUpdate`/`canReadAllocationDecision` → `resolveProfileScopedRead`; `canCreateMunicipalityUpdate` — eixo.
- `votePledges.ts`: `canReadVotePledge` → `resolveProfileScopedRead`; `canCreateVotePledge`/`canUpdateVotePledge` — eixo (tudo → `true`; none → `false`; carteira → fragmento atual).
- `activities.ts`: `canReadActivity` — advisor com visão tudo → `true`; `canCreateActivity`/`canUpdateActivity` — eixo (carteira mantém o or-where `responsible`/`municipality`; tudo → `true`; none → `false`).
- `demands.ts`: **inalterado na leitura** (carteira — C143 dono). Só `canUpdateCampaignDemand`/`canCreateCampaignDemand`: eixo none → `false`; tudo/carteira → comportamento atual (carteira; C143 substitui a regra depois).
- `supporters.ts`: `canCreateSupporter`/`canManageSupporter` — eixo none → `false`; senão comportamento atual (carteira SEMPRE; PII cap nos dois eixos). `canReadSupporter` inalterado.
- `organizations.ts` / `stateDeputies.ts`: booleans staff → eixo (none → `false` para advisor; senão comportamento atual).
- `contacts.ts`: `getAccessibleContactIds` — ramo lideranças: advisor com visão tudo → `where: {}` (dobradinhas já são todas); ramo apoiadores: SEMPRE carteira (PII cap). `canReadContacts` segue derivando ids.
- `calendarFeeds.ts`, `campaignUsers.ts` (phone): **inalterados** — feeds são superfície pessoal (contrato C96 re-deriva escopo na leitura) e telefone é PII; continuam carteira.
**Actions — `src/app/(campaign)/campanha/actions/`**
- `advisor.ts`: `updateAdvisorPermission` (novo) — `reloadUnrestrictedActor` + `assertTargetAdvisor` + `payload.update({ visibility, editing }, user, overrideAccess: false)` (field access `canManageCampaignUserRole` re-checa); `createAdvisorRecord` aceita `visibility`/`editing` opcionais (default carteira). `revalidateAdvisorPaths`.
- `activity.ts` `createTourDraftActivitiesRecord`: trocar o check de escopo do giro de **leitura** para **escrita** — hoje lê municípios com access (com visão tudo o read alargaria e um assessor "Carteira" comporia giro fora da carteira). Usar `getWritableMunicipalityIds` (novo helper em `municipalities.ts`: unrestricted/tudo → `null`; none → `[]`; carteira → ids da carteira).
- `formActions.ts` (assessores): `updateAdvisorPermissionFormAction`; `createAdvisorFormAction` lê `visibility`/`editing` do FormData.
**Dados/view models — `src/utilities/advisorData.ts`**
- `AdvisorRowViewModel` / `AdvisorDetailViewModel` ganham `visibility`/`editing`; `loadAdvisorListPageData`/`loadAdvisorDetail` incluem os campos no `select`.
**UI — `src/components/campaign/advisor/`** (Impeccable B — encaixe na superfície existente)
- `AdvisorPermissionEditor` (novo, client): dois `NativeSelect` (Visão/Edição) + nota de demandas + Cancelar/Salvar; ao escolher Edição "Tudo" com Visão "Carteira", auto-eleva Visão para "Tudo" (só combos coerentes são oferecidos); salva via formAction + `router.refresh()`.
- `AdvisorPermissionBadge` (novo, client): badge "Carteira · Edita carteira" etc. (botão) abrindo Popover com o editor — reusado na lista e no draft row.
- `AdvisorsTable`: coluna **`permission`** (mandatory, `headerClassName` ~`w-48`) com o badge; draft row ganha célula de permissão (estado local, default carteira/carteira) e `saveDraft` envia os campos; `DraftAdvisor` ganha `visibility`/`editing`.
- Detalhe `[id]/page.tsx`: seção "Permissão da conta" (título + editor inline + nota de demandas/coordenação), mesma formAction.
- Coluna participa do picker como mandatory (contrato B197+ — body sempre renderiza).
### Dados → forma (se aplicável)
- Sem KPI/dado novo: superfície de configuração. Rótulo "Permissão" em badge curto (coluna) e textos descritivos no editor — forma do draft do gate, reutilizada como vocabulário único (`campaignAdvisorProfile.ts`).
## Fases verificáveis
1. **Tracer — schema + leitura** (~0,5 dia): campos + hook + migration + `campaignAdvisorProfile.ts` + `resolveProfileScopedRead`/`advisorEditingAccess` + leitura dos access (municípios, lideranças, updates, pledges, atividades, contatos) + `generate:types` + int spec de leitura (visão tudo alarga; carteira não; demandas/apoiadores continuam carteira).
2. **Escrita** (~0,5–0,75 dia): eixo `editing` nos `canUpdate*`/`canCreate*` + `getWritableMunicipalityIds` + giro + int spec (somente_leitura nega tudo; tudo permite fora da carteira exceto PII/coordenação; carteira = hoje).
3. **Actions + dados** (~0,25 dia): `updateAdvisorPermission`, create com perfil, zod, form actions, view models/loaders.
4. **UI** (~0,75–1 dia): editor + badge + coluna + draft row + detalhe; shape→craft→critique→polish; unit specs dos componentes/labels.
5. **Gates**: `pnpm migrate:create` + `pnpm migrate` (local), `generate:types` (com `S3_*` dummy setados), `tsc --noEmit`, `pnpm lint`, `format:check`, `knip`, `check:cycles`, `pnpm test` (unit+int), `pnpm build`. e2e roda no CI.
## Rabbit holes / Não escopo (engenharia)
- Redesenho mobile da lista de assessores em cards (draft cena 2) — a tabela atual rola; apresentação por perfil é o item irmão C142. Não escopo aqui.
- Mudar a regra de demandas (responsáveis) — C143 é o dono; aqui só o bloqueio "somente leitura" e o teto carteira.
- Histórico/auditoria de mudanças de perfil; autoatendimento; perfil para coordinator/candidate/leader.
- `saveToJWT` para os campos novos — desnecessário: `getFreshCampaignUser` recarrega por ID.
- Migration de backfill — default cobre linhas existentes.
## Riscos e mitigação
- **Mapa/quantis/dashboard** leem `loadMunicipalityScope` (access-driven) — alargam automaticamente com visão tudo; esperado e desejado ("mesa de trabalho completa"). Mitigação: int spec pina que o escopo do mapa acompanha o perfil.
- **`getAccessibleLeadershipIds` alargada** abre convites para todas as lideranças com visão tudo — coerente com a visão; registrar no resumo (C142 apresenta; se a mesa discordar, item sucessor).
- **Sollinha** usa as mesmas queries/access — segue automaticamente; conferir B185/B186 (fragmentos allowlistados por escopo já resolvido) em fase 2.
- **Contatos**: a lista de pessoas deriva de lideranças (alarga) + apoiadores (carteira) — composição por ids, nunca `true` (evita abrir PII). Teste pina o ramo apoiadores.
- **Combinação incoerente via API** — hook no `beforeValidate` + zod; teste pina o 400.
- **Giro (tour)** — o check read→write é a única correção fora dos access; teste específico.
## Aceite de engenharia
- [ ] Aceite de produto da intenção ainda coberto (dois eixos, default carteira, fail-closed, PII/demandas/coordenação intocados)
- [ ] Invariantes AGENTS/engineering-standards: Local API com `user` → `overrideAccess: false`; sem re-escrita do fragmento P3-D (guarda em `codebaseConventions`); identificadores em inglês, valores/UI pt-BR
- [ ] Testes de domínio previstos (unit/int) onde access/write paths mudam: `campaignAdvisorPermission.int.spec.ts` + unit dos helpers de perfil

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

View file

@ -1,322 +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 — Perfis de permissão por assessor</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">
Coordenador/candidato define por assessor o que ele vê (Tudo | Carteira) e o que ele pode
editar (Tudo | Carteira | Somente leitura) — cenas: lista com coluna Permissão + editor,
mobile, detalhe do assessor.
</p>
<!-- Cena 1: lista desktop com editor aberto numa linha -->
<section data-shot="desktop" class="mt-6 overflow-hidden rounded-xl border border-zinc-800">
<div
class="flex items-center justify-between border-b border-zinc-200 bg-zinc-100 px-6 py-4"
>
<div>
<div class="h-5 w-44 rounded bg-zinc-500"></div>
<div class="mt-1 h-2 w-64 rounded bg-zinc-400"></div>
</div>
<div class="flex items-center gap-3">
<div
class="h-9 w-44 rounded-full border border-zinc-300 bg-white px-3 text-sm text-zinc-500"
>
Buscar assessor…
</div>
<div
class="inline-flex h-9 items-center rounded-lg bg-red-600 px-4 text-sm font-extrabold text-white"
>
+ Novo assessor
</div>
</div>
</div>
<div class="bg-zinc-100 px-6 pb-6">
<!-- tabela -->
<div class="overflow-hidden rounded-lg border border-zinc-200 bg-white">
<div
class="grid grid-cols-[1.3fr_1.7fr_1.1fr_1.7fr_1.5fr] gap-2 border-b border-zinc-200 bg-zinc-50 px-4 py-2 text-xs font-bold text-zinc-500"
>
<div>Nome</div>
<div>E-mail</div>
<div>Celular</div>
<div>Municípios</div>
<div>Permissão</div>
</div>
<div
class="grid grid-cols-[1.3fr_1.7fr_1.1fr_1.7fr_1.5fr] items-center gap-2 border-b border-zinc-100 px-4 py-3 text-sm"
>
<div class="font-semibold text-zinc-900">Nome do assessor</div>
<div class="text-zinc-600">assessor@exemplo.org</div>
<div class="text-zinc-600">(71) 90000-0000</div>
<div class="flex flex-wrap gap-1">
<span class="rounded bg-zinc-200 px-1.5 py-0.5 text-xs text-zinc-700"
>Salvador (19)</span
>
<span class="rounded bg-zinc-200 px-1.5 py-0.5 text-xs text-zinc-700"
>Feira de Santana</span
>
<span class="rounded bg-zinc-200 px-1.5 py-0.5 text-xs text-zinc-700">+3</span>
</div>
<div>
<span class="rounded bg-zinc-300 px-2 py-0.5 text-xs font-bold text-zinc-800"
>Carteira · Edita carteira</span
>
</div>
</div>
<div
class="grid grid-cols-[1.3fr_1.7fr_1.1fr_1.7fr_1.5fr] items-center gap-2 border-b border-zinc-100 px-4 py-3 text-sm"
>
<div class="font-semibold text-zinc-900">Outra assessora</div>
<div class="text-zinc-600">outra@exemplo.org</div>
<div class="text-zinc-600">(71) 90000-0001</div>
<div class="flex flex-wrap gap-1">
<span class="rounded bg-zinc-200 px-1.5 py-0.5 text-xs text-zinc-700"
>Camaçari</span
>
</div>
<div>
<span class="rounded bg-zinc-300 px-2 py-0.5 text-xs font-bold text-zinc-800"
>Tudo · Somente leitura</span
>
</div>
</div>
<div
class="relative grid grid-cols-[1.3fr_1.7fr_1.1fr_1.7fr_1.5fr] items-center gap-2 bg-zinc-50 px-4 py-3 text-sm pb-[28rem]"
>
<div class="font-semibold text-zinc-900">Assessora de campo</div>
<div class="text-zinc-600">campo@exemplo.org</div>
<div class="text-zinc-600">(71) 90000-0002</div>
<div class="flex flex-wrap gap-1">
<span class="rounded bg-zinc-200 px-1.5 py-0.5 text-xs text-zinc-700">Ilhéus</span>
<span class="rounded bg-zinc-200 px-1.5 py-0.5 text-xs text-zinc-700">Itabuna</span>
</div>
<div>
<span class="rounded bg-zinc-300 px-2 py-0.5 text-xs font-bold text-zinc-800"
>Carteira · Edita carteira</span
>
</div>
<!-- popover editor -->
<div
class="absolute left-4 top-6 z-10 w-80 rounded-lg border border-zinc-200 bg-white p-4 shadow-xl"
>
<div class="text-sm font-bold text-zinc-900">Permissão da conta</div>
<div class="mt-1 text-xs text-zinc-500">Vale para todas as telas do /campanha.</div>
<div class="mt-4">
<div class="text-xs font-bold text-zinc-700">Visão</div>
<div
class="mt-1 flex h-9 items-center justify-between rounded-md border border-zinc-300 bg-zinc-50 px-3 text-sm text-zinc-800"
>
Carteira — municípios que administra
<span class="text-xs text-zinc-400">▾</span>
</div>
<div class="mt-1 text-[11px] text-zinc-500">
Tudo: enxerga todos os municípios e lideranças.
</div>
</div>
<div class="mt-4">
<div class="text-xs font-bold text-zinc-700">Edição</div>
<div
class="mt-1 flex h-9 items-center justify-between rounded-md border border-zinc-300 bg-zinc-50 px-3 text-sm text-zinc-800"
>
Carteira — edita só o que administra
<span class="text-xs text-zinc-400">▾</span>
</div>
<div class="mt-1 text-[11px] text-zinc-500">
Somente leitura: nenhum controle de edição em lugar nenhum.
</div>
</div>
<div
class="mt-4 rounded-md bg-zinc-100 px-3 py-2 text-[11px] leading-relaxed text-zinc-600"
>
Demandas seguem regra própria: só quem é responsável pela demanda (e
candidato/coordenador) a vê — mesmo com visão "Tudo".
</div>
<div class="mt-4 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"
>
Salvar
</div>
</div>
</div>
</div>
</div>
<div class="mt-3 text-xs text-zinc-500">
Padrão de contas novas: Carteira · Edita carteira (comportamento atual). Alterações
valem na hora — ninguém precisa reenviar senha.
</div>
</div>
</section>
<!-- Cena 2: mobile — lista em cards -->
<section
data-shot="mobile"
class="mt-6 w-[390px] overflow-hidden rounded-xl border border-zinc-800"
>
<div class="bg-zinc-100 p-4">
<div class="flex items-center justify-between">
<div class="h-5 w-36 rounded bg-zinc-500"></div>
<div
class="inline-flex h-9 items-center rounded-lg bg-red-600 px-4 text-sm font-extrabold text-white"
>
+ Novo
</div>
</div>
<div
class="mt-3 h-9 w-full rounded-full border border-zinc-300 bg-white px-3 text-sm text-zinc-500"
>
Buscar assessor…
</div>
<div class="mt-3 rounded-lg border border-zinc-200 bg-white p-3">
<div class="flex items-start justify-between gap-2">
<div class="min-w-0 truncate font-semibold text-zinc-900">Nome do assessor</div>
<span
class="shrink-0 rounded bg-zinc-300 px-2 py-0.5 text-[11px] font-bold text-zinc-800"
>Carteira · Edita</span
>
</div>
<div class="mt-1 text-xs text-zinc-600">assessor@exemplo.org · (71) 90000-0000</div>
<div class="mt-2 flex flex-wrap gap-1">
<span class="rounded bg-zinc-200 px-1.5 py-0.5 text-[11px] text-zinc-700"
>Salvador (19)</span
>
<span class="rounded bg-zinc-200 px-1.5 py-0.5 text-[11px] text-zinc-700"
>Feira de Santana</span
>
<span class="rounded bg-zinc-200 px-1.5 py-0.5 text-[11px] text-zinc-700">+3</span>
</div>
</div>
<div class="mt-2 rounded-lg border border-zinc-200 bg-white p-3">
<div class="flex items-start justify-between gap-2">
<div class="min-w-0 truncate font-semibold text-zinc-900">Outra assessora</div>
<span
class="shrink-0 rounded bg-zinc-300 px-2 py-0.5 text-[11px] font-bold text-zinc-800"
>Tudo · Só leitura</span
>
</div>
<div class="mt-1 text-xs text-zinc-600">outra@exemplo.org · (71) 90000-0001</div>
<div class="mt-2 flex flex-wrap gap-1">
<span class="rounded bg-zinc-200 px-1.5 py-0.5 text-[11px] text-zinc-700"
>Camaçari</span
>
</div>
</div>
<div class="mt-2 rounded-lg border border-zinc-200 bg-white p-3">
<div class="flex items-start justify-between gap-2">
<div class="min-w-0 truncate font-semibold text-zinc-900">Assessora de campo</div>
<span
class="shrink-0 rounded bg-zinc-300 px-2 py-0.5 text-[11px] font-bold text-zinc-800"
>Carteira · Edita</span
>
</div>
<div class="mt-1 text-xs text-zinc-600">campo@exemplo.org · (71) 90000-0002</div>
<div class="mt-2 flex flex-wrap gap-1">
<span class="rounded bg-zinc-200 px-1.5 py-0.5 text-[11px] text-zinc-700"
>Ilhéus</span
>
<span class="rounded bg-zinc-200 px-1.5 py-0.5 text-[11px] text-zinc-700"
>Itabuna</span
>
</div>
</div>
</div>
</section>
<!-- Cena 3: detalhe do assessor com seção de permissão -->
<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 class="flex items-center gap-3">
<div class="h-10 w-10 rounded-full bg-zinc-400"></div>
<div>
<div class="text-lg font-bold text-zinc-900">Nome do assessor</div>
<div class="text-sm text-zinc-500">assessor@exemplo.org · (71) 90000-0000</div>
</div>
</div>
<div
class="rounded-lg border border-zinc-300 bg-white px-4 py-2 text-sm font-bold text-zinc-700"
>
Enviar link de redefinição de senha
</div>
</div>
<div class="mt-6 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">Permissão da conta</div>
<div class="mt-3">
<div class="text-xs font-bold text-zinc-700">Visão</div>
<div
class="mt-1 flex h-9 items-center justify-between rounded-md border border-zinc-300 bg-zinc-50 px-3 text-sm text-zinc-800"
>
Carteira — municípios que administra
<span class="text-xs text-zinc-400">▾</span>
</div>
</div>
<div class="mt-3">
<div class="text-xs font-bold text-zinc-700">Edição</div>
<div
class="mt-1 flex h-9 items-center justify-between rounded-md border border-zinc-300 bg-zinc-50 px-3 text-sm text-zinc-800"
>
Carteira — edita só o que administra
<span class="text-xs text-zinc-400">▾</span>
</div>
</div>
<div
class="mt-3 rounded-md bg-zinc-100 px-3 py-2 text-[11px] leading-relaxed text-zinc-600"
>
Demandas ficam sempre limitadas aos municípios de responsabilidade. Decisões de
coordenação (nível de envolvimento, contas, demandas escaladas) continuam restritas
a coordenador e candidato.
</div>
</div>
<div class="rounded-lg border border-zinc-200 bg-white p-5">
<div class="text-sm font-bold text-zinc-900">Municípios administrados</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"
>Salvador (19)</span
>
<span class="rounded bg-zinc-200 px-2 py-1 text-xs text-zinc-700"
>Feira de Santana</span
>
<span class="rounded bg-zinc-200 px-2 py-1 text-xs text-zinc-700">Camaçari</span>
<span class="rounded bg-zinc-200 px-2 py-1 text-xs text-zinc-700">Ilhéus</span>
<span class="rounded bg-zinc-200 px-2 py-1 text-xs text-zinc-700">Itabuna</span>
<span
class="inline-flex h-6 items-center rounded bg-zinc-100 px-2 text-xs text-zinc-500"
>+8</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 município…
</div>
</div>
</div>
</div>
</section>
</main>
</body>
</html>

View file

@ -1,101 +0,0 @@
# Perfis de permissão por assessor (visão × edição), configuráveis pelo coordenador/candidato
Status: aguardando execução
Atualizado em: 2026-08-19
Issue: #105
Priority: P1
Model: cursor-grok-4.5-high
Impeccable: B — encaixe em `/campanha/assessores` (lista + detalhe + novo)
Rascunho UI: docs/plans/permissao-granular-assessores-ui-draft.html
Appetite: ~2–3 dias eng; um modelo de permissão + configuração + enforcement fail-closed
Responsável: —
## Intenção
Hoje todo assessor tem exatamente um perfil: **vê e edita só os municípios da carteira**. No campo, a equipe descobriu que isso não cobre a mesa real: há assessor que precisa **ver tudo** (panorama, relatório, cobertura) sem poder **alterar nada**; há assessor que **vê tudo** mas só pode mexer **naquilo pelo que é responsável**; há assessor que só vê o que é seu — com ou sem edição. E **demandas** carregam custo e comprovantes (controle interno): só candidato, coordenador e as **pessoas responsáveis pela demanda** as veem — regra própria, detalhada no item irmão (C143).
O coordenador/candidato precisa configurar isso **por conta**, na vertical `/campanha`, sem depender do Payload admin.
## Persona e fluxo
- **Persona / contexto:** Alex (Coordenador Geral) e o Candidato na mesa — montar e ajustar o time de assessores conforme o papel real de cada um; e o assessor, operando o `/campanha` dentro do que lhe foi liberado.
- **Job principal:** para cada assessor, definir **o que ele vê** e **o que ele pode editar**, em dois controles simples; e ter isso aplicado na hora, em todas as telas.
- **Fluxo desejado:**
1. Alex abre `/campanha/assessores` e vê uma coluna "Permissão" por assessor (ex.: "Carteira · Edita carteira").
2. Toca no perfil de um assessor e escolhe **Visão** (Tudo | Carteira) e **Edição** (Tudo | Carteira | Somente leitura); a tela explica cada combinação e lembra que demandas seguem regra própria.
3. Salva. O assessor entra e opera exatamente dentro do perfil — se "Somente leitura", não encontra nenhum controle de edição; se "Tudo", navega o estado inteiro (demandas fora, que seguem a regra de responsáveis).
4. Conta nova nasce no padrão atual (Carteira · Carteira) — nada muda até alguém mexer.
- **Anti-goals de produto:** papel novo (só o assessor ganha perfis; liderança continua em lockdown e candidato/coordenador intocados); IAM tipo SaaS (grupos, herança, policy engine); permissão fina por coleção ("vê lideranças mas não atividades"); autoatendimento do próprio perfil.
### Esboço de fluxo (B/C/D)
```text
[assessores lista] → [coluna Permissão] → [editor: Visão × Edição + avisos]
→ [salvar] → [perfil ativo imediatamente]
[assessor entra] → [telas renderizam e respondem dentro do perfil — fail-closed no servidor]
```
### Rascunho UI (B/C/D)
![Rascunho UI — desktop (lista + editor)](permissao-granular-assessores-ui-draft-desktop.png)
![Rascunho UI — mobile (cards)](permissao-granular-assessores-ui-draft-mobile.png)
![Rascunho UI — detalhe do assessor](permissao-granular-assessores-ui-draft-detalhe.png)
## Objetivo e aceite
- O coordenador/candidato define, por assessor: **Visão** (Tudo | Carteira) e **Edição** (Tudo | Carteira | Somente leitura), nas superfícies de `/campanha/assessores` (lista, detalhe, novo).
- Contas novas e existentes mantêm o perfil atual por default — zero mudança de comportamento até configurarem.
- O assessor vê/edita **exatamente** o que o perfil permite, em todas as telas do `/campanha` e nas APIs — a checagem falha-fechada no servidor (nunca só ocultar botão no cliente).
- **Demandas:** ficam fora dos eixos — mesmo com Visão "Tudo", o assessor só vê demandas de que é responsável (regra explícita do item C143); Visão "Tudo" não abre demandas nem custo/comprovantes.
- Edição "Tudo" **não** libera escritas de coordenação: gerenciar contas, atribuir assessores a municípios, mover nível de envolvimento e decidir demandas escaladas seguem restritos a candidato/coordenador.
- Combinação incoerente (ex.: editar o que não vê) não é oferecida.
- **Apoiadores** (PII) continuam capados na carteira para todo staff abaixo de candidato/coordenador, mesmo com Visão "Tudo" _(decidido no gate)_.
- Configuração em três pontos: coluna + editor inline na lista, seção no detalhe e campos no formulário de novo assessor _(decidido no gate)_. Edit-where-you-see.
- Guardrails do repo intactos: liderança em lockdown, assimetria de votos (estimativas staff-only), postura LGPD fail-closed.
## Dados (intenção)
- **Vou apresentar dados?** Não — superfície de configuração de contas; sem KPI eleitoral.
- **Decisões desbloqueadas:** coordenador decide quem vê o quê e quem edita o quê, por pessoa.
## Direção no codebase (hipótese)
- **Áreas prováveis:** perfis na conta em `src/collections/CampaignUser.ts`; enforcement nos módulos de acesso `src/utilities/access/*` (o prologue `resolveActorScopedRead`/`resolveAccessibleIds` em `shared.ts` e os módulos por domínio — municípios, lideranças, pledges, demandas, atividades, organizações, dobradinhas, apoiadores); gates de escrita nas server actions `src/app/(campaign)/campanha/actions/*.ts`; UI em `src/components/campaign/advisor/` (`AdvisorsTable`, detalhe `[id]`, formulário novo) e helpers de papel client-safe em `src/lib/campaignRoles.ts`.
- **Precedente a olhar:** B19 (`gerenciar-assessores.md`) — a superfície e os gates de conta; a convenção P3-D do escopo de assessor (uma só grafia do fragmento `{ municipality(s): { in: ids } }`); C116/C116+ — células edit-in-place com estados read-only por papel.
- **Risco de acoplamento:** o escopo do assessor é central e compartilhado (mapa, quantis por escopo, listas, dossiê, Sollinha); qualquer mudança no "o que o assessor vê" propaga. Respeitar leader lockdown e o "não re-escrever o fragmento de escopo" da convenção.
## Dependências
- Nenhuma dura. **Guardrail:** a regra de demandas (C143) não depende deste item, e Visão "Tudo" nunca a contorna. C142 (superfícies respeitando o perfil) depende deste.
## Fora de escopo
- Perfis para `coordinator`/`candidate`/`leader` (congelados por política de produto).
- Demanda com "responsáveis" explícitos por demanda (vínculo por pessoa) — ver Questões; se confirmado, item sucessor.
- Permissão fina por coleção ou por campo além da regra de demandas.
- Autoatendimento/edição do próprio perfil.
- Histórico/auditoria de mudanças de perfil.
## Rabbit holes de produto
- **"Já que tem visão, vou adicionar um role novo."** Papel novo (`viewer`, `auditor`) recria o modelo de papéis e o seed de contas. **Corte:** perfis sobre o papel `advisor` existente — um assessor "visão Tudo · Somente leitura" É o leitor.
- **"Vou pedir permissão por coleção."** Cada campo novo vira matriz N×M e a tela vira IAM. **Corte:** dois eixos só; o que não couber nos eixos fica com a regra das demandas ou fora.
- **"Vou esconder botão no cliente e pronto."** Acesso de verdade mora no servidor; esconder botão é cosmético. **Corte:** enforcement no acesso de dados e nas actions primeiro; a apresentação é a entrega irmã (item seguinte).
- **"Visão Tudo abre as planilhas PII."** Apoiadores/custo de demandas são sensíveis (LGPD/controle interno). **Corte:** apoiadores capados na carteira (decidido); demandas seguem a regra de responsáveis (C143).
## Decisões do gate (2026-08-19)
- **Responsável por uma demanda = vínculo explícito por demanda** (opção B da rodada) — mesmo o assessor do município da demanda, se não estiver marcado como responsável, não vê. A regra vive no item irmão **C143**; este item apenas garante que Visão "Tudo" não a contorna.
- **Visão "Tudo" não abre superfícies sensíveis** (opção A) — apoiadores (PII) seguem capados na carteira; demandas seguem a regra de responsáveis (C143).
- **Edição "Tudo" não libera escritas de coordenação** (opção A).
- **Configuração na lista + detalhe + formulário de novo assessor** (opção A).
## Referências
- Pedido de produto 2026-08-19 (vertical `/campanha` — granularidade de acesso por conta); decisões do gate na seção acima.
- Item irmão: `docs/plans/demandas-responsaveis.md` (C143 — regra de responsáveis de demanda).
- Rascunho UI (gate): `docs/plans/permissao-granular-assessores-ui-draft.html` + PNGs acima.
- Para abrir primeiro: `src/utilities/access/shared.ts` (`resolveActorScopedRead`), `src/utilities/access/demands.ts` (regra atual de demandas = escopo de leitura), `src/components/campaign/advisor/AdvisorsTable.tsx`, `docs/plans/gerenciar-assessores.md` (B19).
- `AGENTS.md` — modelo de papéis de campanha, convenção P3-D do escopo de assessor.

View file

@ -1,87 +0,0 @@
# Impl: Pixel do Meta (Facebook) na página pública de campanha
Status: rascunho
Atualizado em: 2026-08-19
Issue: #94
Intenção: docs/plans/pixel-meta-pagina-campanha.md
Appetite restante: herdado (~0,5–1 dia eng)
## Leitura da intenção
- **Outcome:** o time de campanha paga configura no admin um campo opcional "ID do Pixel do Meta" nas configurações do site; a home pública de campanha (`/`) passa a carregar o base code + `PageView` do Meta quando o ID está presente e válido; a conclusão da captura de novidades (S9) dispara exatamente um `Lead`. Sem ID configurado, nada é carregado (fail-closed, zero impacto).
- **O que NÃO negociar:** persistir **só o ID numérico** (5–20 dígitos, sem HTML/JS colado — XSS); `Lead` **somente no sucesso** da captura, nunca no page load; Pixel **só na página pública de campanha** (não vazar PageViews de notícias/etc.); sem env var obrigatória; sem CAPI/Advanced Matching, cookie banner/CMP e dedup `eventID` nesta fatia (herda gatilhos do plano da petição); cache da home revalidado pela edição do admin.
- **O que reavaliar:** a hipótese da intenção dizia "home `src/app/(frontend)/(home)/page.tsx` (injeção do `<MetaPixel />`)" — a injeção pode morar no **layout** do grupo `(home)` (que cobre exatamente a única rota do grupo) em vez da página; e "form de captura do S9" é `CampaignNewsletterForm` (cliente) — a seam do `Lead` é o callback `onSubmit()` já previsto no impl plan do S9. A leitura do global na home é compatível com o caching: `getCachedGlobal('site-settings')` é `unstable_cache` sob a tag `global_site-settings`, já revalidada pelo `afterChange` existente (a home passa a ser ISR nessa tag — mesmo padrão de `SiteHeader`/`socialLinks`).
## Abordagem recomendada
```mermaid
flowchart LR
Admin["SiteSettings global<br/>tracking.facebookPixelId (group Rastreamento/Ads)"]
Home["(home)/layout.tsx<br/>getCampaignHomeMetaPixelId"]
Pixel["MetaPixel client<br/>next/script + fbq init/PageView"]
Section["CampaignNewsletterSection<br/>getCampaignHomeMetaPixelId"]
Form["CampaignNewsletterForm<br/>pixelId prop"]
Action["submitCampaignNewsletter"]
Lead["trackMetaLead<br/>content_name novidades-da-campanha"]
Meta["Meta Events Manager"]
Admin --> Home
Home --> Pixel
Pixel --> Meta
Section --> Form
Form --> Action
Action -->|sucesso--> Lead
Lead --> Meta
```
**Opções consideradas:** A (campo no global `SiteSettings` + injeção no layout do grupo `(home)`) · B (novo global de rastreamento dedicado) · C (injeção no `page.tsx` em vez do layout) · D (validate compartilhado em `facebookPixel.ts` vs. duplicado inline)
**Recomendação:** **A+D** — porque o dono existe com revalidação pronta (`afterChange` → `revalidateGlobal('site-settings')`, apontado pelo plano da petição como destino natural de um Pixel global); o layout do grupo `(home)` cobre **exatamente** a página pública de campanha (única rota do grupo), escopando o Pixel ao aceite e impedindo vazamento; e as regras de ID do Pixel passam a viver num único módulo profundo (`facebookPixel.ts` já é o dono), com o validate do `Petition` migrando para o helper compartilhado (edit owner, sem gêmeo).
**Rejeitadas:**
- **B (global de rastreamento dedicado):** twin do `SiteSettings` — o dono já existe, o plano da petição já o apontava, e um segundo global seria cerimônia sem volatilidade.
- **C (injeção no `page.tsx`):** funcionaria (única rota do grupo), mas o layout é o lugar que declara a superfície "página pública de campanha" (`data-theme="campaign-site"`) — se o grupo ganhar outra rota pública de campanha, o Pixel a cobre por contrato; e `CampaignNewsletterSection` continuaria precisando do pixelId de qualquer forma (2 call sites da mesma leitura).
- **D-duplicado (validate inline copiado do `Petition`):** a repetição do bloco validação/label/description é exatamente o tipo de drift que o precedente da petição adiou com gatilho ("revisitar quando o mesmo ID for colado em ≥3 petições") — o gatilho foi puxado por produto; agora as regras devem ter dono único.
### Componentes / mudanças
- **`src/lib/facebookPixel.ts`** (edit owner): adicionar `validateFacebookPixelId(value: string | null | undefined): true | string` — envolve `normalizeFacebookPixelId` devolvendo a mensagem pt-BR do admin ("Informe somente o ID numérico do Pixel (5 a 20 dígitos), sem HTML ou script.") quando inválido; `true` quando vazio ou válido (campo opcional). Módulo já importado por client — helper puro, sem `server-only`.
- **`src/collections/Petition.ts`** (edit owner): o `validate` inline do campo `tracking.facebookPixelId` passa a chamar `validateFacebookPixelId` (remove a duplicação; comportamento idêntico). `beforeChange` e campo inalterados.
- **`src/globals/SiteSettings.ts`** (edit owner): novo group `tracking` (label "Rastreamento / Ads", mesmo do `Petition`) com o campo `facebookPixelId` — `type: 'text'`, opcional, label "ID do Pixel do Meta (Facebook)", `admin.description` idêntica ao precedente, `validate: validateFacebookPixelId`; novo hook `beforeChange` normalizando `data.tracking.facebookPixelId` (mesmo padrão do `Petition`). `afterChange`/access inalterados (`update: payloadAdminOnly` já cobre).
- **`src/utilities/campaignHomeTracking.ts`** (novo, `server-only`): `getCampaignHomeMetaPixelId(): Promise<string | null>` — `getCachedGlobal('site-settings')()` → `normalizeFacebookPixelId(settings.tracking?.facebookPixelId)`. Esconde a forma do global + normalização; 2 call sites (layout + section); não é pass-through — é a fronteira do tracking da home.
- **`src/app/(frontend)/(home)/layout.tsx`** (edit owner): `const pixelId = await getCampaignHomeMetaPixelId()`; `{pixelId ? <MetaPixel pixelId={pixelId} /> : null}` dentro do container do grupo. Leitura cacheada sob `global_site-settings` — a edição do admin revalida a home (ISR), guardrail da intenção.
- **`src/app/(frontend)/(home)/CampaignNewsletterSection.tsx`** (edit owner): lê `getCampaignHomeMetaPixelId()` e passa `pixelId={pixelId ?? undefined}` ao `CampaignNewsletterCapture`.
- **`src/components/CampaignNewsletterForm.tsx`** (edit owner): `CampaignNewsletterForm` ganha prop opcional `pixelId?: string`; no sucesso de `submitCampaignNewsletter` (após `await`, antes do swap `onSubmit()`), `if (pixelId) trackMetaLead(pixelId, NEWSLETTER_LEAD_CONTENT_NAME, crypto.randomUUID())` — padrão exato do `PetitionForm` (linha 61-63). Constante de módulo `NEWSLETTER_LEAD_CONTENT_NAME = 'novidades-da-campanha'` (valor estável visível no Events Manager, mesmo padrão de `content_name` da petição). `CampaignNewsletterCapture` repassa a prop.
- **Migration:** `pnpm migrate:create add_site_settings_facebook_pixel_id` — colunas `tracking_facebook_pixel_id` (tabela `site_settings`) + `version_tracking_facebook_pixel_id` (tabela `site_settings_versions`); sem backfill, sem dado novo. Inspcionar SQL gerado antes de aplicar.
- **Access / Consent:** nenhum — campo herdado do access do global (`update: payloadAdminOnly`), sem PII nova, sem chave de Consent (mesmo precedente da petição: transparência de cookies fica no `/privacidade`, dependência suave herdada).
- **UI:** Impeccable A — N/A (campo único no admin replicando o precedente aceito + script invisível ao visitante; zero superfície pública nova).
### Dados → forma (não se aplica)
O dado (PageView/Lead) vive no Meta Events Manager; o app só emite o evento. Nenhuma forma de apresentação nova.
## Fases verificáveis
1. **Tracer / schema+server** — migration + `SiteSettings` (group/campo/beforeChange) + `validateFacebookPixelId` em `facebookPixel.ts` + refactor `Petition`; `pnpm migrate` local (teqo e teqo_test) + `pnpm generate:types`; unit do `validateFacebookPixelId` em `tests/unit/facebookPixel.unit.spec.ts`; `pnpm exec tsc --noEmit`.
2. **UI/leitura** — `campaignHomeTracking.ts` + layout com `<MetaPixel>` + section/form com a seam do `Lead`; e2e novo `tests/e2e/campaignHomePixel.e2e.spec.ts` (configura o global via REST admin com ID válido fixo, carrega a home com `?e2e=`, assere o script `#meta-pixel-<id>`, restaura o global para null no cleanup — idempotente, chave própria, paralelo-safe no DB de teste); spec do S9 inalterado (sem pixel configurado, nada renderiza).
3. **Gates** — `pnpm gate:fast`; `pnpm lint`, `pnpm format:check`, `pnpm exec knip`, `pnpm check:cycles`, `pnpm test`, `pnpm build` (com `S3_*` dummy e `generate:importmap` por via das dúvidas); changelog `docs/changelog/2026-08-19-s10.md` + `pnpm changelog:build`; push e PR via `pnpm push` (base `main` no Forgejo, `Closes #94`).
## Rabbit holes / Não escopo (engenharia)
- Não mexer em `submitCampaignNewsletter` (action intacta — o `Lead` é puramente client-side, mesmo padrão da petição).
- Não adicionar fila/retry de `Lead` quando `window.fbq` não carregou (race submit rápido vs. `afterInteractive`) — **herda o gatilho** do plano da petição (revisitar quando smoke com Meta Pixel Helper mostrar `Lead` ausente em produção).
- Não usar o ID do Pixel em outras páginas/superfícies do site.
- Não tocar CSP, env vars, `@types` de terceiros para `fbq` (tipagem local já existe).
- Não indexar/unicidade do campo (mesmo ID pode aparecer em petição e home — aceite da intenção).
## Riscos e mitigação
- **Home ISR sob `global_site-settings`:** a primeira render pós-save usa o valor novo (tag busta a cache); o e2e usa o truque do query param (`?e2e=`) para bypass de fetch cache — precedente do spec S9. Nenhuma página fora do grupo `(home)` lê o pixel.
- **Pins de geometria do e2e `frontend`:** `MetaPixel` só renderiza com ID configurado; o spec existente roda sem pixel → DOM inalterado (script + `noscript` `display:none` não deslocam layout). Sem re-pin esperado.
- **Global compartilhado no e2e:** o cleanup restaura `tracking.facebookPixelId` para `null` (update via REST); idempotente por run, DB de teste dedicado por worktree.
- **Concorrência de migration:** rebase em `forgejo/main` já feito; criar a migration após; cadeia validada por `pnpm migrate` + `test:int` no PR.
## Aceite de engenharia
- [x] Aceite de produto da intenção ainda coberto (campo admin com mesma validação; PageView na home com fail-closed; exatamente um `Lead` no sucesso; cache revalidado)
- [x] Invariantes AGENTS/engineering-standards (sem HTML colado; edit owner sem gêmeos; copy pt-BR/identifiers EN; `unstable_cache`/tag existentes)
- [ ] Testes de domínio previstos: unit `validateFacebookPixelId` (novo helper) + e2e de injeção do base code condicional

View file

@ -1,76 +0,0 @@
# Pixel do Meta (Facebook) na página pública de campanha
Status: registrado
Atualizado em: 2026-08-19
Issue: #94
Priority: P1
Model: composer-2.5
Impeccable: A — N/A (campo único no admin replicando precedente aceito + script invisível ao visitante; zero superfície pública nova)
Rascunho UI: N/A — sem UI
Appetite: ~0,5–1 dia eng; campo admin + injeção do base code na home + `Lead` no sucesso do form de captura
Responsável: —
## Intenção
O time de campanha paga roda tráfego no Meta direcionado à home pública de campanha, mas hoje não vê nada: sem Pixel, o Events Manager não registra visitas nem conversões dessa página, e as campanhas otimizam às cegas (sem retargeting, sem evento de conversão). Nos abaixo-assinados o time já configura o ID do Pixel por documento (`petition.tracking.facebookPixelId`, entregue 2026-07-21); a home de campanha precisa do mesmo instrumento em nível de site — um campo no admin para o ID numérico do Dataset, injetado na página pública de campanha, e o evento `Lead` quando a captura de novidades (S9) for concluída. Em janela eleitoral (1º turno 04/10), cada semana sem atribuição é verba paga sem feedback.
Este item revisita a decisão adiada do plano `pixel-meta-abaixo-assinado` ("Pixel / Dataset global em SiteSettings — revisitar quando o mesmo ID for colado em ≥3 petições e o admin reclamar de repetição"): produto agora pede explicitamente um Pixel de site para a página de campanha — o gatilho foi puxado, o precedente segue intacto para os abaixo-assinados.
## Persona e fluxo
- **Persona / contexto:** marketing digital / analista de campanha paga no Meta, no admin; quer ver visitas e conversões da home de campanha no Events Manager do Dataset.
- **Job principal:** configurar um Pixel no admin (colar só o ID numérico) e ver a home de campanha alimentar o Dataset sem colar JavaScript.
- **Fluxo desejado:**
1. Editor no `/admin` cola o ID numérico do Pixel no campo de rastreamento das configurações do site.
2. A home pública de campanha carrega o base code do Meta (`PageView`) quando o ID está presente e válido.
3. Quando o visitante conclui a captura de novidades (S9), exatamente um `Lead` é disparado.
4. O Events Manager mostra PageViews e Leads da página de campanha para otimização/retargeting.
- **Anti-goals de produto:** colar snippet HTML no admin (XSS); Pixel vazando PageViews do resto do site (notícias, etc.); CAPI/Advanced Matching nesta fatia; cookie banner/CMP nesta fatia.
## Objetivo e aceite
- Campo opcional "ID do Pixel do Meta" nas configurações do site (admin), com a mesma validação dos abaixo-assinados (somente ID numérico, 5–20 dígitos, sem HTML) e a mesma normalização no save.
- Quando o ID está configurado e válido, a home pública de campanha carrega o base code + `PageView`; sem ID, nada é carregado (fail-closed, zero impacto).
- Quando a captura de novidades (S9) é concluída com Pixel ativo, dispara exatamente um `Lead` (nunca no page load) — mesmo padrão da petição.
- Guardrails: nunca persistir nem executar HTML/JS do editor; sem env var obrigatória; cache da home revalidado pela edição do admin (o hook de revalidação do global já existe).
## Dados (intenção)
- **Vou apresentar dados?** Não — o dado (PageView/Lead) vive no Meta Events Manager; o app só emite o evento.
## Direção no codebase (hipótese)
- **Áreas prováveis:** global `src/globals/SiteSettings.ts` (campo único, provável dono — o plano anterior já apontava `SiteSettings` para um Pixel global; a revalidação `revalidateGlobal('site-settings')` já existe), home `src/app/(frontend)/(home)/page.tsx` (injeção do `<MetaPixel />`), `src/components/MetaPixel.tsx` + `src/lib/facebookPixel.ts` (reuso direto do precedente), form de captura do S9 (disparo do `Lead` no sucesso, no padrão `PetitionForm`).
- **Precedente a olhar:** `docs/plans/pixel-meta-abaixo-assinado.md` (decisões travadas: persistir só o ID, nunca snippet; `Lead` só no sucesso; validação e normalização reutilizadas), `src/collections/Petition.ts` (grupo `tracking` + `validate` + `beforeChange`), `src/app/(frontend)/abaixo-assinado/[id]/page.tsx` (injeção condicional).
- **Risco de acoplamento:** toca a home (mesmo arquivo do S9) e o `SiteSettings` (global lido por outras superfícies do site) — serializado por dependência; a leitura do global na home deve ser compatível com o caching da página (decisão de implementação, não de produto).
## Dependências
- **S9** (captura de novidades) — necessária só para o `Lead` no sucesso do form; o `PageView` na home é independente. Registrada como dependência para o `Lead` sair junto.
## Fora de escopo
- Pixel nas demais páginas do site (notícias, artigos, privacidade) — o pedido é a página pública de campanha; se marketing quiser site-wide, item futuro.
- Conversions API (CAPI) / Advanced Matching / dedup `eventID` — herda os gatilhos do plano da petição.
- Cookie banner/CMP, GTM, TikTok/Google pixels.
- Uniqueness de Dataset — mesmo ID pode aparecer na petição e na home.
## Rabbit holes de produto
- **Colar snippet HTML "como o Meta manda".** XSS + HTML no CMS. **Corte:** campo só ID + description + validação server-side, idêntico ao precedente aceito.
- **Pixel em tudo.** "Já que o campo existe, coloca em todas as páginas". **Corte:** só a home de campanha; site-wide é decisão separada de produto.
- **CAPI "já que estamos no servidor".** Secrets, hashing PII, dedup, retries. **Corte:** fora, herda os gatilhos do plano da petição.
## Questões em aberto (produto)
- **Evento `Lead` no sucesso do form de captura?** Confirmado com produto em 2026-08-19: **sim** — PageView no load + `Lead` no sucesso da captura (S9), réplica do precedente da petição.
- **Onde o campo mora?** **Opções:** (a) global `SiteSettings` (Configurações do site) | (b) novo global dedicado de rastreamento. **Recomendação:** (a) — dono existente com revalidação pronta; o plano da petição já o apontava como destino natural de um Pixel global. _(assumido — validar com produto)_
## Referências
- `docs/plans/pixel-meta-abaixo-assinado.md` — precedente integral (decisões, gatilhos, rejeitados)
- `src/collections/Petition.ts` — campo `tracking.facebookPixelId` + validação/normalização
- `src/components/MetaPixel.tsx` + `src/lib/facebookPixel.ts` — base code e `trackMetaLead` (reuso)
- `src/globals/SiteSettings.ts` — global provável dono do campo
- `src/app/(frontend)/(home)/page.tsx` — home pública de campanha
- S9 — form de captura de novidades (dependência do `Lead`)

View file

@ -1,172 +0,0 @@
# Impl: Seção de vídeo da história na home de campanha (S8)
Status: em execução
Atualizado em: 2026-08-19
Issue: #91
Intenção: docs/plans/secao-video-historia-home.md
Appetite restante: herdado (~1 dia eng)
## Divergência de produto aprovada pelo humano (2026-08-19)
**Embed do YouTube no lugar de self-hosted.** O plano de intenção previa asset próprio
(WebM/AV1 + MP4/H.264 em `public/videos/`, lazy total, poster extraído/fornecido). Durante a
execução o humano constatou que o vídeo já está publicado no canal do deputado
(https://www.youtube.com/shorts/i_fbclWWC5o — "18 de agosto de 2026", confirmado pelo dono do
produto como o vídeo da história; a assessoria usa títulos datados) e **decidiu embutir o
YouTube**, divergindo do aceite "sem dependência de terceiros". Motivação do produto: cada play
embutido conta como view no canal (alimenta o algoritmo; o canal já é vitrine na seção
"Acompanhe de perto"). Aceite atualizado:
- Seção com o vídeo da história via **iframe `youtube-nocookie`** (LGPD), `playsinline`,
`loading="lazy"` nativo (iframe só é buscado perto do viewport), frame 9:16, `allowFullScreen`,
título a11y.
- **Deixa de valer:** lazy-until-click total, poster próprio, chip de duração, orçamento de
~15 MB de assets no repo (nada é commitado; os encodes one-off ficam em `data/encode/`,
gitignored, como artefato local caso se volte a self-hosted).
- **Mantém-se:** posição após "Bandeiras", cenas desktop (2 colunas) e mobile (empilhado) do
rascunho, CTA "Assistir à história" (agora rola até o player), zero schema/Consent, seções
existentes intactas.
## Leitura da intenção
Outcome e aceite da intenção (docs/plans/secao-video-historia-home.md), com a divergência
aprovada abaixo: seção com o vídeo da história na home pública, posição após "Bandeiras",
cenas desktop/mobile do rascunho aprovado, zero schema/migration/Consent, seções existentes
intactas, original de 236 MB fora do repo. O que permanece NÃO negociável: copy/pt-BR e
identificadores em inglês; lazy (nada pesado baixa sem o usuário pedir — o `loading="lazy"` do
iframe cobre isso); sem autoplay forçado; seções existentes intocadas.
## Abordagem recomendada
```mermaid
flowchart LR
YT["YouTube Short i_fbclWWC5o (canal do Solla)"] --> EMB["iframe youtube-nocookie (9:16, lazy, playsinline)"]
EMB --> SEC["CampaignStorySection (client island)"]
SEC --> HOME["src/app/(frontend)/(home)/page.tsx — após seção bandeiras"]
```
**Opções consideradas:** A) iframe do YouTube (decisão do humano) | B) `<video>` nativo
self-hosted com WebM/AV1 + MP4/H.264 (plano original)
**Recomendação (humano):** A — o vídeo já vive no canal; embed soma views ao canal e evita
15 MB de asset no repo; troca de vídeo sem deploy.
**Rejeitadas (pelo humano, sobre o plano aprovado):** B — dependência própria do aceite; os
artefatos de encode já produzidos (14.9 MB total, validados por design-vision) ficam em
`data/encode/` como fallback local.
### Decisões de engenharia
- **Embed (decisão do humano, supersede o formato/encode do plano original).** iframe
`youtube-nocookie.com/embed/i_fbclWWC5o` (privacy LGPD) com `playsinline=1&rel=0`, 9:16,
`loading="lazy"` (busca só perto do viewport), `allow` (autoplay, fullscreen, gyroscope…),
`allowFullScreen`, `title` a11y. Rejeitadas: `youtube.com/embed` (versão com cookies), o
padrão de overlay próprio + click (não dá para disparar play cross-origin sem o IFrame API —
duplo clique no iOS, fragilidade sem ganho).
- **Formato/encode (histórico — não entra).** A conversão one-off foi executada e validada
(MP4 H.264 720×1280 CRF 27 + WebM AV1 810×1440 CRF 39, 14.9 MB total, poster AVIF da foto
JOA00327 escolhida pelo humano) e fica como artefato local em `data/encode/` (gitignored) —
fallback pronto se um dia se voltar a self-hosted. Nada disso é commitado.
- **Posição na home.** Opções: A) após conteúdos | B) após "Bandeiras" (fim da página, antes do
footer) | C) página própria. **Recomendação: B** (da intenção) — zona de conversão acima da
dobra intacta, fechamento emocional no fim, semente da seção 6 "Quem é Jorge Solla" do plano do
site. Rejeitadas: A porque compete com o bloco do problema no funil; C porque estoura o
appetite de "uma seção na home".
- **CTA "Assistir à história".** Botão que rola (smooth) até o player — play cross-origin não é
disparável de fora do iframe; o visitante chega no frame com o player do YouTube à mostra.
- **Estilos.** Seguir o padrão atual `CampaignContentSection` (container `max-w-[1160px]`,
`px-5 sm:px-8 lg:px-10 py-12 lg:py-16`, tokens `--campaign-band`/`--campaign-line`/
`--campaign-muted`/`--pt-red`) — seção em fluxo, não o padrão absoluto de `problem`/`flags`.
Fundo branco (contraste entre `--campaign-band` de bandeiras e o footer `#180a09`).
### Componentes / mudanças
- **`CampaignStorySection`** (`src/components/CampaignStorySection.tsx`): client island (`'use
client'`, precedente `CampaignCarousel`) — copy (eyebrow/título/copy do rascunho), frame 9:16
com iframe do YouTube (nocookie, lazy, playsinline); CTA rola até o player.
`data-home-section="story"`.
- **`page.tsx`** (`src/app/(frontend)/(home)/page.tsx`): inserir `<CampaignStorySection />` após
a seção `bandeiras`, antes do footer. Nenhuma seção existente é tocada.
- **Assets:** nenhum commitado (o vídeo vive no YouTube; encodes em `data/encode/` gitignored).
- **Migration:** sem migration (zero schema).
- **Access / Consent:** N/A — conteúdo estático público.
- **UI:** Impeccable B — seguir o rascunho aprovado (desktop 2 colunas / mobile empilhado);
shape → craft → critique → polish contra o rascunho; verificação mobile 320–430px (o frame
9:16 full-width fica alto — checar folga vertical e sem overflow horizontal).
### Dados → forma (se aplicável)
- N/A: sem métrica/KPI. Nenhum dado estático (o player do YouTube traz o próprio thumbnail e
duração).
## Fases verificáveis
1. ~~**Encode (tracer)**~~ — executado como fallback antes da divergência: encodes H.264 + AV1
(14.9 MB) + poster AVIF da foto JOA00327 (validados por design-vision), retidos em
`data/encode/` (gitignored), fora do commit.
2. **UI** — `CampaignStorySection` (embed) + wiring no page; shape/craft contra o rascunho;
crítica visual (design-vision) + polish; e2e novo em `frontend.e2e.spec.ts` (seção presente
após bandeiras, iframe com src nocookie + `loading="lazy"` + `allowfullscreen`, CTA rola até
o player, mobile sem overflow; requests do YouTube abortados no teste para o CI ficar
hermético).
3. **Gates** — `pnpm gate:fast`, `pnpm format:check`, `pnpm exec knip`, `pnpm check:cycles`,
`pnpm test`, `pnpm build`; e2e no CI; push via `pnpm push`.
## Conflitos de rebase resolvidos (2026-08-19)
- **S9 mergeou em main durante a execução** e inseriu `CampaignNewsletterSection` no mesmo ponto
(após bandeiras, antes do footer). Resolução: **história → newsletter → footer** — o funil
põe o "ask" (formulário de captura) depois do fechamento emocional; S9 declara "logo acima do
rodapé" e S8 "após bandeiras"; ambas as intenções ficam satisfeitas.
- **Changelog agregado**: conflito insert-only resolvido com `git checkout --theirs` +
`pnpm changelog:build` (S8 re-anexado sobre o agregado com S7/S9).
- Nota de ambiente: o e2e local (dev server) acusa warnings de React keys em
`CampaignColumnPicker`/omnibox de assessores (B137/B197, era C139) que o guard falha — são
**dev-only** (build de produção do CI não emite warnings de keys), pré-existentes em main e
fora do escopo S8.
## Débitos (capture-review-debts — triage autônoma)
- **Absorvidos nesta entrega:** JSDoc enxuto; e2e sem `toHaveURL` redundante; asserts de
desktop (2 colunas, centro vertical) + `allow`/`playsinline`; plano sem conteúdo morto da
era self-hosted; `compute-pressure` no `allow` do iframe (silencia o console.error do
player do YouTube — guard do e2e falha em console.error) + abort global do
`youtube-nocookie` no fixture `e2eTest.ts` (hermeticidade CI, mesma filosofia dos stubs de
feed).
- **Diferidos (registrados aqui, não como Issue):** (1) rótulo do CTA "Assistir à história"
promete ação que só rola — copy aprovada no gate, alternativa mais honesta ("Ver a história
em vídeo") se a assessoria reabrir o copy; (2) classe do CTA duplica
`sectionHeaderLinkClassName` de `CampaignContentSection` — consolidar quando S5 (#33)
terminar a separação da seção de conteúdos (evitar conflito com trabalho in-progress).
- **Descartados:** Safari <16.4/Firefox <121 ignoram `loading="lazy"` em iframe (carrega
eager — degradação graciosa, sem ação); artefatos de encode self-hosted em `data/encode/`
(gitignored, fallback documentado).
## Rabbit holes / Não escopo (engenharia)
- Player JS / playlist / tracking de views próprias (o embed já conta views no YouTube) /
analytics de playback.
- Overlay próprio + click para "autoplay" do embed (cross-origin; IFrame API é fragilidade sem
ganho — o player do YouTube já tem o próprio play).
- Pipeline de transcodificação versionado no repo (conversão one-off local, não commitada).
- Legendas/transcrição (item futuro separado, fora do escopo da intenção).
- Trocar o vídeo por outro Short: mudança de ID no `src` do iframe (um lugar).
## Riscos e mitigação
- **Embed depender do YouTube** (risco aceito pelo humano): se o Short cair/for bloqueado, a
seção degrada para o quadro cinza com o player vazio. Mitigação: o fallback self-hosted
(artefatos em `data/encode/`) fica pronto para reverter em horas.
- **Short trocado/ID errado** → troca de ID no `src` do iframe (um lugar).
- **CI/e2e tocando o YouTube** → `page.route` aborta `youtube-nocookie.com` no teste; os
asserts são estruturais (src, lazy, allowfullscreen, posição), não de conteúdo do player.
- **Iframe pesado no mobile** → `loading="lazy"` nativo (busca só perto do viewport) + seção no
fim da página.
## Aceite de engenharia
- [x] Aceite de produto (atualizado pelo humano em 2026-08-19): seção com o vídeo da história
via embed do YouTube (nocookie, playsinline, lazy, 9:16), posição após Bandeiras, zero
schema, seções existentes intactas
- [x] Invariantes AGENTS/engineering-standards (sem schema/Consent/access; i18n pt-BR na UI,
identificadores em inglês; sem tocar seções existentes)
- [x] Testes previstos: e2e da seção (estrutura + scroll + mobile) — sem unit (JSX estático;
nenhuma lógica de domínio pura a testar)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

View file

@ -1,132 +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 — Seção de vídeo da história 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 pública de campanha (jorgesolla1313). Nova seção com o vídeo de
apresentação da história do Dep. Jorge Solla — frame vertical 9:16 (vídeo SOLLA_P2_V1,
~1:40), clique para assistir, controles nativos. Cenas: desktop, mobile e estado de player.
</p>
<!-- ============ CENA: desktop ============ -->
<section
data-shot="desktop"
class="mt-8 overflow-hidden rounded-2xl bg-white p-8 text-zinc-900 shadow-xl"
>
<div class="flex items-center justify-between gap-12">
<div class="max-w-md">
<p class="text-xs font-extrabold tracking-[0.14em] text-red-700 uppercase">
Nossa história
</p>
<h2 class="mt-2 text-3xl font-extrabold tracking-tight text-zinc-900">
Conheça a trajetória de Jorge Solla
</h2>
<p class="mt-3 text-sm leading-relaxed text-zinc-500">
A história de quem já fez saúde pública na Bahia e no Brasil, em um minuto e meio: da
gestão no SUS ao mandato na Câmara.
</p>
<div class="mt-6 flex items-center gap-3">
<a href="#" class="text-sm font-bold text-red-700 underline-offset-4 hover:underline"
>Assistir à história</a
>
</div>
</div>
<!-- frame vertical 9:16 (poster + play) -->
<div class="relative w-[300px] shrink-0">
<div class="relative aspect-[9/16] overflow-hidden rounded-2xl bg-zinc-300">
<div
class="absolute right-2 bottom-2 rounded bg-zinc-900/80 px-1.5 py-0.5 text-[11px] font-bold text-white"
>
1:40
</div>
</div>
<div class="absolute inset-0 flex items-center justify-center">
<div
class="flex h-16 w-16 items-center justify-center rounded-full bg-zinc-900 shadow-lg"
aria-label="Reproduzir vídeo"
>
<span
class="ml-1 h-0 w-0 border-y-8 border-l-[14px] border-y-transparent border-l-white"
/>
</div>
</div>
</div>
</div>
</section>
<!-- ============ CENA: mobile (~390px) ============ -->
<section
data-shot="mobile"
class="mt-8 w-[390px] overflow-hidden rounded-2xl bg-white p-6 text-zinc-900 shadow-xl"
>
<p class="text-xs font-extrabold tracking-[0.14em] text-red-700 uppercase">
Nossa história
</p>
<h2 class="mt-2 text-2xl font-extrabold tracking-tight text-zinc-900">
Conheça a trajetória de Jorge Solla
</h2>
<p class="mt-2 text-sm leading-relaxed text-zinc-500">
A história de quem já fez saúde pública na Bahia e no Brasil, em um minuto e meio.
</p>
<!-- frame vertical 9:16 full-width -->
<div class="relative mt-5">
<div class="relative aspect-[9/16] overflow-hidden rounded-2xl bg-zinc-300">
<div
class="absolute right-2 bottom-2 rounded bg-zinc-900/80 px-1.5 py-0.5 text-[11px] font-bold text-white"
>
1:40
</div>
</div>
<div class="absolute inset-0 flex items-center justify-center">
<div
class="flex h-14 w-14 items-center justify-center rounded-full bg-zinc-900 shadow-lg"
aria-label="Reproduzir vídeo"
>
<span
class="ml-1 h-0 w-0 border-y-7 border-l-[12px] border-y-transparent border-l-white"
/>
</div>
</div>
</div>
</section>
<!-- ============ CENA: player (controles nativos visíveis) ============ -->
<section
data-shot="player"
class="mt-8 overflow-hidden rounded-2xl bg-white p-8 text-zinc-900 shadow-xl"
>
<div class="mx-auto flex max-w-md flex-col items-center">
<p class="text-xs font-extrabold tracking-[0.14em] text-red-700 uppercase">
Nossa história
</p>
<h2 class="mt-2 text-center text-2xl font-extrabold tracking-tight text-zinc-900">
Conheça a trajetória de Jorge Solla
</h2>
<div class="relative mt-5 w-[300px]">
<div class="relative aspect-[9/16] overflow-hidden rounded-2xl bg-zinc-300"></div>
<!-- barra de controles nativa (representação) -->
<div
class="absolute right-0 bottom-0 left-0 flex items-center gap-2 rounded-b-2xl bg-zinc-900/85 px-3 py-2"
>
<span class="h-3 w-3 rounded bg-white/90" />
<span class="h-0.5 flex-1 rounded bg-zinc-600">
<span class="block h-0.5 w-1/3 rounded bg-white" />
</span>
<span class="text-[10px] font-bold text-white">0:31</span>
<span class="text-[10px] text-white/60">/ 1:40</span>
<span class="h-3 w-3 rounded bg-white/90" />
</div>
</div>
</div>
</section>
</main>
</body>
</html>

View file

@ -1,156 +0,0 @@
# Seção de vídeo da história na home de campanha (S8)
Status: registrado
Atualizado em: 2026-08-19
Issue: #91
Priority: P1
Model: composer-2.5
Impeccable: B — nova seção na home pública (superfície que o visitante vê/toca)
Rascunho UI: docs/plans/secao-video-historia-home-ui-draft.html + PNGs embutidos abaixo
Appetite: ~1 dia eng; uma seção na home + conversão one-off do vídeo
Responsável: —
## Intenção
A home pública de campanha (jorgesolla1313.com.br) apresenta o candidato só com fotos e
números: o hero mostra o rosto, a prova social mostra os feitos, mas ninguém **conta a história**.
Existe um vídeo pronto de apresentação da trajetória do Dep. Jorge Solla — `SOLLA_P2_V1.mp4`
(~1:40, vertical 1080×1920) — que é exatamente esse material. O problema: o arquivo original tem
**236 MB**, inviável para a rede. Este item publica o vídeo numa seção nova da home, convertido
para um formato leve de web (WebM/AV1 ou o mais moderno disponível), para tocar no site sem
travar o carregamento e sem depender de YouTube.
## Persona e fluxo
- **Persona / contexto:** visitante da home de campanha (eleitor baiano, maioria no celular,
chegou pelo WhatsApp/redes, no meio do funil) — viu o hero e a prova social, quer saber
"quem é esse cara" antes de decidir apoiar.
- **Job principal:** conhecer a história de Jorge Solla de forma rápida e humana (1 minuto e
meio de vídeo) sem sair da página e sem esperar carregamento pesado.
- **Fluxo desejado:** rola a home → encontra a seção (eyebrow + título + frame vertical 9:16)
→ aperta play → assiste à história → continua navegando (bandeiras, apoiar).
- **Anti-goals de produto:** a seção NÃO compete com o CTA primário acima da dobra; NÃO
autoplay com som (barrado por browsers e ruído para quem não pediu); NÃO vira player
sofisticado/library; NÃO depende de plataforma de terceiros (YouTube) para existir.
### Esboço de fluxo (B)
```text
[home: hero → prova social → conteúdos] → [SEÇÃO: eyebrow + título + frame 9:16 com poster]
→ [clique no play] → [vídeo toca com controles nativos] → [termina / volta à página]
```
### Rascunho UI (B)
![Rascunho UI — desktop](secao-video-historia-home-ui-draft-desktop.png)
![Rascunho UI — mobile](secao-video-historia-home-ui-draft-mobile.png)
![Rascunho UI — player](secao-video-historia-home-ui-draft-player.png)
Fonte iterável: [`secao-video-historia-home-ui-draft.html`](secao-video-historia-home-ui-draft.html).
## Objetivo e aceite
- A home pública exibe uma seção dedicada com o vídeo da história (eyebrow "Nossa história" +
título + frame vertical 9:16 com poster e botão play), nas cenas desktop (2 colunas) e mobile
(empilhado) do rascunho.
- O vídeo toca em todos os browsers da audiência: Chrome, Firefox e Edge (Android e desktop) e
Safari **incluindo iOS** — a escolha de formato garante playback universal (WebM sozinho quebra
iOS Safari; ver Questões em aberto).
- O vídeo é **leve para rede**: conversão do original de 236 MB para formato(s) web com peso
alvo ≤ ~15 MB no total; o player não baixa o vídeo até o usuário apertar play (lazy).
- Poster extraído do próprio vídeo (frame representativo), controles nativos, `playsInline`
(sem abrir player fullscreen forçado no iOS), sem autoplay com som.
- O arquivo original (236 MB) NÃO entra no repositório nem no fluxo de deploy.
- Zero mudança de schema/migration/Consent: é conteúdo estático da home pública, sem coleção
nova (ver Questões em aberto sobre onde o arquivo mora).
## Dados (intenção)
Dados: N/A — o item publica um **conteúdo** (vídeo), não métrica nem KPI. Nenhum número novo
de campanha é inventado aqui (a duração "1:40" do chip é propriedade do vídeo, não estatística).
## Direção no codebase (hipótese)
- **Áreas prováveis:** `src/app/(frontend)/(home)/page.tsx` (inserir a seção), novo componente
de seção/player em `src/components/`, estilos no bloco `[data-theme='campaign-site']` de
`src/app/(frontend)/styles.css` (padrão das seções atuais), vídeo servido como asset estático
em `public/videos/` (precedente: imagens de campanha em `public/`) — ou via coleção `media`
(decisão no gate).
- **Precedente a olhar:** seção de conteúdos (S1/S2/S3, `CampaignContentSection`) para o ritmo
visual de seção (eyebrow/título/copy); `CampaignHero` para como o candidato é apresentado
hoje; seção 6 "Quem é Jorge Solla" do wireframe (`docs/campanha/wireframe-solla-1313.html`) —
esta seção de vídeo é a semente daquela posição do plano do site
(`docs/campanha/plano-site-campanha-2026.md` §3.6).
- **Risco de acoplamento:** seção nova convive com as existentes sem mexer nelas (S5, em
andamento, trata a separação visual da seção de conteúdos — a posição relativa decide no
gate); se a entrega usar a coleção `media`, respeitar os guards/plugins de storage existentes
(S3) sem criar caminho paralelo de escrita.
## Dependências
- Nenhuma dura.
- Soft: S5 (#33, in-progress) — separação visual da seção de conteúdos; seção 6 do wireframe
(linha do tempo "Quem é Jorge Solla") continua fora de escopo, o vídeo a antecipa em parte.
## Fora de escopo
- Linha do tempo + etiquetas de esfera + formação/militância da seção 6 do wireframe → item
futuro (esta entrega é só o vídeo).
- Legendas/transcrição acessível do áudio → item futuro separado, se a assessoria pedir.
- Hospedar no YouTube / embeds de terceiros (o vídeo é asset próprio, no próprio site).
- Player customizado, biblioteca de vídeo, tracking de views, analytics de playback.
- Alterar hero, prova social ou qualquer seção existente; redesign da home.
## Rabbit holes de produto
- **Player sofisticado / biblioteca.** Se alguém "só completar": integra player JS, capas,
tracking, playlist. **Corte neste item:** `<video>` nativo com controles nativos; capa + botão
play são o poster/estado inicial.
- **Pipeline de transcodificação no repo/CI.** Se alguém "só completar": script de conversão
versionado, re-encode a cada deploy. **Corte:** conversão **one-off** local com ffmpeg (não
instalado na máquina hoje — executor instala ou usa container); os arquivos convertidos são
artefato da entrega, não processo.
- **Commit do original no git.** Se alguém "só completar": o repo cresce 236 MB. **Corte:** só
os arquivos convertidos entram; o original fica fora do repo (referência no plano impl).
- **Autoplay para "engajar".** Se alguém "só completar": autoplay muted loop como teaser.
**Corte:** click-to-play — o vídeo é narrado (~1:40); o visitante escolhe ouvir a história.
## Questões em aberto (produto)
- **Onde a seção fica na home?** **Opções:** A) logo após a seção de conteúdos (posição 4 atual,
antes de "Por que essa eleição importa") — história sobe no funil | B) após "Bandeiras"
(posição 6 do wireframe, onde o plano do site reserva "Quem é Jorge Solla") — respeita a
estrutura aprovada e não compete com o bloco do problema | C) página própria (ex.
`/nossa-historia`) linkada da home. **Recomendação: B** — o vídeo é a semente da seção 6 do
plano do site; a zona de conversão acima da dobra fica intacta; o fim da página ganha um
fechamento emocional. _(assumido — validar)_
- **Formato de entrega do vídeo?** **Opções:** A) **dupla**: WebM (AV1 — o codec mais moderno e
leve da atualidade) + MP4 (H.264) como fallback universal — melhor peso/qualidade e cobre
todos os browsers, incluindo iOS Safari, que não toca WebM | B) só MP4 H.264 — universal,
mais simples, um arquivo só, um pouco mais pesado | C) só WebM (AV1) — menor, mas iOS Safari
fica de fora. **Recomendação: A** — audiência de campanha tem muitos iPhones; AV1 sozinho
excluiria Safari; H.264 sozinho é o caminho mais simples se o peso alvo não for alcançável em
dois formatos. _(assumido — validar)_
- **Onde o arquivo convertido mora?** **Opções:** A) estático em `public/videos/`, commitado —
precedente das imagens de campanha em `public/`, deploy automático junto do app, PR revisa o
arquivo | B) coleção `media` + Garage S3 — admin troca o vídeo sem deploy, mas exige upload
para produção e o proxy de mídia. **Recomendação: A** — asset fixo da campanha, autossuficiente
e revisável; B se a troca de vídeo sem deploy virar necessidade real. _(assumido — validar)_
- **Peso-alvo do vídeo?** **Opções:** A) ≤ ~15 MB somando os formatos (referência de mercado
para 1:40 em 1080×1920) | B) ≤ ~5 MB (compressão agressiva, qualidade visivelmente pior).
**Recomendação: A** — a história tem valor emocional; qualidade de imagem importa, e 15 MB
carrega rápido em 4G com lazy. _(assumido — validar)_
## Referências
- Vídeo original: `/home/fsolla/Downloads/SOLLA_P2_V1.mp4` (236 MB, ~1:40, 1080×1920 — fora do
repo; artefato da sessão que entregar).
- Rascunho UI (gate): `docs/plans/secao-video-historia-home-ui-draft.html` + PNGs acima
- Plano geral do site: `docs/campanha/plano-site-campanha-2026.md` (§3.6 "Quem é Jorge Solla",
§4.1 doutrina de conversão)
- Wireframe aprovado: `docs/campanha/wireframe-solla-1313.html` (seção 6)
- `src/app/(frontend)/(home)/page.tsx` — onde a seção entra
- `src/components/CampaignContentSection.tsx` — padrão visual de seção (eyebrow/título/copy)
- `AGENTS.md` — conventions do site público (i18n pt, naming, cache/revalidação se aplicável)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

Some files were not shown because too many files have changed in this diff Show more