Compare commits

..

2 commits

Author SHA1 Message Date
Francisco Solla
d81f941337 style(S8): prettier no rascunho UI
All checks were successful
CI (PR) / checks (pull_request) Successful in 1m24s
PR Ready + auto-merge / ready-automerge (pull_request) Successful in 1m32s
Main merge flips claimed issues to done + in-prod / issue-done (pull_request) Successful in 15s
Promote plan-issue Related on main merge / plan-issue-ready (pull_request) Successful in 13s
2026-08-19 15:53:51 -03:00
Francisco Solla
0d5586d4cd docs(S8): plano de intenção — seção de vídeo da história na home de campanha 2026-08-19 15:52:38 -03:00
226 changed files with 1900 additions and 80719 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 alwaysApply: true
--- ---
# Agent PR workflow (Teqo) # 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) ## Create (Ready, base main)
```bash ```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 é ## O que é
@ -25,12 +25,11 @@ Closes #<N>
- **Never** `--draft`. PRs must be **Ready for review** on creation. - **Never** `--draft`. PRs must be **Ready for review** on creation.
- Base is always **`main`**. - 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`). - **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` ### 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`**). - **`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). - Never end a turn with an open agent PR left as **Draft** — inclui PRs do `engineering-audit` (artefatos e remediações).
## Push (determinístico) ## Push (determinístico)
@ -38,51 +37,66 @@ Closes #<N>
**Use `pnpm push`, never bare `git push`.** Não depende do Husky estar wired: **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` 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) 3. `git push --no-verify` — só porque o gate já rodou no passo 2 (não é escape hatch)
```bash ```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 # só depois de `pnpm i` — hook repete ensure-deps + gate:push
git push --no-verify # WIP / cutover escape — CI repete tudo; ver AGENT-OPS git push --no-verify # WIP / cutover escape — CI repete tudo; ver AGENT-OPS
``` ```
## Auto-merge immediately ## 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 ## 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. OPS64: the rollup is structurally honest since OPS62 — ci-pr.yml is a SINGLE
sequential job (`checks`), so its own status IS the cascade verdict (no
matrix, no shards, no parent aggregation to lie). The historical bug (Forgejo
9.0.3 reporting the LAST matrix shard's result as the parent's in `needs`,
posting `checks` SUCCESS with an e2e shard red — runs 604/607/610) is gone by
construction; the `needs.<job>.results` array of GitHub does not exist in this
runner, so any future matrix must NOT aggregate via `needs`. The safety-net
CLI pins the defense: it never trusts the rollup alone (scans individual
statuses; run-607 scenario unit-tested in `forgejoApi.unit.spec.ts`).
(`migration-lock` removed 2026-08-12 — the CI validates migration chains via migrate + int on every PR.) (`migration-lock` removed 2026-08-12 — the CI validates migration chains via migrate + int on every PR/main.)
**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. **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.
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). 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).
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 ## 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) ## Safety net (workflow)
`agent-pr-ready-automerge.yml` — PRs same-repo → `main` (todas as branches). `agent-pr-ready-automerge.yml` — PRs same-repo → `main` (todas as branches).
O workflow roda `scripts/github-pr-automerge.mjs` (plain Node, zero-dep, O workflow roda `scripts/forgejo-pr-automerge.mjs` (plain Node, zero-dep): se
REST+GraphQL): se draft `cursor/*` → marca Ready (PATCH `draft: false`); arm draft `cursor/*` → marca Ready; espera os checks e mergea por rebase (o
`enablePullRequestAutoMerge` (GraphQL, mergeMethod REBASE) no `node_id` do PR equivalente Forgejo do auto-merge — sem `gh`). O wait exige o rollup
— o **servidor** espera o required check `CI (PR) / checks` POSTADO e verde `CI (PR) / checks` POSTADO e verde (OPS61) — o único contexto que só existe
antes de mergear; o próprio check-run do safety net fica `pending` durante o quando a cascata inteira do ci-pr assentou (jobs ainda não agendados não
run e não interfere. A regra de branch protection no servidor postam status; o próprio status do job do safety net é `pending` durante o run
(`enforce_admins: true`) bloqueia qualquer caminho de merge — inclusive API e **nunca** entra no veredito). O CLI nunca confia no rollup sozinho: varre
manual — enquanto o required check não estiver satisfeito (foi um merge todos os statuses individuais e falha em qualquer `failure`/`error` — um shard
manual que colocou a PR #52 em main com o CI vermelho na era Forgejo). de e2e vermelho bloqueia o merge mesmo com o rollup verde (OPS64; run 607).
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 Cobre o caso em que o
agente (ou o default draft do Cloud) esqueceu e PRs humanas/worktree com merge 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 é 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. 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. 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). 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 ## 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 - [ ] 1. Sessão: rename_chat + open_resource na intenção
- [ ] 2. Modelo: spawn do pool já fixou (`model:` da Issue) — sem comparação - [ ] 2. Modelo: spawn do pool já fixou (`model:` da Issue) — sem comparação
- [ ] 3. Plan mode → plano de implementação (`*-impl.md`) - [ ] 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) - [ ] 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 ## 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: (executar → simplify → fechar em main), com os deltas do ator pool:
- **Branch:** `agent/<id>-<slug>` (worktrees Cursor podem já ter criado). - **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). - **UI:** shape → craft → critique → polish (harden/optimize só sob gatilho).
- **`capture-review-debts`:** **autônomo** — só `expensive_lock` com score ≥4 - **`capture-review-debts`:** **autônomo** — só `expensive_lock` com score ≥4
(Issues novas com `depends: [<id-pai>]` se necessário); score ≤3 / (Issues novas com `depends: [<id-pai>]` se necessário); score ≤3 /
cheap_polish / defer_trigger → defer com gatilho no `*-impl.md` ou descarte — cheap_polish / defer_trigger → defer com gatilho no `*-impl.md` ou descarte —
**não** polua a fila. **não** polua a fila.
- **Cloud:** PR via `ManagePullRequest` com `draft: false` no **GitHub** - **Cloud:** PR via `ManagePullRequest` com `draft: false`; Prep Cloud no
(repo/PR agora no GitHub; o tracker de Issues segue no Forgejo — OPS71); Passo 0.
Prep Cloud no Passo 0.
## Resumo final ## 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. 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) ## 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) - [ ] 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) - [ ] 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** - [ ] 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 ## Passo 1 — Contexto da sessão
@ -96,12 +96,6 @@ nunca invente produto novo.
fechar em main), com os deltas do ator humano: fechar em main), com os deltas do ator humano:
- **Branch:** `<Code>-<slug>` do worktree — nunca crie branch nova na sessão. - **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. - **UI:** shape → craft → critique → polish.
- **`capture-review-debts`:** **autônomo** — colha, dedupe, pontue e **decida - **`capture-review-debts`:** **autônomo** — colha, dedupe, pontue e **decida
você mesmo o destino** (registrar/absorver/deferir/descartar) pela triage 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. testes de domínio. Invariantes do engineering-brief.
2. **UI** — se Impeccable B/C/D: shape → craft → critique → polish. Tokens 2. **UI** — se Impeccable B/C/D: shape → craft → critique → polish. Tokens
`data-theme='campaign'`; shells existentes. `data-theme='campaign'`; shells existentes.
3. **E2E local afetado (OPS72, discricionário)** — antes do push, rode 3. **Gates** — `pnpm gate:fast` na iteração; entrega com `pnpm push` (não
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
`git push` nu). `git push` nu).
Tracer bullet cedo se o item for grande. Inclua o `*-impl.md` no commit da Tracer bullet cedo se o item for grande. Inclua o `*-impl.md` no commit da
entrega. 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 ## /simplify + débitos
1. Rode o comando `/simplify` completo (3 reviewers paralelos via Task, 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` e rode `pnpm changelog:build` para regenerar `docs/CHANGELOG-AGENTS.md`
(insert-only: entradas históricas nunca mudam; o diff será só a entrada (insert-only: entradas históricas nunca mudam; o diff será só a entrada
nova + o agregado). Rode `pnpm changelog:check` para confirmar. nova + o agregado). Rode `pnpm changelog:check` para confirmar.
3. **`pnpm push -u origin HEAD`** — origin é o **GitHub** (OPS71; o tracker de 3. **`pnpm push -u origin HEAD`**
Issues continua no Forgejo por API). 4. PR via API/MCP (ou `ManagePullRequest` no Cloud) — **Ready** (nunca draft), base `main`, com `Closes #<N>`
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` espera o rollup `CI (PR) / checks` e mergea por rebase — nada a armar
5. O safety net `agent-pr-ready-automerge.yml` arma o **auto-merge nativo do 6. CI flipa `done`/`in-prod` no merge. Comente na Issue o desfecho em uma linha.
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.
## Deltas por ator ## 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 description: Cursor run id (run-…) optional cancel first
required: false required: false
type: string type: string
permissions:
contents: read
jobs: jobs:
archive: archive:
runs-on: ubuntu-latest runs-on: ubuntu-latest

View file

@ -1,71 +1,53 @@
# PR gate: feature → main (OPS71 — GitHub Actions). SINGLE sequential job # PR gate: feature → main. SINGLE sequential job (OPS62, X1): one container,
# (OPS62, X1): one container, one checkout, one pnpm install, one runner slot. # one checkout, one pnpm install, one runner slot. Fail-fast is STRUCTURAL —
# Fail-fast is STRUCTURAL — a failed step skips every step after it, the job # a failed step skips every step after it, the job fails, the run fails and
# fails and the run fails. GitHub-native `concurrency` cancels the in-flight # the slot is released immediately. No cancel-run API (Forgejo 9.0.3 has
# run when the PR gets a new push (the Forgejo era had no cancel API; here it # none), no watchdog job, no upgrades: with one job there are no siblings to
# is free and matches the OPS62 "one slot per PR" spirit). # 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
# The job id `checks` preserves the `CI (PR) / checks` check-run that the # automerger waitForChecks and branch protection rely on (OPS61).
# branch protection rule and the native auto-merge rely on (OPS71): GitHub
# names it <workflow name> / <job name> — keep both literals frozen. GitHub's
# auto-merge is server-side: it only merges with every required check green,
# so the OPS64 "rollup can lie" defense is structural here (single job posts
# one honest check-run; no matrix, no shards).
# #
# Job-level skips come from the `scope` classifier (scripts/ci-scope.mjs) run # 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: # as a step; its outputs drive `if:` on the heavy steps (same modes as the
# none/changed/full; e2e: none/selected/full). e2e policy (OPS72): the PR # former per-job cascade: none/changed/full).
# 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.
# #
# Schema migrations are NOT serialized across PRs (migration-lock removed # 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 # 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 # Forgejo port (OPS50): Postgres services live on the job network and are
# a fork PR would burn hosted minutes running arbitrary code. Same guard class # reached by NAME (postgres-int / postgres-build on 5432) — no host publish,
# as the RCE guard on the post-merge workflows. # so concurrent runs never collide on host ports (OPS62 X1). Two isolated
# # services keep e2e from inheriting int fixture residue.
# 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.
name: CI (PR) name: CI (PR)
on: on:
pull_request: pull_request:
branches: [main] branches: [main]
permissions:
contents: read
concurrency:
group: ci-pr-${{ github.head_ref }}
cancel-in-progress: true
env: env:
PAYLOAD_SECRET: test-only-secret-not-used-in-production PAYLOAD_SECRET: test-only-secret-not-used-in-production
NEXT_PUBLIC_SITE_URL: http://localhost:3000 NEXT_PUBLIC_SITE_URL: http://localhost:3000
jobs: jobs:
checks: checks:
if: github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 50 timeout-minutes: 50
services: 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: postgres-int:
image: postgres:17-alpine image: postgres:17-alpine
env: env:
POSTGRES_USER: teqo POSTGRES_USER: teqo
POSTGRES_PASSWORD: teqo POSTGRES_PASSWORD: teqo
POSTGRES_DB: teqo_test POSTGRES_DB: teqo_test
ports:
- 5432:5432
options: >- options: >-
--health-cmd "pg_isready -U teqo" --health-cmd "pg_isready -U teqo"
--health-interval 5s --health-interval 5s
@ -77,8 +59,6 @@ jobs:
POSTGRES_USER: teqo POSTGRES_USER: teqo
POSTGRES_PASSWORD: teqo POSTGRES_PASSWORD: teqo
POSTGRES_DB: teqo_test POSTGRES_DB: teqo_test
ports:
- 5433:5432
options: >- options: >-
--health-cmd "pg_isready -U teqo" --health-cmd "pg_isready -U teqo"
--health-interval 5s --health-interval 5s
@ -153,11 +133,11 @@ jobs:
- name: Prepare database URL (int service) - name: Prepare database URL (int service)
run: | run: |
set -euo pipefail 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 # vitest re-loads .env.test over the process env, so the service URL
# must win through the .env.test.local layer (same mechanism # must win through the .env.test.local layer (same mechanism
# `pnpm worktree next` uses). # `pnpm worktree next` uses). OPS50.
echo "DATABASE_URL=postgresql://teqo:teqo@localhost:5432/teqo_test" > .env.test.local echo "DATABASE_URL=postgresql://teqo:teqo@postgres-int:5432/teqo_test" > .env.test.local
- name: Migrate (int database) - name: Migrate (int database)
run: pnpm migrate run: pnpm migrate
- name: Seed minimal (int database) - name: Seed minimal (int database)
@ -166,10 +146,10 @@ jobs:
- name: Integration tests (full suite) - name: Integration tests (full suite)
if: steps.scope.outputs.test_mode == 'full' if: steps.scope.outputs.test_mode == 'full'
env: env:
# vitest forks ≈ CPUs and each fork holds a Payload pool (max 10) → # Self-hosted runner has 8 CPUs; vitest forks ≈ CPUs and each fork
# overflows the service Postgres' 100 clients ("too many clients # holds a Payload pool (max 10) → overflows the service Postgres' 100
# already"). 4 forks stay well under. The unit step above stays # clients ("too many clients already", CI run 180). 4 forks stay
# CPU-relative (no DB). # well under. The unit step above stays CPU-relative (no DB).
VITEST_MAX_WORKERS: 4 VITEST_MAX_WORKERS: 4
run: pnpm test:int run: pnpm test:int
- name: Integration tests (changed since PR base) - name: Integration tests (changed since PR base)
@ -179,16 +159,16 @@ jobs:
run: pnpm test:int -- --changed origin/${{ github.base_ref }} --passWithNoTests run: pnpm test:int -- --changed origin/${{ github.base_ref }} --passWithNoTests
- name: Prepare database URL (build/e2e service) - 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: | run: |
set -euo pipefail set -euo pipefail
echo "DATABASE_URL=postgresql://teqo:teqo@localhost:5433/teqo_test" >> "$GITHUB_ENV" echo "DATABASE_URL=postgresql://teqo:teqo@postgres-build:5432/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" > .env.test.local
- name: Migrate (build/e2e database) - 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 run: pnpm migrate
- name: Seed minimal (build/e2e database) - 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 run: pnpm db:seed:minimal
- name: Build (against the local service database) - name: Build (against the local service database)
@ -196,7 +176,7 @@ jobs:
run: pnpm build run: pnpm build
- name: Cache Playwright browsers - name: Cache Playwright browsers
if: steps.scope.outputs.e2e_mode == 'selected' if: steps.scope.outputs.e2e_mode != 'none'
uses: actions/cache@v4 uses: actions/cache@v4
with: with:
path: ~/.cache/ms-playwright path: ~/.cache/ms-playwright
@ -204,23 +184,26 @@ jobs:
restore-keys: | restore-keys: |
playwright-${{ runner.os }}- playwright-${{ runner.os }}-
- name: Install Playwright chromium - 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 run: pnpm exec playwright install --with-deps chromium
- name: Build for e2e - name: Build for e2e
# Selected mode needs the build too (the Playwright webServer serves # Selected mode needs the build too (the Playwright webServer serves
# the `.next-e2e` production build). # .next-e2e; the fallback `.next-e2e` dir is what gate-ci.mjs builds).
if: steps.scope.outputs.e2e_mode == 'selected' if: steps.scope.outputs.e2e_mode != 'none'
run: pnpm build run: pnpm build
env: env:
NEXT_DIST_DIR: .next-e2e NEXT_DIST_DIR: .next-e2e
- name: E2E tests (blast radius — manifest-selected specs) - name: E2E tests (full suite, single job, 4 workers)
if: steps.scope.outputs.e2e_mode == 'selected' if: steps.scope.outputs.e2e_mode == 'full'
# OPS72: the PR never runs e2e full — mode `full` (high-risk diff)
# skips this step by design (local mirror + deploy verify cover it).
# Explicit CI workers; the local default stays 2 (playwright.config.ts). # Explicit CI workers; the local default stays 2 (playwright.config.ts).
# 4 workers in one process replace the former 2 shards × 2 workers. # 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: env:
PLAYWRIGHT_WORKERS: 4 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 }} 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 # Proprietary design font files kept only in the local workspace
/public/arial-mt/ /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: > **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`). > 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. > 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. 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. > 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 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`). > 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`. > 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`. > 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. > **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: `.github/workflows/ci-pr.yml` · Deploy: `.github/workflows/deploy.yml`. > 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. 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 # 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) ## Ambientes de dados (test ladder — nunca misturar)
| Ambiente | Conteúdo | Quem usa | | 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 | | **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) | | **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`. 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 ## Fluxo
```text ```text
claim → feature branch → PR --base main (GitHub) → CI PR green (cascade + skips) → auto-merge nativo em main (rebase) claim → feature branch → PR --base main → CI PR green (cascade + skips) → auto-merge em main
main → (opcional, deliberado) workflow_dispatch deploy.yml → verify full suite verde → job deploy no homeserver (OPS53) 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. - **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), `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`). - **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 ### Dono do PR, dono do CI
@ -36,29 +36,28 @@ main → (opcional, deliberado) workflow_dispatch deploy.yml → verify full sui
| Comando | Faz | | 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 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: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:status` | Overview / fila / mermaid |
| `pnpm agent:prioritize -- <issue> <P0..P3>` | Troca `prio:*` | | `pnpm agent:prioritize -- <issue> <P0..P3>` | Troca `prio:*` |
| `pnpm agent:file-miss -- --title ...` | Issue `kind:agent-miss` | | `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 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) | | `pnpm push [-- -u origin HEAD …]` | Canônico: ensure-deps + gate:push + push |
| `GITHUB_TOKEN=… node scripts/github-pr.mjs --head <b> --title <t> --body-file <f>` | Abre PR no **GitHub** (Ready, base `main`, nunca draft) | | `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) |
| `GITHUB_TOKEN=… pnpm configure:branch-protection [-- --dry-run]` | Proteção de `main` no **GitHub**: required check `CI (PR) / checks` (idempotente — cria/atualiza/no-op) |
| `pnpm db:seed:minimal` | DB mínimo sintético | | `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`. **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 ## Contrato de PR
1. Schema → atualizar `db:seed:minimal` no mesmo PR (`needs:migration`). 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. 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` (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. 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). 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) ## 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). - **`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. - 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 | | 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) | | `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) |
| `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) | | `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) + **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) | | `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) + **dispatch manual (recovery)** | — | `Related #N` aguardando plano → `ready` **no Forgejo** (OPS18; soft-skip; mesmo PAT) | | `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 `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 | | `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 | | `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`. Fast gate: `pnpm gate:fast`. Push: `pnpm push`.
### Secrets (humano, uma vez — repo GitHub) ### Secrets (humano, uma vez)
| Secret | Uso | | 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) | | `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) |
| `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 |
| `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). **OPS64:** o CLI nunca confia no rollup sozinho — varre os statuses individuais e falha em qualquer `failure`/`error` (pin em `forgejoApi.unit.spec.ts`, cenário run 607: rollup verde com shard de e2e vermelho → throw). Com o desenho de job único do OPS62 (sem matrix, sem shards) o status do job `checks` já é o veredito honesto da cascata — o bug de agregação do parent de matrix deixou de existir estruturalmente. 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). 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.
**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).
## Cursor Cloud ## 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 ## Agent pool

View file

@ -4,38 +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`). **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`. **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`.
@ -64,16 +32,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. **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):** **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):** **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** — a media saiu do Vercel Blob para o Garage S3 do homeserver (bucket `teqo-media` + key `teqo-key`, já existentes no Garage v2.1.0). O plugin `@payloadcms/storage-vercel-blob` (e o `@vercel/blob` do seed) foi trocado por `@payloadcms/storage-s3` na config, no padrão R2 da doc do Payload (`endpoint` + `forcePathStyle: true`, region `garage`). **Decisão de arquitetura (revisão A→B durante a execução, gate com o humano):** o Garage v2.1.0 **não suporta acesso anônimo na API S3** (sem `--public-read`; source: "when we support anonymous access in the future"; testado: `anonymous`/`anon` → `NoSuchAccessKey`), então o bucket fica **privado** e a app **serve a media por proxy** — sem `disablePayloadAccessControl`, o `staticHandler` do plugin streama o objeto do Garage em `/api/media/file/<filename>` (range + etag/304 nativos) — o **mesmo contrato de URL relativa que as 40 rows pré-existentes já têm** (a recuperação dos arquivos é a Issue #10/OPS52-media). **Fail-closed:** `resolveS3StorageEnv` (`src/utilities/mediaStorage.ts`, unit-testado) — envs `S3_*` parciais → throw no boot com a lista exata de faltantes; nenhuma → storage local (dev/test). Envs novas: `S3_BUCKET`, `S3_ENDPOINT`, `S3_REGION` (default `garage`), `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY` (`.env.example`, `.env.test`, worktree.mjs com cópia all-or-nothing). Seed de posts: pre-delete do Blob removido (o S3 sobrescreve o key determinístico nativamente — idempotência intacta). Prova de contrato real: PUT/GET/DELETE via SDK contra o Garage (tailnet `http://100.119.220.31:3900`), depois media criada pelo Payload local com S3 ativo → `media.url` relativa + `GET /api/media/file/...` → 200 image/png; bucket devolvido a 0 objects e `.env.local` do worktree limpo (sem credenciais de prod). Sem migration (config-only; importMap regenerado sem `VercelBlobClientUploadHandler`). Runbook de cutover registrado no impl plan: setar as 5 `S3_*` no `~/stack/teqo-1313.env` (endpoint tailnet) antes do próximo deploy (OPS53). Repo: `docs/plans/ops52-blob-vercel-para-garage-impl.md`; infra-solla `STATE.md` atualizado à parte.
**Recently resolved (2026-08-18):** **OPS52-media** — os objetos de media de produção (as ~40 capas dos posts, que existiam como rows com URL relativa `/api/media/file/<filename>` mas sem arquivo em lugar nenhum) são recuperáveis do WordPress de origem com `pnpm media:recover` (`scripts/recover-media.mjs`, novo): lê as rows de `media` + `post.coverImage` (mapeamento exato row→slug; fallback pelo filename determinístico `<slug>.<ext>`), resolve a capa com a MESMA lógica do seed (featured media do REST `_embed`, senão 1ª imagem inline do corpo — só 7/43 posts têm featured), baixa e faz `PutObject` no bucket das envs `S3_*` com key = `filename` da row, **sem nunca escrever no DB** (rows e URLs intocadas; o staticHandler do Payload passa a servir). Extraído para `scripts/lib/wpArticles.mjs` o fetch ao vivo do WordPress (seed-posts agora importa de lá — sem twin; unit-testado). Fail-closed: exige as 4 `S3_*` completas (`resolveS3StorageEnv` reusado; recuperação nunca vai pro storage local), `ALLOW_REMOTE_DB=true` explícito para DB remoto + echo do alvo (DB/bucket/endpoint), e timeouts curtos no SDK (`@smithy/node-http-handler`, 5s/15s) — endpoint inalcançável falha em segundos em vez de pendurar (a rota pública de media de prod pendurava >25s; evidência de possível falta de rota container→Garage, diagnosticada no runbook do impl plan). Modos: reconcile (padrão, upload idempotente por overwrite), `--dry-run` (plano + `headObject`), `--verify` (HEAD da URL pública por filename — o aceite da Issue, sem credencial). Runbook de execução no homeserver: `docs/plans/ops52-media-recuperar-arquivos-impl.md`. **Recently resolved (2026-08-18):** **OPS52-media** — os objetos de media de produção (as ~40 capas dos posts, que existiam como rows com URL relativa `/api/media/file/<filename>` mas sem arquivo em lugar nenhum) são recuperáveis do WordPress de origem com `pnpm media:recover` (`scripts/recover-media.mjs`, novo): lê as rows de `media` + `post.coverImage` (mapeamento exato row→slug; fallback pelo filename determinístico `<slug>.<ext>`), resolve a capa com a MESMA lógica do seed (featured media do REST `_embed`, senão 1ª imagem inline do corpo — só 7/43 posts têm featured), baixa e faz `PutObject` no bucket das envs `S3_*` com key = `filename` da row, **sem nunca escrever no DB** (rows e URLs intocadas; o staticHandler do Payload passa a servir). Extraído para `scripts/lib/wpArticles.mjs` o fetch ao vivo do WordPress (seed-posts agora importa de lá — sem twin; unit-testado). Fail-closed: exige as 4 `S3_*` completas (`resolveS3StorageEnv` reusado; recuperação nunca vai pro storage local), `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`.

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. - **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. - **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). **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 @@
**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 @@
**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 @@
**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 O deploy de produção é uma etapa do CI (`ci.yml` no Forgejo): mergir em `main`
Actions, `.github/workflows/deploy.yml`): mergir em `main` **não** publica com a suite verde publica o site em `jorgesolla1313.com.br` — sem passo manual.
nada. O operador dispara o deploy quando decide publicar; antes de tocar o Merge **com mudança de produção** = site atualizado em ~30 min (janela) + tempo
homeserver, o job `verify` (hosted) roda a suíte **full** (incl. e2e full). da suite/deploy (~15–25 min cada; tipicamente < 1 h no total).
## Gatilho e fluxo ## Gatilho e fluxo
1. GitHub → Actions → **Deploy (manual)** → Run workflow (ref: `main`). 1. `git push` para `main` (via PR merge) — o `ci.yml` **não dispara mais a cada
2. Job `verify` (hosted `ubuntu-latest`, ~50 min): suíte full sem skips — push**: roda por **janela fixa de 30 min** (`schedule */30`) ou via
check-test-locations → lint → format → typecheck → knip → cycles → unit → `workflow_dispatch` (deploy manual, imediato, sempre full suite).
int (migrate+seed nos services) → build → e2e full. 2. Job `gate` (barato, host, sem pnpm): lê a **revision do container rodando**
3. Se `verify` verde, o job `deploy` (`needs: [verify]`, no homeserver (`docker inspect` → label `org.opencontainers.image.revision`
`runs-on: [self-hosted, homeserver]`) roda no **runner do GitHub instalado do `teqo-1313` — a fonte de verdade; o compose pode mentir após rollback
no homeserver** e executa `scripts/deploy-homeserver.sh <sha>` **localmente** falho, pois é trocado antes do rollout) e classifica "mudança de produção"
(sem SSH; o runner conecta outbound ao GitHub — funciona atrás do com o `.dockerignore` como fonte da verdade (o que não entra na imagem não
Cloudflare tunnel; o hosted nunca toca o homeserver; o self-hosted não justifica build novo; `docs/`, `.agents/`, `.env*` viram skip;
conta minutos hosted). `scripts/`, `tests/`, `.forgejo/`, `AGENTS.md` contam como produção —
4. O script, no **homeserver**: guarda de HEAD (`git ls-remote` de conservador, qualquer dúvida roda a suite).
`TEQO_REPO_URL` — default `https://github.com/fsolla/teqo.git`, público — - Deployed == HEAD → suite e deploy **skipped**; run verde.
== SHA do job, senão "stale run" skip) → `flock` (serializa) → guard - Sem revision conhecida (label ausente, container sem label) → fail-open:
"already deployed" (revision do container rodando) → **build do migrator** suite + deploy rodam na janela (run verde; comportamento conservador).
(o estágio migrator não roda `next build` — builda mesmo contra o schema - Gate incapaz de classificar (erro de git/`.dockerignore`) → suite roda e
antigo; BuildKit, secrets do `~/stack/teqo-1313.env`) → push/tag do o run fica **vermelho** — nunca blackout silencioso.
migrator em `localhost:5000` → swap dos tags de imagem no 3. Com mudança de produção: suite completa verde (static/int/build/e2e/checks).
`~/stack/docker-compose.yml` (backup antes) → **migrations** 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`, (`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Á 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; 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 → → push/tag do runner → `docker compose up -d teqo-1313` → healthcheck →
smoke (`/`, `/campanha/login`, `/admin`, barreira 307, WebAuthn smoke (`/`, `/campanha/login`, `/admin`, barreira 307, WebAuthn
login-options, `api/revalidate` com o secret real). login-options, `api/revalidate` com o secret real).
5. Falha = job vermelho; nada é publicado pela metade (rollback automático 6. Falha = job vermelho no commit; nada é publicado pela metade.
após o swap).
**Primeiro deploy (verificação ao vivo):** depois do cutover OPS71, dispare o **Primeira janela (verificação ao vivo):** após o merge do OPS65, acompanhe um
deploy manual e confira no log do job `deploy` o `set-url` do workspace run agendado — confira no log do job `gate` o `deployed_sha` lido do container e
(origem Forgejo → GitHub), o `deployed_sha` lido do container e o guard o veredito da classificação, e que um dispatch manual roda suite + deploy
"already deployed" num segundo dispatch do mesmo SHA. (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 ## Onde roda cada coisa
| Máquina | Papel | | 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. | | **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; **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) | | **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) |
| **workstation** | dev/agentes apenas — o runner do Forgejo foi **desligado** no cutover (o schedule do `ci.yml` antigo não tem mais razão; religar é reversível) |
Segredos **não** ficam no GitHub: o script sourceia `~/stack/teqo-1313.env` e Segredos **não** ficam no Forgejo: o script sourceia `~/stack/teqo-1313.env` e
`~/stack/.env` (chmod 600) no próprio homeserver. O repo GitHub é público — o `~/stack/.env` (chmod 600) no próprio homeserver. Nenhum secret novo em
clone do workspace não precisa de credencial. No GitHub ficam apenas os Settings → Actions.
secrets de integração: `FORGEJO_API_TOKEN` (flips pós-merge) e
`CURSOR_API_KEY` (archive helper, dormente).
## Cutover OPS71 (passos manuais, ordem) ## Rollback
1. Remotes locais (config compartilhada pelos worktrees): A imagem anterior continua local e no registry (`localhost:5000/teqo-1313:<sha>`
`git remote set-url origin git@github.com:fsolla/teqo.git` e — o registry nunca deleta). O compose anterior fica em
`git remote add forgejo ssh://git@192.168.15.142:2222/fsolla/teqo.git` `~/stack/docker-compose.yml.pre-<sha-do-deploy-que-falhou>`.
(ou `https://git.solla.dev/fsolla/teqo.git`) — o repo Forgejo congela em
main; o tracker (Issues) segue vivo por API.
2. GitHub repo: secrets `FORGEJO_API_TOKEN` + `CURSOR_API_KEY`
(Settings → Secrets and variables → Actions) e **`Allow auto-merge`**
(Settings → General → Pull Requests; ou `PATCH /repos/...` com
`allow_auto_merge: true`). Repo é público: em Settings → Actions →
General, defina fork PR workflows como não executar (ou exigir aprovação).
3. PAT local: criar token GitHub com escopo `repo` e exportar `GITHUB_TOKEN`
(para `node scripts/github-pr.mjs` e `pnpm configure:branch-protection`).
4. `GITHUB_TOKEN=… pnpm configure:branch-protection` — required check
`CI (PR) / checks` (literal de match: `checks` — o GitHub casa pelo nome do
check-run; a UI exibe workflow/job; pin PR #742), 0 reviews,
`enforce_admins: true`. **Antes do primeiro push** (senão o PR mergearia
sem required check).
5. **Desligar o runner do Forgejo** na workstation (ex.:
`systemctl --user stop forgejo-runner`) — após o primeiro PR GitHub
validado; para o schedule do `ci.yml` antigo.
6. Instalar o runner self-hosted do GitHub no homeserver (labels
`self-hosted` + `homeserver`; usuário com docker + acesso a `~/stack`;
registrar em infra-solla: `STATE.md`).
7. Push do PR do OPS71 → CI verde → auto-merge → flip da Issue no Forgejo →
deploy manual de validação.
Rollback do cutover (se o GitHub falhar cedo): religar o runner do Forgejo e
abrir PRs no Forgejo a partir do main congelado — os workflows de
`.forgejo/workflows/` foram removidos na OPS71 Fase 2, mas restauram do git
history (`git revert` do PR da remoção, ou clone do main congelado do Forgejo)
— remotes: `git remote set-url origin ssh://git@192.168.15.142:2222/fsolla/teqo.git`.
## Rollback (do deploy)
A imagem anterior **não** fica mais local após a limpeza pós-deploy (INF3/F2:
`docker builder prune` + remoção das tags locais antigas, exceto o SHA em uso)
— o registry `localhost:5000` **nunca deleta** e é a fonte do rollback. O
compose anterior fica em `~/stack/docker-compose.yml.pre-<sha-do-deploy-que-falhou>`.
```bash ```bash
ssh homeserver ssh homeserver
@ -103,7 +76,7 @@ cd ~/stack
# o backup do deploy que falhou é o compose com os tags antigos # o backup do deploy que falhou é o compose com os tags antigos
ls docker-compose.yml.pre-* # escolha o anterior ao deploy ruim ls docker-compose.yml.pre-* # escolha o anterior ao deploy ruim
cp docker-compose.yml.pre-<sha-anterior> docker-compose.yml 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 pull localhost:5000/teqo-1313:<sha-anterior>
docker tag localhost:5000/teqo-1313:<sha-anterior> teqo-1313:<sha-anterior> docker tag localhost:5000/teqo-1313:<sha-anterior> teqo-1313:<sha-anterior>
# 3. subir: # 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 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 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); 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 ## Falhas conhecidas
| Sintoma | Causa | Tratamento | | 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 ("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 | | 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 | | 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 |
| Deploy dispara mas o job `verify` falha | Regressão real na suíte full | Corrigir e re-dispatchar — o homeserver nunca é tocado | | 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 |
| 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 | | 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) |
| 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 | | 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 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 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 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 | | 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 |
| 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 | | 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 |
| Migrate falha | Drift/erro de schema | Job vermelho; site segue no container antigo; corrigir e re-mergear | | `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`) |
| Smoke falha pós-up | Regressão de runtime | Rollback automático (restore + `up -d`) + job vermelho; investigar | | 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) ## Segurança (decisão deliberada)
- O job `deploy` usa `runs-on: [self-hosted, homeserver]` — executa como o - O job `deploy` usa `runs-on: host` — executa como `fsolla` na workstation.
usuário do runner no homeserver. Está **apenas** no `deploy.yml` Está **apenas** no `ci.yml` (janela `*/30` em `main` + dispatch), nunca no
(dispatch manual), nunca no `ci-pr.yml`; o hosted (`verify`) não tem acesso `ci-pr.yml`. O job `gate` tem o mesmo perfil e é restrito a
ao homeserver e o self-hosted não recebe secrets de produção (as envs vivem `github.ref == 'refs/heads/main'` (dispatch em branch não roda código do
no próprio homeserver). `main` só anda por PR mergeado com CI verde; o branch no host — a suite roda fail-open em containers com secrets de teste).
deploy só roda com `verify` full verde. Fork-PRs não rodam CI (same-repo `main` só anda por PR mergeado com suite verde. Fork-PRs não disparam
gate + setting do repo). workflows no Forgejo (default off — verificado).
- Segredos nunca ecoados: sem `set -x`, senhas via `--password-stdin` / - Segredos nunca ecoados: sem `set -x`, senhas via `--password-stdin` /
build-secrets; envs só no homeserver. build-secrets; envs só no homeserver.
## Referências ## Referências
- `scripts/deploy-homeserver.sh` — o script (fonte da verdade do fluxo) - `scripts/deploy-homeserver.sh` — o script remoto (fonte da verdade do fluxo)
- `.github/workflows/deploy.yml` — verify → deploy - `docs/plans/ops53-ci-deploy-homeserver*.md` — intenção e decisões
- `docs/plans/ops53-ci-deploy-homeserver*.md` — intenção e decisões (era Forgejo)
- `docs/plans/ops71-ci-github-actions-tracker-forgejo*.md` — o cutover
- infra-solla: `STATE.md`, `plano-infra-final.md` §"Arquitetura de deploy" - 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,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,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: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

View file

@ -1,230 +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 — Interface respeita o perfil de permissão</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">
Depois do perfil de permissão (C141): as telas param de oferecer edição a quem não pode
editar e passam a renderizar o escopo completo para quem vê tudo — cenas: lista de
municípios em modo leitura, lista com visão total, detalhe de demanda somente leitura.
</p>
<!-- Cena 1: lista de municípios — assessor somente leitura -->
<section
data-shot="municipios-leitura"
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>
<div class="bg-zinc-100 px-6 pb-6">
<div class="overflow-hidden rounded-lg border border-zinc-200 bg-white">
<div
class="grid grid-cols-[1.5fr_1.05fr_0.95fr_1.05fr_0.8fr_1.1fr] gap-2 border-b border-zinc-200 bg-zinc-50 px-4 py-2 text-xs font-bold text-zinc-500"
>
<div>Município</div>
<div>Tendência</div>
<div>Nível</div>
<div>Classe</div>
<div>Cobertura</div>
<div>Últ. atualização</div>
</div>
<div
class="grid grid-cols-[1.5fr_1.05fr_0.95fr_1.05fr_0.8fr_1.1fr] items-center gap-2 border-b border-zinc-100 px-4 py-3 text-sm"
>
<div class="min-w-0 truncate font-semibold text-zinc-900">Salvador (19 zonas)</div>
<div class="min-w-0 text-zinc-600">Favorável</div>
<div class="min-w-0 text-zinc-600">N3</div>
<div class="min-w-0 text-zinc-600">Reduto</div>
<div class="min-w-0 text-zinc-600">82%</div>
<div class="min-w-0 text-zinc-600">há 2 dias</div>
</div>
<div
class="grid grid-cols-[1.5fr_1.05fr_0.95fr_1.05fr_0.8fr_1.1fr] items-center gap-2 border-b border-zinc-100 px-4 py-3 text-sm"
>
<div class="min-w-0 truncate font-semibold text-zinc-900">Feira de Santana</div>
<div class="min-w-0 text-zinc-600">Neutro</div>
<div class="min-w-0 text-zinc-600">N2</div>
<div class="min-w-0 text-zinc-600">Expansão</div>
<div class="min-w-0 text-zinc-600">41%</div>
<div class="min-w-0 text-zinc-600">há 5 dias</div>
</div>
<div
class="grid grid-cols-[1.5fr_1.05fr_0.95fr_1.05fr_0.8fr_1.1fr] items-center gap-2 px-4 py-3 text-sm"
>
<div class="min-w-0 truncate font-semibold text-zinc-900">Ilhéus</div>
<div class="min-w-0 text-zinc-600">Favorável</div>
<div class="min-w-0 whitespace-nowrap text-zinc-600">—</div>
<div class="min-w-0 text-zinc-600">Manutenção</div>
<div class="min-w-0 text-zinc-600">57%</div>
<div class="min-w-0 text-zinc-600">há 1 dia</div>
</div>
</div>
<div class="mt-3 text-xs text-zinc-500">
Mesma lista de sempre — mas sem autosave, sem chips editáveis, sem "Registrar
atualização": nenhum caminho de escrita visível.
</div>
</div>
</section>
<!-- Cena 2: mesma lista para assessor com visão Tudo -->
<section
data-shot="municipios-visao-tudo"
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="h-9 w-44 rounded-full border border-zinc-300 bg-white px-3 text-sm text-zinc-500"
>
Buscar município…
</div>
</div>
<div class="bg-zinc-100 px-6 pb-6">
<div class="overflow-hidden rounded-lg border border-zinc-200 bg-white">
<div
class="grid grid-cols-[1.5fr_1.05fr_0.95fr_1.05fr_0.8fr_1.1fr] gap-2 border-b border-zinc-200 bg-zinc-50 px-4 py-2 text-xs font-bold text-zinc-500"
>
<div>Município</div>
<div>Tendência</div>
<div>Nível</div>
<div>Classe</div>
<div>Cobertura</div>
<div>Últ. atualização</div>
</div>
<div
class="grid grid-cols-[1.5fr_1.05fr_0.95fr_1.05fr_0.8fr_1.1fr] items-center gap-2 border-b border-zinc-100 px-4 py-3 text-sm"
>
<div class="min-w-0 truncate font-semibold text-zinc-900">Salvador (19 zonas)</div>
<div class="min-w-0 text-zinc-600">Favorável</div>
<div class="min-w-0 text-zinc-600">N3</div>
<div class="min-w-0 text-zinc-600">Reduto</div>
<div class="min-w-0 text-zinc-600">82%</div>
<div class="min-w-0 text-zinc-600">há 2 dias</div>
</div>
<div
class="grid grid-cols-[1.5fr_1.05fr_0.95fr_1.05fr_0.8fr_1.1fr] items-center gap-2 border-b border-zinc-100 px-4 py-3 text-sm"
>
<div class="min-w-0 truncate font-semibold text-zinc-900">Vitória da Conquista</div>
<div class="min-w-0 text-zinc-600">Neutro</div>
<div class="min-w-0 text-zinc-600">N1</div>
<div class="min-w-0 text-zinc-600">Marginal</div>
<div class="min-w-0 text-zinc-600">12%</div>
<div class="min-w-0 text-zinc-600">há 9 dias</div>
</div>
<div
class="grid grid-cols-[1.5fr_1.05fr_0.95fr_1.05fr_0.8fr_1.1fr] items-center gap-2 border-b border-zinc-100 px-4 py-3 text-sm"
>
<div class="min-w-0 truncate font-semibold text-zinc-900">Barreiras</div>
<div class="min-w-0 text-zinc-600">Favorável</div>
<div class="min-w-0 text-zinc-600">N2</div>
<div class="min-w-0 text-zinc-600">Expansão</div>
<div class="min-w-0 text-zinc-600">38%</div>
<div class="min-w-0 text-zinc-600">há 4 dias</div>
</div>
<div
class="grid grid-cols-[1.5fr_1.05fr_0.95fr_1.05fr_0.8fr_1.1fr] items-center gap-2 px-4 py-3 text-sm"
>
<div class="min-w-0 truncate font-semibold text-zinc-900">Juazeiro</div>
<div class="min-w-0 text-zinc-600">Desfavorável</div>
<div class="min-w-0 text-zinc-600">N1</div>
<div class="min-w-0 text-zinc-600">Sem base</div>
<div class="min-w-0 text-zinc-600">9%</div>
<div class="min-w-0 text-zinc-600">há 12 dias</div>
</div>
</div>
<div class="mt-3 text-xs text-zinc-500">
Mesma mesa de trabalho de um coordenador (menos apoiadores fora da carteira e demandas
de que não é responsável) — ver regras no plano C141.
</div>
</div>
</section>
<!-- Cena 3: detalhe de demanda em modo leitura -->
<section
data-shot="demanda-leitura"
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>
<div class="rounded-lg border border-zinc-200 bg-white p-5">
<div class="text-sm font-bold text-zinc-900">Histórico de status</div>
<div class="mt-3 space-y-3">
<div class="flex gap-3">
<div class="h-2 w-2 shrink-0 rounded-full bg-zinc-400"></div>
<div class="text-xs text-zinc-600">
<span class="font-bold text-zinc-800">Em aberto</span> — criada por Assessora de
campo · 12 ago
</div>
</div>
<div class="flex gap-3">
<div class="h-2 w-2 shrink-0 rounded-full bg-zinc-400"></div>
<div class="text-xs text-zinc-600">
<span class="font-bold text-zinc-800">Encaminhada</span> — decidida por
Coordenador Geral · 14 ago
</div>
</div>
</div>
<div
class="mt-4 rounded-md bg-zinc-100 px-3 py-2 text-[11px] leading-relaxed text-zinc-600"
>
Sem botões de transição, sem "registrar pedido", sem edição — só leitura do
andamento.
</div>
</div>
</div>
</div>
</section>
</main>
</body>
</html>

View file

@ -1,88 +0,0 @@
# Interface respeita o perfil de permissão (assessor somente-leitura sem controles de edição; visão Tudo no escopo completo)
Status: aguardando execução
Atualizado em: 2026-08-19
Issue: #107
Priority: P1
Model: cursor-grok-4.5-medium
Impeccable: B — passada de apresentação sobre as superfícies staff do `/campanha`
Rascunho UI: docs/plans/ui-respeita-perfil-permissao-ui-draft.html
Appetite: ~1–2 dias eng; telas passam a refletir o perfil (sem 403s à vista, escopo coerente)
Responsável: —
## Intenção
O item irmão (perfis de permissão por assessor) entrega o modelo e o enforcement no servidor. Sem esta passada, um assessor "Somente leitura" ainda **vê** todos os botões, células autosave, FABs e sheets de edição — e cada toque morre com erro de permissão, o oposto do produto que a mesa espera. E um assessor que ganhou "Visão Tudo" precisa que listas, detalhes e mapa carreguem o estado inteiro de forma coerente (contagens, facetas, filtros), não meia-lista.
## Persona e fluxo
- **Persona / contexto:** o assessor com perfil restrito, operando o `/campanha` no celular e no desktop — não pode, não deve e não quer tentar editar; e o assessor de visão total, querendo a mesa de trabalho completa.
- **Job principal:** a interface diz a verdade sobre o perfil: quem não edita não encontra edição; quem vê tudo vê tudo.
- **Fluxo desejado:**
1. Assessor "Carteira · Somente leitura" abre `/campanha/municipios`: células são leitura pura, sem chips editáveis, sem "Registrar atualização", sem FAB de ações — nenhum controle de escrita à vista, sem aviso: a tela simplesmente não oferece edição.
2. Assessor "Tudo · Edita carteira" abre as mesmas listas: vê o estado inteiro (todos os municípios/lideranças) e edita apenas o que administra — o controle de edição aparece só onde a carteira alcança.
3. Assessor "Carteira · Edita carteira": exatamente o comportamento de hoje (nada muda).
- **Anti-goals de produto:** bloquear por mensagem de erro em vez de ocultar/desabilitar; cinza-fantasma que confunde o que é editável; redesenho de superfícies que não precisam mudar.
### Esboço de fluxo (B/C/D)
```text
[login assessor] → [perfil da conta resolve o que renderizar]
→ [visão: escopo completo ou carteira] × [edição: controles presentes ou ausentes]
→ [nenhum caminho leva a 403; nenhum dado fora do escopo é oferecido]
```
### Rascunho UI (B/C/D)
![Rascunho UI — lista de municípios em modo leitura](ui-respeita-perfil-permissao-ui-draft-municipios-leitura.png)
![Rascunho UI — lista de municípios com visão Tudo](ui-respeita-perfil-permissao-ui-draft-municipios-visao-tudo.png)
![Rascunho UI — detalhe de demanda em modo leitura](ui-respeita-perfil-permissao-ui-draft-demanda-leitura.png)
## Objetivo e aceite
- Assessor sem permissão de edição **não encontra nenhum controle de escrita** nas superfícies staff: células de autosave, chips removíveis, "Registrar atualização", FAB/ações rápidas, wizards, sheets e triggers de Popover — no desktop e no mobile. Sem aviso de modo leitura: a presença/ausência dos controles é a única linguagem _(decidido no gate: opção B)_.
- Assessor "Visão Tudo" navega listas, detalhes e mapa no escopo completo, com contagens, facetas e filtros coerentes com o que ele vê.
- Atalhos de escrita (ex.: busca global) seguem a mesma regra: destino de escrita some para quem não pode escrever; atalhos de navegação permanecem _(decidido no gate: opção A)_.
- Nada muda para coordenador/candidato/liderança e para o assessor "Carteira · Edita carteira".
- O enforcement de segurança não é relaxado em nenhum ponto: a apresentação acompanha o servidor, nunca o substitui.
## Dados (intenção)
- **Vou apresentar dados?** Não — passada de apresentação sobre superfícies existentes.
- **Decisões desbloqueadas:** nenhuma decisão de dado nova.
## Direção no codebase (hipótese)
- **Áreas prováveis:** componentes `src/components/campaign/<domínio>/*` — células edit-in-place e cascas compartilhadas (`CampaignCellEditOverlay`, `CampaignListSheetProvider`, colunas por papel), triggers de autosave por lista, FAB/ações rápidas, botões "Registrar atualização" em municípios/atividades, controles de status em demandas; predicados client-safe de perfil em `src/lib/campaignRoles.ts` (ou o helper do item irmão); dashboards/mapa (`MunicipalityMapPanel` e loaders escopados).
- **Precedente a olhar:** estados `readOnly` já existentes (`RelationChipCell`/`RelationOptionCell` com `readOnly`), a matriz por papel da célula de pessoas (C116: assessor edita só onde a carteira alcança, resto read-only), card mobile de municípios (B193).
- **Risco de acoplamento:** o mapa e as listas já leem "escopo do ator" — cuidado para não duplicar lógica de escopo no cliente; o fragmento de escopo continua vivendo uma vez só (convenção P3-D).
## Dependências
- **C141** (modelo + enforcement + configuração) — este item só faz sentido depois do perfil existir e valer no servidor.
## Fora de escopo
- Qualquer mudança no modelo de permissão ou no enforcement (é o item irmão).
- Redesenho de superfícies não relacionadas a permissão.
- Estado de leitura para liderança (lockdown já é outra superfície, intocada).
## Rabbit holes de produto
- **"Vou esconder tudo com um flag e rezar."** A passada precisa achar **cada** caminho de escrita real (autosave, sheets, wizards, links para formulários). **Corte:** varrer por superfície com a matriz de papéis, como a célula de pessoas já faz; o que não for achado agora aparece no aceite do item.
- **"Vou redesenhar a lista de municípios."** Tocar layout por tocar não é o pedido. **Corte:** manter layouts; mudar só presença/ausência de controles e o aviso de modo leitura.
- **"Cinza-fantasma em tudo."** Desabilitar tudo visualmente polui mais do que ajuda. **Corte:** controles de edição **ocultos** para leitura; aviso discreto no topo.
## Decisões do gate (2026-08-19)
- **Modo leitura sem aviso** (opção B) — controles ocultos; presença/ausência de edição é a única linguagem.
- **Atalhos de escrita seguem a regra** (opção A) — destino de escrita some para quem não pode escrever.
## Referências
- Item irmão: `docs/plans/permissao-granular-assessores.md` (C141) e `docs/plans/demandas-responsaveis.md` (C143 — a regra de demandas muda o que a demanda renderiza para quem não é responsável).
- Rascunho UI (gate): `docs/plans/ui-respeita-perfil-permissao-ui-draft.html` + PNGs acima.
- Para abrir primeiro: `src/components/campaign/shared/CampaignCellEditOverlay.tsx` (casca dos overlays de edição), `src/lib/campaignRoles.ts` (predicados por papel), a matriz por papel em `pessoas-*` (C116).
- `AGENTS.md` — convenção P3-D do escopo de assessor.

View file

@ -6,30 +6,5 @@
"url": "https://design.penpot.app/mcp/stream?userToken={file:.opencode/secrets/penpot-token}", "url": "https://design.penpot.app/mcp/stream?userToken={file:.opencode/secrets/penpot-token}",
"enabled": true "enabled": true
} }
},
"provider": {
"cheapest-inference": {
"npm": "@ai-sdk/openai-compatible",
"name": "Cheapest Inference",
"options": {
"baseURL": "https://api.cheapestinference.com/v1"
},
"models": {
"deepseek-v4-flash": {
"name": "DeepSeek V4 Flash",
"limit": {
"context": 1000000,
"output": 384000
}
},
"mimo-v2.5": {
"name": "MiMo v2.5",
"limit": {
"context": 1000000,
"output": 128000
}
}
}
}
} }
} }

View file

@ -0,0 +1,155 @@
#!/usr/bin/env node
/**
* ci-classify-production — OPS65 production-change gate for the main CI
* window. Classifies whether the merge head differs from the deployed
* artifact in anything that would enter the docker build context.
*
* The `.dockerignore` is the source of truth (the Dockerfile does `COPY . .`):
* a changed path is production when it is NOT excluded by the ignore
* patterns. The gate is FAIL-OPEN toward production: dispatch (explicit
* operator intent) and an unknown deployed SHA always run the full suite;
* a classification ERROR (git/.dockerignore unreadable, no input) prints
* production=true AND exits 1, so the workflow run turns red — the suite
* still runs (fail-open) but the deploy never happens silently. A green run
* only ever means "the gate could classify and there is nothing to do".
*
* Plain Node, stdlib only — the gate must stay cheap (no pnpm install).
*
* node scripts/ci-classify-production.mjs --deployed <sha> [--value]
* node scripts/ci-classify-production.mjs --files-from <file> [--value]
*
* Output: JSON { production, reason, changed, kept, skipped } on stdout
* (`--value` prints only `true`/`false`). Exit 0 = classified; exit 1 =
* could not classify (production=true printed, run should go red).
*/
import { execFileSync } from 'node:child_process'
import { readFileSync } from 'node:fs'
import { pathToFileURL } from 'node:url'
import { isDockerignored, parseDockerignore } from './lib/dockerignore.mjs'
const parseArgs = (argv) => {
const flags = {}
for (let index = 2; index < argv.length; index += 1) {
const arg = argv[index]
if (arg.startsWith('--')) {
const name = arg.slice(2).replace(/-([a-z])/g, (_, char) => char.toUpperCase())
const next = argv[index + 1]
flags[name] = typeof next === 'string' && !next.startsWith('--') ? next : true
if (typeof next === 'string' && !next.startsWith('--')) index += 1
}
}
return flags
}
const flags = parseArgs(process.argv)
const deployed = flags.deployed
const filesFrom = flags.filesFrom
const valueOnly = flags.value === true
/**
* @param {string[]} changed - repo-relative changed paths
* @param {import('./lib/dockerignore.mjs').DockerignoreRule[]} patterns -
* .dockerignore rules
* @returns {{ production: boolean, kept: string[], skipped: string[] }}
*/
export function classifyProduction(changed, patterns) {
const kept = []
const skipped = []
for (const path of changed) {
if (path.length === 0) continue
if (isDockerignored(path, patterns)) skipped.push(path)
else kept.push(path)
}
return { production: kept.length > 0, kept, skipped }
}
const report = (production, reason, extra = {}) => {
const result = { production, reason, ...extra }
if (valueOnly) {
console.log(production)
return
}
console.log(JSON.stringify(result))
}
/**
* Could not classify at all (git/.dockerignore unreadable, no input): run
* the full pipeline AND make the run red — a gate that cannot classify must
* never look like "nothing to do".
*/
const failOpenRed = (reason) => {
report(true, reason, { changed: [], kept: [], skipped: [] })
process.exitCode = 1
}
// Import-safe: the module is unit-tested (classifyProduction), so the CLI
// body only runs when executed as a script.
const isCli =
typeof process.argv[1] === 'string' && import.meta.url === pathToFileURL(process.argv[1]).href
if (isCli) {
main()
}
function main() {
if (process.env.GITHUB_EVENT_NAME === 'workflow_dispatch') {
report(true, 'workflow_dispatch (intenção explícita)')
return
}
let changed
try {
if (filesFrom) {
changed = readFileSync(filesFrom, 'utf8').split('\n').filter(Boolean)
} else if (deployed === '') {
// The workflow always passes --deployed (possibly empty: no container
// revision on the homeserver). An empty deployed SHA is a legitimate
// "unknown deployed state" — run the full pipeline, keep the run green.
report(true, 'sem SHA deployado (estado desconhecido)', {
changed: [],
kept: [],
skipped: [],
})
return
} else if (deployed === true) {
failOpenRed('--deployed sem valor')
return
} else if (deployed) {
changed = execFileSync('git', ['diff', '--name-only', deployed, 'HEAD', '--'], {
encoding: 'utf8',
})
.split('\n')
.filter(Boolean)
} else {
failOpenRed('sem SHA deployado nem files-from')
return
}
} catch (error) {
failOpenRed(`erro ao obter o diff (${error instanceof Error ? error.message : error})`)
return
}
if (changed.length === 0) {
report(false, 'diff vazio (nada a fazer)', {
changed: [],
kept: [],
skipped: [],
})
return
}
let rules
try {
rules = parseDockerignore(readFileSync('.dockerignore', 'utf8'))
} catch (error) {
failOpenRed(`.dockerignore ilegível (${error instanceof Error ? error.message : error})`)
return
}
const classified = classifyProduction(changed, rules)
report(
classified.production,
classified.production ? 'mudança de produção desde o último deploy' : 'sem mudança de produção',
{ changed, kept: classified.kept, skipped: classified.skipped },
)
}

View file

@ -1,45 +1,38 @@
/** /**
* Drift repair for `main` branch protection — GitHub (OPS71). Idempotent: * Drift repair for branch protection (main-only) — Forgejo. Idempotent: reads
* reads the existing rule and creates / updates / no-ops (never duplicates). * the existing rules and creates / updates / no-ops (never duplicates).
* *
* GITHUB_TOKEN=<PAT> pnpm configure:branch-protection # apply * pnpm configure:branch-protection # apply
* GITHUB_TOKEN=<PAT> pnpm configure:branch-protection -- --dry-run * pnpm configure:branch-protection -- --dry-run
* *
* Desired rule: required check-run `CI (PR) / checks` (the ci-pr cascade — * main: required status check `CI (PR) / checks*` (the ci-pr cascade rollup —
* OPS62), strict=false, 0 reviews, `enforce_admins: true` (nobody, admin * OPS61: Forgejo matches the contexts as globs against the real status
* included, merges with a red required check). Feature PRs auto-merge when * contexts like `CI (PR) / checks (pull_request)`, so the old literal `checks`
* that check is green; the server rule is the final defense against every * would match nothing), strict=false, 0 reviews. Feature PRs auto-merge when
* merge path, manual API merges included. * that context is green; the server rule is the final defense against merges
* before the cascade settles (the waitForChecks rollup gate is the client
* half).
* (migration-lock removed 2026-08-12 — see ci-pr.yml header.)
*/ */
import { createApi } from './lib/github-api.mjs' import { parseArgs } from './lib/agent-forgejo.mjs'
import { DESIRED_RULE, planBranchProtectionRule } from './lib/github-branch-protection.mjs' import { DESIRED_RULE, planBranchProtectionRule } from './lib/branch-protection.mjs'
import { createApi } from './lib/forgejo-api.mjs'
const parseArgs = (argv) => { const { flags } = parseArgs(process.argv.slice(2), new Set())
const flags = {} const dryRun = flags['dry-run'] === true
for (let index = 2; index < argv.length; index += 1) {
const arg = argv[index]
if (arg.startsWith('--')) {
const name = arg.slice(2).replace(/-([a-z])/g, (_, char) => char.toUpperCase())
const next = argv[index + 1]
flags[name] = typeof next === 'string' && !next.startsWith('--') ? next : true
if (typeof next === 'string' && !next.startsWith('--')) index += 1
}
}
return flags
}
const flags = parseArgs(process.argv)
const dryRun = flags.dryRun === true
const branch = 'main'
const api = createApi({}) const api = createApi({})
const repo = process.env.GITHUB_REPOSITORY ?? 'fsolla/teqo' const repo = process.env.FORGEJO_REPOSITORY ?? process.env.GITHUB_REPOSITORY ?? 'fsolla/teqo'
const existing = await api.getBranchProtection(branch) const ruleName = DESIRED_RULE.rule_name
const existingRules = await api.listBranchProtections()
const existing =
existingRules.find((rule) => rule.rule_name === ruleName || rule.branch_name === ruleName) ?? null
const { action } = planBranchProtectionRule(existing) const { action } = planBranchProtectionRule(existing)
console.log( console.log(
`[configure:branch-protection] repo ${repo}${dryRun ? ' (dry-run)' : ''} — regra "${branch}": ${ `[configure:branch-protection] repo ${repo}${dryRun ? ' (dry-run)' : ''} — regra "${ruleName}": ${
action === 'noop' action === 'noop'
? 'já conforme — nada a fazer' ? 'já conforme — nada a fazer'
: action === 'update' : action === 'update'
@ -49,26 +42,34 @@ console.log(
) )
if (action === 'noop') { if (action === 'noop') {
console.log(`[configure:branch-protection] no-op: ${JSON.stringify(existing)}`) console.log(`[configure:branch-protection] no-op: ${JSON.stringify(DESIRED_RULE)}`)
process.exit(0) process.exit(0)
} }
const payload = { ...DESIRED_RULE, branch_name: ruleName }
const target = action === 'create' ? '/branch_protections' : `/branch_protections/${ruleName}`
const method = action === 'create' ? 'POST' : 'PATCH'
if (dryRun) { if (dryRun) {
console.log( console.log(`[dry-run] ${method} /repos/${repo}${target} ${JSON.stringify(payload)}`)
`[dry-run] PUT /repos/${repo}/branches/${branch}/protection ${JSON.stringify(DESIRED_RULE)}`,
)
process.exit(0) process.exit(0)
} }
await api.updateBranchProtection(DESIRED_RULE, branch) if (action === 'create') {
await api.updateBranchProtection(ruleName, payload)
} else {
await api.editBranchProtection(ruleName, payload)
}
const after = await api.getBranchProtection(branch) const after = await api.listBranchProtections()
if (!after) { const applied =
after.find((rule) => rule.rule_name === ruleName || rule.branch_name === ruleName) ?? null
if (!applied) {
console.error( console.error(
`[configure:branch-protection] FALHOU: PUT respondeu mas a regra "${branch}" não aparece no servidor.`, `[configure:branch-protection] FALHOU: ${method} ${target} respondeu mas a regra "${ruleName}" não aparece no servidor.`,
) )
process.exit(1) process.exit(1)
} }
console.log( console.log(
`[configure:branch-protection] done — regra verificada no servidor: ${JSON.stringify(after)}`, `[configure:branch-protection] done — regra verificada no servidor: ${JSON.stringify(applied)}`,
) )

View file

@ -2,11 +2,8 @@
# #
# Deploy teqo-1313 to the homeserver (OPS53). # Deploy teqo-1313 to the homeserver (OPS53).
# #
# OPS71: runs ON the homeserver via the GitHub self-hosted runner (deploy job # Runs ON the homeserver, piped by the CI deploy job:
# of .github/workflows/deploy.yml): # ssh homeserver "bash -s -- <commit-sha>" < scripts/deploy-homeserver.sh
# bash scripts/deploy-homeserver.sh <commit-sha>
# (The Forgejo-era invocation `ssh homeserver "bash -s -- <sha>" < script`
# is gone — no SSH hop, no workstation involvement.)
# #
# Flow: HEAD guard (only the current main HEAD deploys) -> flock # Flow: HEAD guard (only the current main HEAD deploys) -> flock
# serialization -> workspace fetch at <sha> -> docker login (local registry) # serialization -> workspace fetch at <sha> -> docker login (local registry)
@ -20,16 +17,14 @@
# the running site untouched and the job red. # the running site untouched and the job red.
# #
# Environment defaults assume the homeserver layout: stack under # Environment defaults assume the homeserver layout: stack under
# $HOME/stack, repo cloned from github.com/fsolla/teqo (public — no # $HOME/stack, repo cloned from the local Forgejo. Secrets are sourced from
# credentials; the pre-OPS71 clone pointed at the local Forgejo and is # the chmod-600 env files and never echoed (no `set -x`, passwords only via
# re-pointed idempotently below). Secrets are sourced from the chmod-600 env # --password-stdin / build secrets).
# files and never echoed (no `set -x`, passwords only via --password-stdin /
# build secrets).
set -euo pipefail set -euo pipefail
SHA="${1:?usage: deploy-homeserver.sh <commit-sha>}" SHA="${1:?usage: deploy-homeserver.sh <commit-sha>}"
TEQO_REPO_URL="${TEQO_REPO_URL:-https://github.com/fsolla/teqo.git}" TEQO_REPO_URL="${TEQO_REPO_URL:-http://localhost:3000/fsolla/teqo.git}"
STACK_DIR="${STACK_DIR:-$HOME/stack}" STACK_DIR="${STACK_DIR:-$HOME/stack}"
WORKSPACE_DIR="${WORKSPACE_DIR:-$HOME/teqo-deploy}" WORKSPACE_DIR="${WORKSPACE_DIR:-$HOME/teqo-deploy}"
DEPLOY_LOCK="${DEPLOY_LOCK:-/tmp/teqo-1313-deploy.lock}" DEPLOY_LOCK="${DEPLOY_LOCK:-/tmp/teqo-1313-deploy.lock}"
@ -93,14 +88,6 @@ if [ ! -d "$WORKSPACE_DIR/.git" ]; then
say "cloning $TEQO_REPO_URL into $WORKSPACE_DIR" say "cloning $TEQO_REPO_URL into $WORKSPACE_DIR"
git clone "$TEQO_REPO_URL" "$WORKSPACE_DIR" git clone "$TEQO_REPO_URL" "$WORKSPACE_DIR"
else else
# OPS71: the pre-cutover clone pointed at the local Forgejo — re-point
# idempotently so the HEAD guard and the fetch compare against the repo
# that actually received the merge.
current_url="$(git -C "$WORKSPACE_DIR" remote get-url origin 2>/dev/null || true)"
if [ -n "$current_url" ] && [ "$current_url" != "$TEQO_REPO_URL" ]; then
say "updating workspace origin: $current_url → $TEQO_REPO_URL"
git -C "$WORKSPACE_DIR" remote set-url origin "$TEQO_REPO_URL"
fi
git -C "$WORKSPACE_DIR" fetch origin main git -C "$WORKSPACE_DIR" fetch origin main
fi fi
git -C "$WORKSPACE_DIR" checkout --detach "$SHA" || fatal "checkout of $SHA failed" git -C "$WORKSPACE_DIR" checkout --detach "$SHA" || fatal "checkout of $SHA failed"
@ -230,26 +217,4 @@ curl -fsS -o /dev/null -X POST "$base/campanha/webauthn/login-options" \
body="$(curl -fsS -X POST "$base/api/revalidate" -H "x-revalidate-secret: $REVALIDATE_SECRET" || true)" body="$(curl -fsS -X POST "$base/api/revalidate" -H "x-revalidate-secret: $REVALIDATE_SECRET" || true)"
echo "$body" | grep -q '"revalidated":true' || smoke_fail "POST /api/revalidate did not confirm" echo "$body" | grep -q '"revalidated":true' || smoke_fail "POST /api/revalidate did not confirm"
# --- post-deploy cleanup (INF3/F2) --------------------------------------
# O deploy compila no homeserver e acumulava build cache + tags locais
# antigas no disco raiz (incidente 19/08: 45G de cache + ~20 tags teqo
# antigas). Best-effort e APÓS o smoke: falha de limpeza nunca falha um
# deploy verde. O registry localhost:5000 preserva as imagens (rollback
# intacto — runbook teqo-1313-deploy.md); removemos só as tags LOCAIS.
# Fail-closed: sem revision no container, não remove nada além do cache.
say "post-deploy cleanup: build cache + tags locais antigas"
docker builder prune -f >/dev/null 2>&1 || true
in_use="$(docker inspect -f '{{index .Config.Labels "org.opencontainers.image.revision"}}' teqo-1313 2>/dev/null || true)"
if [ -n "$in_use" ]; then
for img in $(docker images --format '{{.Repository}}:{{.Tag}}' | grep -E '^(localhost:5000/)?teqo-1313(-migrator)?:' || true); do
tag="${img##*:}"
[ "$tag" = "$in_use" ] && continue
docker rmi "$img" >/dev/null 2>&1 || true
done
else
say "cleanup: container sem revision — mantendo imagens locais"
fi
say "deploy of $SHA complete: $(docker inspect -f '{{.Image}}' teqo-1313)" say "deploy of $SHA complete: $(docker inspect -f '{{.Image}}' teqo-1313)"

View file

@ -0,0 +1,32 @@
/**
* Plain-Node CLI (no pnpm) — dispatch a Forgejo workflow via workflow_dispatch.
* Used by CI jobs (requeue) and humans.
*
* node scripts/forgejo-dispatch.mjs <workflow-file> <ref> [key=value …]
*/
import { createApi } from './lib/forgejo-api.mjs'
const [, , workflowFile, ref = 'main', ...inputPairs] = process.argv
if (!workflowFile) {
console.error('Usage: node scripts/forgejo-dispatch.mjs <workflow-file> [ref] [key=value …]')
process.exit(1)
}
const inputs = {}
for (const pair of inputPairs) {
const eq = pair.indexOf('=')
if (eq <= 0) continue
inputs[pair.slice(0, eq)] = pair.slice(eq + 1)
}
const api = createApi({})
try {
await api.workflowDispatch(workflowFile, { ref, inputs })
console.log(
`[forgejo-dispatch] ${workflowFile}@${ref} enviado${Object.keys(inputs).length > 0 ? ` (${Object.keys(inputs).join(', ')})` : ''}`,
)
} catch (error) {
console.error(`[forgejo-dispatch] falhou: ${error instanceof Error ? error.message : error}`)
process.exit(1)
}

View file

@ -2,12 +2,8 @@
* Plain-Node CLI (no pnpm) — on merge to main, flip Issues cited as * Plain-Node CLI (no pnpm) — on merge to main, flip Issues cited as
* `Closes #N` / `Fixes #N` in the PR body to `done` + `in-prod` and comment. * `Closes #N` / `Fixes #N` in the PR body to `done` + `in-prod` and comment.
* Replacement for the `gh issue edit/comment` loop of issue-done-on-main-merge. * Replacement for the `gh issue edit/comment` loop of issue-done-on-main-merge.
* * The PR body comes from the API (Forgejo's PR webhook payload is not relied
* OPS71: the PR now lives on GitHub. The workflow passes the PR body via the * on), so the workflow only needs the PR number.
* `PR_BODY` env (from the GitHub event payload) and the script flips Issues
* on the Forgejo tracker by API — no Forgejo PR read when PR_BODY is present.
* Without PR_BODY the script falls back to reading the PR from the Forgejo
* API (the Forgejo-era workflows stay alive until the OPS71 Fase 2 removal).
* *
* OPS61: a failed flip exits 1 — the flip is the whole purpose of this * OPS61: a failed flip exits 1 — the flip is the whole purpose of this
* workflow, and the 403 era proved that a swallowed error ends the job * workflow, and the 403 era proved that a swallowed error ends the job
@ -15,7 +11,6 @@
* (the loop does not abort), but the job goes red so the breakage is visible. * (the loop does not abort), but the job goes red so the breakage is visible.
* *
* node scripts/forgejo-issue-transition.mjs --pr <N> * node scripts/forgejo-issue-transition.mjs --pr <N>
* PR_BODY='Closes #97' node scripts/forgejo-issue-transition.mjs --pr <N>
*/ */
import { createApi } from './lib/forgejo-api.mjs' import { createApi } from './lib/forgejo-api.mjs'
@ -42,11 +37,7 @@ if (!Number.isInteger(prNumber) || prNumber <= 0) {
} }
const api = createApi({}) const api = createApi({})
const bodyFromEnv = process.env.PR_BODY const pr = await api.getPullRequest(prNumber)
const pr =
typeof bodyFromEnv === 'string'
? { merged: true, body: bodyFromEnv }
: await api.getPullRequest(prNumber)
if (!pr) { if (!pr) {
console.error(`[forgejo-issue-transition] PR #${prNumber} não encontrada`) console.error(`[forgejo-issue-transition] PR #${prNumber} não encontrada`)
process.exit(1) process.exit(1)
@ -71,12 +62,9 @@ for (const number of numbers) {
await api.setLabels(number, { add: ['done', 'in-prod'], remove: ['in-progress'] }) await api.setLabels(number, { add: ['done', 'in-prod'], remove: ['in-progress'] })
await api.addComment( await api.addComment(
number, number,
`Merged em main via PR #${prNumber} — \`done\` + \`in-prod\` + Issue fechada. Deploy de produção é manual (workflow_dispatch no GitHub Actions) — ver docs/AGENT-OPS.md.`, `Merged em main via PR #${prNumber} — \`done\` + \`in-prod\`. Deploy de produção segue o verificador \`ci.yml\` (gated).`,
) )
// OPS71-FLIP: o PR vive no GitHub e não fecha a Issue no Forgejo como o console.log(`[forgejo-issue-transition] #${number}: done + in-prod`)
// merge nativo fazia na era Forgejo — o close é explícito aqui.
await api.closeIssue(number)
console.log(`[forgejo-issue-transition] #${number}: done + in-prod + fechada`)
} catch (error) { } catch (error) {
flipFailed = true flipFailed = true
console.error( console.error(

View file

@ -0,0 +1,92 @@
/**
* Plain-Node CLI (no pnpm) — PR safety net: wait for checks and merge by
* rebase (the Forgejo equivalent of `gh pr merge --auto --rebase`, which the
* Forgejo API does not offer as a scheduled operation). Idempotent: already
* merged → no-op success. The verdict comes from re-reading the PR (Forgejo's
* merge endpoint answers 200 + empty body on success, so the POST response
* alone cannot prove the merge) — "já mergeado" is only printed for a PR the
* server actually reports as merged; a failed merge exits 1 loudly.
*
* Draft policy (OPS57): only `cursor/*` heads get marked ready. A draft from
* any other branch is the actor's veto — the CLI skips it (exit 0) so the
* workflow never forces a human/plans PR to ready.
*
* node scripts/forgejo-pr-automerge.mjs --pr <N>
*/
import { createApi } from './lib/forgejo-api.mjs'
const parseArgs = (argv) => {
const flags = {}
for (let index = 2; index < argv.length; index += 1) {
const arg = argv[index]
if (arg.startsWith('--')) {
const name = arg.slice(2)
const next = argv[index + 1]
flags[name] = typeof next === 'string' && !next.startsWith('--') ? next : true
if (typeof next === 'string' && !next.startsWith('--')) index += 1
}
}
return flags
}
const flags = parseArgs(process.argv)
const prNumber = Number(flags.pr)
if (!Number.isInteger(prNumber) || prNumber <= 0) {
console.error('Usage: node scripts/forgejo-pr-automerge.mjs --pr <N>')
process.exit(1)
}
const api = createApi({})
try {
const pr = await api.getPullRequest(prNumber)
if (!pr || pr.state !== 'OPEN') {
console.log(
`[forgejo-pr-automerge] PR #${prNumber} não está aberto (${pr?.state ?? 'inexistente'}) — skip`,
)
process.exit(0)
}
if (pr.base.ref !== 'main') {
console.log(`[forgejo-pr-automerge] PR #${prNumber} base é ${pr.base.ref} (não main) — skip`)
process.exit(0)
}
if (pr.isDraft) {
if (!pr.head.ref.startsWith('cursor/')) {
console.log(
`[forgejo-pr-automerge] PR #${prNumber} é draft fora de cursor/* — skip (veto do ator)`,
)
process.exit(0)
}
console.log(`[forgejo-pr-automerge] PR #${prNumber} é draft — marcando Ready`)
await api.markPullRequestReady(prNumber)
} else {
console.log(`[forgejo-pr-automerge] PR #${prNumber} já Ready`)
}
const {
attempted,
merged,
pr: after,
} = await api.autoMerge(prNumber, {
// OPS61: the wait now covers the whole CI (PR) cascade (the `checks`
// rollup only posts after every ci-pr job), so the 30 min default was too
// tight under load — e2e shards + int + build run before the rollup.
timeoutMs: 45 * 60 * 1000,
log: (line) => console.log(`[forgejo-pr-automerge] ${line}`),
})
if (merged) {
console.log(
attempted
? `[forgejo-pr-automerge] PR #${prNumber} mergeado (rebase)`
: `[forgejo-pr-automerge] PR #${prNumber} já mergeado`,
)
} else {
console.log(
after?.state === 'CLOSED'
? `[forgejo-pr-automerge] PR #${prNumber} fechado sem merge — skip`
: `[forgejo-pr-automerge] PR #${prNumber} voltou a draft — skip (veto do ator)`,
)
}
} catch (error) {
console.error(`[forgejo-pr-automerge] falhou: ${error instanceof Error ? error.message : error}`)
process.exit(1)
}

View file

@ -1,11 +1,9 @@
#!/usr/bin/env node #!/usr/bin/env node
/** /**
* Local mirror of `.github/workflows/ci-pr.yml` (serial): phase-1 cheap checks, * Local mirror of `.forgejo/workflows/ci-pr.yml` (serial): phase-1 cheap checks,
* then phase-2 expensive (int/build). e2e is NOT part of the local mirror * then phase-2 expensive (int/build). e2e is NOT part of the local mirror
* (OPS59 + OPS72): the PR CI runs only the e2e blast radius (mode `selected`, * (OPS59): it runs once, in CI (PR job `e2e` → `checks`; main `ci.yml` e2e →
* never `full`), the deploy `verify` job runs the full suite before * `checks` → `deploy`). Local optional feedback: `pnpm test:e2e:affected`.
* publishing, and the local affected run is a skill step (discretionary):
* `pnpm test:e2e:affected`.
* Docs guards (OPS63): the `docs-guards` checks (changelog append-only, * Docs guards (OPS63): the `docs-guards` checks (changelog append-only,
* aggregate sync, conflict markers) also run here — cheap git diffs, same * aggregate sync, conflict markers) also run here — cheap git diffs, same
* scripts the CI job runs (the `changelog-rewrite:` escape stays CI-only, * scripts the CI job runs (the `changelog-rewrite:` escape stays CI-only,
@ -79,7 +77,7 @@ const main = async () => {
const scope = readJsonScript('scripts/ci-scope.mjs') const scope = readJsonScript('scripts/ci-scope.mjs')
console.log( console.log(
`[gate:ci] scope base=${scope.base} code=${scope.code.mode} build=${scope.build.mode} test=${scope.test.mode} e2e=${scope.e2e.mode} (e2e not in this gate — OPS72; local affected is a skill step)`, `[gate:ci] scope base=${scope.base} code=${scope.code.mode} build=${scope.build.mode} test=${scope.test.mode} e2e=${scope.e2e.mode} (e2e runs in CI, not in this gate — OPS59)`,
) )
// --- Phase 1 (cheap) --- // --- Phase 1 (cheap) ---
@ -154,18 +152,14 @@ const main = async () => {
if (scope.e2e.mode === 'none') { if (scope.e2e.mode === 'none') {
console.log('\n[gate:ci] ⊘ e2e: CI will skip (no e2e blast radius)') console.log('\n[gate:ci] ⊘ e2e: CI will skip (no e2e blast radius)')
} else if (scope.e2e.mode === 'selected') {
console.log(
`\n[gate:ci] ▶ e2e: CI runs the blast radius (${scope.e2e.specs.join(', ')}) — not in this gate (OPS72); full lives in deploy verify`,
)
} else { } else {
console.log( const specs =
'\n[gate:ci] ▶ e2e: diff is high-risk (full mode) — no e2e in this PR by design (OPS72); run `pnpm test:e2e:affected` locally; deploy verify runs full before publishing', scope.e2e.mode === 'full' ? 'full suite' : `selected: ${scope.e2e.specs.join(', ')}`
) console.log(`\n[gate:ci] ▶ e2e: runs in CI (job e2e, ${specs}) — not in this gate (OPS59)`)
} }
console.log( console.log(
'\n[gate:ci] ✓ all checks passed (ci-pr mirror: docs guards local — OPS63; e2e: local affected is a skill step — OPS72)', '\n[gate:ci] ✓ all checks passed (ci-pr mirror: docs guards local — OPS63; e2e verified in CI)',
) )
} }

View file

@ -1,86 +0,0 @@
/**
* Plain-Node CLI (no pnpm) — GitHub PR safety net: mark `cursor/*` drafts
* ready and arm GitHub's native auto-merge (rebase). Runs in
* .github/workflows/agent-pr-ready-automerge.yml. The auto-merge arming uses
* the AUTOMERGE_PAT (real user PAT) — never the built-in GITHUB_TOKEN, whose
* actor (github-actions[bot]) would merge without creating workflow runs for
* the `closed` event (OPS71-FLIP; anti-recursion — silent broken flips).
* Mark-ready keeps the run token (nothing consumes its event).
*
* Unlike the Forgejo-era automerger (removed in OPS71 Fase 2), there is NO
* poll loop: `enablePullRequestAutoMerge` is server-side — GitHub only merges
* when every required check (incl. `CI (PR) / checks`) is green and the PR is
* mergeable. The OPS64 "never trust the rollup" pin is structural here: a
* single sequential job posts one honest check-run, and the server enforces
* it. Draft policy (OPS57) preserved: only `cursor/*` heads are marked
* ready — any other draft is the actor's veto (skip, exit 0).
*
* Idempotent: already merged / closed / non-main base → no-op success.
* A GraphQL error (e.g. repo auto-merge disabled, PR in conflict) exits 1 so
* the job goes red and the situation is visible.
*
* node scripts/github-pr-automerge.mjs --pr <N>
*/
import { createApi } from './lib/github-api.mjs'
import { automergeArmingToken, decideAutomergeAction } from './lib/github-pr-flow.mjs'
const parseArgs = (argv) => {
const flags = {}
for (let index = 2; index < argv.length; index += 1) {
const arg = argv[index]
if (arg.startsWith('--')) {
const name = arg.slice(2)
const next = argv[index + 1]
flags[name] = typeof next === 'string' && !next.startsWith('--') ? next : true
if (typeof next === 'string' && !next.startsWith('--')) index += 1
}
}
return flags
}
const flags = parseArgs(process.argv)
const prNumber = Number(flags.pr)
if (!Number.isInteger(prNumber) || prNumber <= 0) {
console.error('Usage: node scripts/github-pr-automerge.mjs --pr <N>')
process.exit(1)
}
const api = createApi({})
// OPS71-FLIP: the native auto-merge MUST be armed with a real user PAT, not
// the built-in GITHUB_TOKEN. A merge performed through the GITHUB_TOKEN actor
// (github-actions[bot]) never creates workflow runs for its events (GitHub
// anti-recursion — the `pull_request` `closed` is recorded in the timeline
// but no run is created), so the post-merge flips would silently never run
// (live finding: PR #746). With a PAT the merge is attributed to its owner (a
// real user) and the `closed` event creates runs again (PR #742 behavior).
// Fail-closed: no AUTOMERGE_PAT → exit 1 — never fall back to GITHUB_TOKEN.
const arming = automergeArmingToken(process.env)
if (!arming.ok) {
console.error(`[github-pr-automerge] ${arming.reason}`)
process.exit(1)
}
const automergeApi = createApi({ token: arming.token })
try {
const pr = await api.getPullRequest(prNumber)
const verdict = decideAutomergeAction(pr)
if (verdict.action === 'skip') {
console.log(`[github-pr-automerge] PR #${prNumber}: skip (${verdict.reason})`)
process.exit(0)
}
if (verdict.action === 'mark-ready') {
console.log(`[github-pr-automerge] PR #${prNumber} é draft cursor/* — marcando Ready`)
await api.markPullRequestReady(prNumber)
}
console.log(`[github-pr-automerge] PR #${prNumber}: armando auto-merge (rebase)`)
await automergeApi.enableAutoMerge(pr.nodeId)
console.log(
`[github-pr-automerge] PR #${prNumber}: auto-merge armado — o GitHub mergea quando o required check "CI (PR) / checks" fica verde`,
)
} catch (error) {
console.error(`[github-pr-automerge] falhou: ${error instanceof Error ? error.message : error}`)
process.exit(1)
}

View file

@ -1,64 +0,0 @@
/**
* Plain-Node CLI (no pnpm) — create a GitHub PR (Ready, never draft) for
* agent deliveries. Replaces the Forgejo MCP `create_pull_request` in the
* delivery flow (OPS71: PRs live on GitHub; the tracker stays on Forgejo).
*
* GITHUB_TOKEN=<PAT> node scripts/github-pr.mjs \
* --head <branch> --title "<título>" --body-file <path>
* GITHUB_TOKEN=<PAT> node scripts/github-pr.mjs --head <b> --title <t> --body "Closes #N"
*
* The repo rule "PRs are Ready, never draft" is structural: there is no
* `--draft` flag. Base defaults to `main`.
*/
import { createApi } from './lib/github-api.mjs'
const parseArgs = (argv) => {
const flags = {}
for (let index = 2; index < argv.length; index += 1) {
const arg = argv[index]
if (arg.startsWith('--')) {
const name = arg.slice(2)
const next = argv[index + 1]
flags[name] = typeof next === 'string' && !next.startsWith('--') ? next : true
if (typeof next === 'string' && !next.startsWith('--')) index += 1
}
}
return flags
}
const flags = parseArgs(process.argv)
const head = flags.head
const title = flags.title
const base = flags.base ?? 'main'
if (!head || !title) {
console.error(
'Usage: node scripts/github-pr.mjs --head <branch> --title <t> [--base main] --body "<text>" | --body-file <path>',
)
process.exit(1)
}
let body = flags.body
if (flags['body-file']) {
try {
const { readFileSync } = await import('node:fs')
body = readFileSync(flags['body-file'], 'utf8')
} catch (error) {
console.error(`[github-pr] não consegui ler --body-file: ${error.message}`)
process.exit(1)
}
}
if (typeof body !== 'string' || body.length === 0) {
console.error('[github-pr] body vazio — passe --body ou --body-file')
process.exit(1)
}
const api = createApi({})
try {
const pr = await api.createPullRequest({ head, base, title, body })
console.log(`[github-pr] PR #${pr.number} criado (Ready): ${pr.htmlUrl}`)
} catch (error) {
console.error(`[github-pr] falhou: ${error instanceof Error ? error.message : error}`)
process.exit(1)
}

View file

@ -0,0 +1,60 @@
/**
* Pure decision for the main branch protection rule (OPS61).
*
* The desired rule is the repo contract: main requires the CI (PR) cascade
* rollup as the only status check (`strict=false`, 0 reviews). The context is
* a glob on purpose — Forgejo matches `status_check_contexts` with gobwas/glob
* against the real commit-status contexts (`CI (PR) / checks (pull_request)` —
* the ` (event)` suffix is part of the context), so the literal `checks` would
* match nothing and block every merge.
*/
export const DESIRED_RULE = Object.freeze({
rule_name: 'main',
enable_status_check: true,
status_check_contexts: ['CI (PR) / checks*'],
enable_push: false,
required_approvals: 0,
dismiss_stale_approvals: false,
})
const COMPARED_FIELDS = [
'rule_name',
'enable_status_check',
'status_check_contexts',
'enable_push',
'required_approvals',
'dismiss_stale_approvals',
]
const sameStrings = (have, want) =>
have.length === want.length &&
want.every((value) => have.includes(value)) &&
have.every((value) => want.includes(value))
/**
* True when the existing rule (from the API, already normalized) matches the
* desired rule on every compared field — array fields as sets.
*/
export const ruleMatches = (existing, desired = DESIRED_RULE) => {
if (!existing) return false
return COMPARED_FIELDS.every((field) => {
const want = desired[field]
const have = existing[field]
if (Array.isArray(want)) return Array.isArray(have) && sameStrings(have, want)
return have === want
})
}
/**
* What to do with the rule: `create` (no rule), `update` (rule exists with
* drift), `noop` (already compliant). Idempotent re-runs land on `noop`.
*
* @param {object|null} existing - normalized rule from the API, or null
* @param {object} [desired]
* @returns {{ action: 'create' | 'update' | 'noop' }}
*/
export const planBranchProtectionRule = (existing, desired = DESIRED_RULE) => {
if (ruleMatches(existing, desired)) return { action: 'noop' }
return { action: existing ? 'update' : 'create' }
}

View file

@ -0,0 +1,106 @@
/**
* dockerignore match — pure `.dockerignore` semantics (moby patternmatcher
* subset) for the CI production-change gate (OPS65). No git, no fs, no deps.
*
* The gate classifies "production change" by asking: would this path enter the
* docker build context? The `.dockerignore` is the source of truth for the
* artifact (the Dockerfile does `COPY . .`), so a path is production when it
* is NOT excluded by the ignore patterns.
*
* Implemented rules (moby semantics):
* - blank lines and `#` comments are ignored
* - `!` prefixes a negation (re-include); last match wins
* - trailing `/` is a directory pattern (the dir itself and its tree)
* - leading `/` anchors the pattern to the context root
* - a pattern with no other `/` matches the basename at ANY depth
* - any matched path also excludes everything under it (the builder prunes
* at the matched directory, so `docs` excludes `docs/x.md`)
* - `*` matches any run of non-separator chars, `?` a single one
* - `**` matches any number of directories (globstar)
*/
/** A compiled ignore rule: regex against the cleaned repo-relative path. */
export class DockerignoreRule {
/**
* @param {RegExp} regex - compiled matcher
* @param {boolean} negate - `!` prefix: re-include when matched
* @param {string} source - original pattern text (diagnostics)
*/
constructor(regex, negate, source) {
this.regex = regex
this.negate = negate
this.source = source
}
}
/**
* @param {string} pattern - one `.dockerignore` line (no trailing newline)
* @returns {DockerignoreRule}
*/
export function compileDockerignoreRule(pattern) {
const source = pattern
let negate = false
if (pattern.startsWith('!')) {
negate = true
pattern = pattern.slice(1)
}
if (pattern.endsWith('/')) pattern = pattern.slice(0, -1)
const anchored = pattern.startsWith('/')
if (anchored) pattern = pattern.slice(1)
let body = ''
for (let index = 0; index < pattern.length; index += 1) {
const char = pattern[index]
if (char === '*') {
if (pattern[index + 1] === '*') {
index += 1
if (pattern[index + 1] === '/') {
body += '(?:.*/)?'
index += 1
} else {
body += '.*'
}
} else {
body += '[^/]*'
}
} else if (char === '?') {
body += '[^/]'
} else if ('\\^$.|+()[]{}'.includes(char)) {
body += `\\${char}`
} else {
body += char
}
}
const anyDepth = !anchored && !pattern.includes('/') ? '(?:.*/)?' : ''
const subtree = '(?:/.*)?'
const regexSource = body.length > 0 ? `^${anyDepth}${body}${subtree}$` : '$^'
return new DockerignoreRule(new RegExp(regexSource), negate, source)
}
/**
* @param {string} text - full `.dockerignore` content
* @returns {DockerignoreRule[]} rules in order (last match wins)
*/
export function parseDockerignore(text) {
return text
.split('\n')
.map((line) => line.trim())
.filter((line) => line.length > 0 && !line.startsWith('#'))
.map(compileDockerignoreRule)
}
/**
* Last-match-wins exclusion test for one repo-relative path.
*
* @param {string} path - repo-relative, forward slashes (e.g. "src/app/x.ts")
* @param {DockerignoreRule[]} rules - from parseDockerignore
* @returns {boolean} true when the path is excluded from the build context
*/
export function isDockerignored(path, rules) {
let ignored = false
for (const rule of rules) {
if (rule.regex.test(path)) ignored = !rule.negate
}
return ignored
}

View file

@ -12,12 +12,8 @@
* that rejects (network-level `fetch failed`, DNS/TCP/reset) retries on ANY * that rejects (network-level `fetch failed`, DNS/TCP/reset) retries on ANY
* method; a 5xx (502/503/504 — Cloudflare blips) retries on GET only, so the * method; a 5xx (502/503/504 — Cloudflare blips) retries on GET only, so the
* safety net's long `waitForChecks` poll survives, while write endpoints keep * safety net's long `waitForChecks` poll survives, while write endpoints keep
* failing closed instead of risking duplicated side effects. 410 Gone * failing closed instead of risking duplicated side effects. 4xx never
* (OPS71-FLIP): served by the reverse proxy (nginx/Cloudflare) BEFORE the * retries. Defaults: 3 retries, base 300 ms ×2, ±20% jitter.
* request reaches the app — the origin never saw it, so retrying is safe on
* ANY method (unlike a 5xx, where the origin may have applied the write).
* 4xx never retries (410 is the one documented proxy-level exception).
* Defaults: 3 retries, base 300 ms ×2, ±20% jitter.
* *
* Shapes are normalized to the GitHub-flavored contract the agent scripts and * Shapes are normalized to the GitHub-flavored contract the agent scripts and
* their unit specs already use: `issue.state` ∈ OPEN|CLOSED, * their unit specs already use: `issue.state` ∈ OPEN|CLOSED,
@ -28,10 +24,6 @@ const DEFAULT_BASE_URL = 'https://git.solla.dev/api/v1'
const DEFAULT_REPOSITORY = 'fsolla/teqo' const DEFAULT_REPOSITORY = 'fsolla/teqo'
const USER_AGENT = 'teqo-agent-scripts/1.0 (forgejo)' const USER_AGENT = 'teqo-agent-scripts/1.0 (forgejo)'
const RETRYABLE_STATUSES = new Set([502, 503, 504]) const RETRYABLE_STATUSES = new Set([502, 503, 504])
// 410 Gone from the reverse proxy (OPS71-FLIP live finding: nginx answered
// `GET /repos/fsolla/teqo/issues/97/labels` with 410, transient). The origin
// never received the request — retrying is side-effect-free on ANY method.
const RETRYABLE_BEFORE_ORIGIN = new Set([410])
/** /**
* @typedef {object} ForgejoApiOptions * @typedef {object} ForgejoApiOptions
@ -132,11 +124,6 @@ export const createApi = ({
await retryAfter(attempt, `HTTP ${response.status}`) await retryAfter(attempt, `HTTP ${response.status}`)
continue continue
} }
if (!isLastAttempt && RETRYABLE_BEFORE_ORIGIN.has(response.status)) {
await response.body?.cancel()
await retryAfter(attempt, `HTTP ${response.status} (proxy — origem intocada)`)
continue
}
let text let text
try { try {
text = await response.text() text = await response.text()
@ -146,14 +133,7 @@ export const createApi = ({
continue continue
} }
if (!response.ok) { if (!response.ok) {
const isProxyHtml = /^\s*</.test(text) throw new Error(`Forgejo API ${method} ${path} → ${response.status}: ${text.slice(0, 400)}`)
const snippet = isProxyHtml
? `corpo não-JSON (proxy/nginx — HTTP ${response.status}): ${text
.replace(/\s+/g, ' ')
.trim()
.slice(0, 120)}`
: text.slice(0, 400)
throw new Error(`Forgejo API ${method} ${path} → ${response.status}: ${snippet}`)
} }
if (!text) return null if (!text) return null
try { try {
@ -234,18 +214,6 @@ export const createApi = ({
body: { body }, body: { body },
}), }),
/**
* PATCH state=closed — closes the issue on the tracker. Forgejo-era: the
* merge of the PR closed the issue natively. GitHub-era (OPS71): the PR
* lives on GitHub and never touches the Forgejo tracker — the flip must
* close explicitly, else every merged issue stays OPEN with done+in-prod.
*/
closeIssue: (number) =>
request(`/repos/${owner}/${name}/issues/${number}`, {
method: 'PATCH',
body: { state: 'closed' },
}),
listIssueComments: async (number) => { listIssueComments: async (number) => {
const comments = await request(`/repos/${owner}/${name}/issues/${number}/comments`) const comments = await request(`/repos/${owner}/${name}/issues/${number}/comments`)
return (Array.isArray(comments) ? comments : []).map((comment) => ({ return (Array.isArray(comments) ? comments : []).map((comment) => ({

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