From 9467e0f292df38f276cefc8a8da3539b05b1e900 Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Sun, 23 Aug 2026 16:10:31 -0300 Subject: [PATCH] =?UTF-8?q?fix(campaign):=20C140=20=E2=80=94=20manual=20di?= =?UTF-8?q?spatch=20in=2018=20forms=20prevents=20React=2019=20form=20reset?= =?UTF-8?q?=20on=20error?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix manual dispatch em 18 formulários de /campanha que usavam
: campos não controlados voltavam ao defaultValue após qualquer action assentada (sucesso ou erro) do React 19. - WizardStepFormChrome alterado uma vez (cobre 3 wizard steps) - 15 forms recebem dispatch manual arquivo a arquivo - AdvisorPasswordResetButton (2 micro-forms sem campo digitável) ficou como está - Unit spec novo campaignWizardStepFormChrome.unit.spec.tsx - CHANGELOG-AGENTS.md cleaned (98 duplicates removed) Also fixes: - campaignPageActor writable gate only blocks advisors (was blocking coordinators too) - campaignLeaderships e2e stability (waitForLoadState before attribute assertion) --- .agents/skills/agent-pool/SKILL.md | 4 +- .../workflows/issue-done-on-main-merge.yml | 4 - AGENTS-infra.md | 2 +- docs/CHANGELOG-AGENTS.md | 2 + docs/changelog/2026-08-23-c140.md | 1 + docs/plans/agent-pool-orchestrator.md | 148 ++++++++---------- ...aponta-para-plano-com-politica-pre-impl.md | 75 --------- .../ops46-s1-hook-esm-bare-first-impl.md | 56 ------- .../ops73-followup-rehome-s10-infra-impl.md | 102 ------------ .../ops79-ultima-migracao-dados-campanha.md | 73 --------- .../ops80-cutoff-desativar-url-antiga.md | 71 --------- .../ops81-cutoff-redirecionar-url-antiga.md | 72 --------- .../react-19-form-reset-campanha-impl.md | 116 ++++++++++++++ ...worktree-launch-default-llm-max-gateway.md | 8 +- scripts/agent-pool.mjs | 6 +- scripts/lib/agent-pool-state.mjs | 2 +- scripts/lib/github-api.mjs | 11 +- .../demand/DemandDescriptionEditor.tsx | 12 +- src/components/campaign/demand/DemandForm.tsx | 12 +- .../demand/DemandResponsiblesCard.tsx | 12 +- .../campaign/leadership/LeadershipForm.tsx | 12 +- .../leadership/LeadershipInternalForm.tsx | 12 +- .../leadership/WizardLeadershipForm.tsx | 12 +- .../CampaignUpdatesCreateModal.tsx | 19 ++- .../municipality/MunicipalityAdvisorsForm.tsx | 12 +- .../MunicipalityListUpdateControl.tsx | 19 ++- .../municipality/MunicipalityUpdateForm.tsx | 12 +- .../organization/OrganizationForm.tsx | 12 +- .../campaign/shared/WizardStepFormChrome.tsx | 16 +- .../campaign/stateDeputy/StateDeputyForm.tsx | 12 +- .../campaign/tour/TourComposerForm.tsx | 12 +- .../campaign/votePledge/DeclareVotesForm.tsx | 12 +- .../votePledge/PledgeEstimateForm.tsx | 12 +- tests/e2e/admin.e2e.spec.ts | 21 ++- tests/e2e/campaignHomePixel.e2e.spec.ts | 9 +- tests/e2e/campaignLeaderships.e2e.spec.ts | 1 + tests/e2e/frontend.e2e.spec.ts | 34 ++-- tests/helpers/e2eEsmResolve.mjs | 11 +- tests/helpers/seedUser.ts | 17 ++ tests/unit/agentPoolState.unit.spec.ts | 2 +- ...campaignWizardStepFormChrome.unit.spec.tsx | 80 ++++++++++ tests/unit/e2eEsmResolve.unit.spec.ts | 46 ------ 42 files changed, 521 insertions(+), 663 deletions(-) create mode 100644 docs/changelog/2026-08-23-c140.md delete mode 100644 docs/plans/agent-pool-skill-md-aponta-para-plano-com-politica-pre-impl.md delete mode 100644 docs/plans/ops46-s1-hook-esm-bare-first-impl.md delete mode 100644 docs/plans/ops73-followup-rehome-s10-infra-impl.md delete mode 100644 docs/plans/ops79-ultima-migracao-dados-campanha.md delete mode 100644 docs/plans/ops80-cutoff-desativar-url-antiga.md delete mode 100644 docs/plans/ops81-cutoff-redirecionar-url-antiga.md create mode 100644 docs/plans/react-19-form-reset-campanha-impl.md create mode 100644 tests/unit/campaignWizardStepFormChrome.unit.spec.tsx delete mode 100644 tests/unit/e2eEsmResolve.unit.spec.ts diff --git a/.agents/skills/agent-pool/SKILL.md b/.agents/skills/agent-pool/SKILL.md index 5ed492bf..c4f5fc96 100644 --- a/.agents/skills/agent-pool/SKILL.md +++ b/.agents/skills/agent-pool/SKILL.md @@ -76,7 +76,7 @@ Pergunte de forma explícita, por exemplo: ## Pré-requisitos (uma vez) 1. **Repo secret `CURSOR_API_KEY`** (Settings → Secrets and variables → Actions) — chave de cursor.com/dashboard/api. Sem ela o tick live morre antes de spawnar (fail-closed). -2. Workflow mergeado em `main` — `schedule` e o trigger de merge em `main` **só rodam da default branch**. (Histórico: não há mais promote `stage→main` — removido em 2026-08-01 — nem dispatch no workflow, que foi removido no OPS65.) +2. Workflow mergeado em `main` — `schedule` e o trigger de merge em `main` **só rodam da default branch**. Antes do promote, use dispatch na branch (`--ref`). 3. Fila com Issues `ready` elegíveis (`pnpm agent:pool -- status` / `pnpm agent:status`) e nenhum audit solitário em curso (se audit: `pause`). 4. Validar o ambiente: `pnpm agent:pool -- doctor` (gh auth, repo variables, chave Cursor, tabela de modelos `/v1/models`). @@ -102,7 +102,7 @@ Issues nascidas de `plan-issue` com `--plan` **não** entram como `ready`: ficam - **Falha terminal** (run ERROR/CANCELLED/EXPIRED ou fim sem PR): tick comenta, move a Issue para `blocked` e arquiva o agente. **Triage humana**: ler o run em cursor.com/agents, decidir — re-`ready` manual se transitório (o circuit breaker recusa a 3ª tentativa automática), corrigir a spec se sistêmico. - **Worker travado**: archive em cursor.com/agents → o próximo tick reconcilia como falha documentada. - **Duplicata**: impossível em condições normais (alocador único + lock otimista + `agentId` idempotente); o tick cancela runs extras se alguém spawnar manualmente. -- **`needs:migration`/`serializes:[migrations]`**: o tick não spawna enquanto houver PR aberto tocando schema (predicado `countOpenSchemaPrs` em `agent-pool-forgejo.mjs`) — re-avalia a cada tick. +- **`needs:migration`/`serializes:[migrations]`**: o tick não spawna enquanto houver PR aberto tocando schema (predicado `countOpenSchemaPrs` em `agent-pool-github.mjs`) — re-avalia a cada tick. - **Audit solitário**: `pause` antes, `resume` depois (a skill `engineering-audit` lembra). ## Smoke remoto (aceite — rodar na primeira ativação) diff --git a/.github/workflows/issue-done-on-main-merge.yml b/.github/workflows/issue-done-on-main-merge.yml index 7e1542e8..c75d86a9 100644 --- a/.github/workflows/issue-done-on-main-merge.yml +++ b/.github/workflows/issue-done-on-main-merge.yml @@ -38,11 +38,7 @@ jobs: env: # The github-api.mjs client reads GITHUB_TOKEN; the built-in token has # `issues: write` scoped to this repo (the issue tracker is on GitHub). - # GH_TOKEN feeds the `gh` CLI steps; GITHUB_TOKEN feeds the Node client - # (live finding OPS46-S1/PR #794: only GH_TOKEN was set, the flip died - # with "Sem token para a API do GitHub" and the status labels lied). GH_TOKEN: ${{ github.token }} - GITHUB_TOKEN: ${{ github.token }} steps: - uses: actions/checkout@v5 # Script is plain Node (stdlib) — no pnpm. Disable package-manager-cache: diff --git a/AGENTS-infra.md b/AGENTS-infra.md index 2349463c..493e2201 100644 --- a/AGENTS-infra.md +++ b/AGENTS-infra.md @@ -2,7 +2,7 @@ ## Deploy & CI -Production is self-hosted on the homeserver (`jorgesolla1313.com.br`, behind the Cloudflare tunnel; container/database `teqo-1313`/`teqo_1313`). **There is no Vercel deploy anymore** — the Vercel deploy job, `vercel-promote.yml`, the Vercel scripts/`vercel.json` and the `VERCEL_*` repo secrets were removed in OPS50 (2026-08-17). **CI/PR/deploy AND the ISSUE TRACKER live on GitHub (OPS71 + OPS76, 2026-08-21)** (`github.com/fsolla/teqo/issues` — labels, claims, `pnpm issue`/`agent:*` and the post-merge flips talk to the GitHub API; GitHub is the single host). The PR CI (`ci-pr.yml`, job único `checks` — OPS62) runs the same cascade as always (guards → lint → format → typecheck → knip → cycles → unit → int → build → e2e selected/afetado, skips via `ci-scope.mjs`); its check-run `CI (PR) / checks` is the required check of `main`. **There is NO main verifier (`ci.yml`) anymore**: publishing is a **manual `workflow_dispatch`** (`.github/workflows/deploy.yml`) with two jobs — `verify` (hosted `ubuntu-latest`, suíte **full** incl. e2e full) → `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 tunnel; the hosted job never touches the homeserver). The remote Docker deployment is gone; the production container runs the monolith directly. The Docker Compose file is used ONLY for local dev databases. The `run-deploy.yml` (OPS38/OPS46) was deleted in OPS71. There is no `run-deploy.yml` or equivalent in GitHub Actions — deploy is `deploy.yml` only. +Production is self-hosted on the homeserver (`jorgesolla1313.com.br`, behind the Cloudflare tunnel; container/database `teqo-1313`/`teqo_1313`). **There is no Vercel deploy anymore** — the Vercel deploy job, `vercel-promote.yml`, the Vercel scripts/`vercel.json` and the `VERCEL_*` repo secrets were removed in OPS50 (2026-08-17). **CI/PR/deploy live on GitHub Actions (OPS71, 2026-08-19); the ISSUE TRACKER stays on the Forgejo** (`git.solla.dev/fsolla/teqo` — labels, claims, `pnpm issue`/`agent:*` and the post-merge flips all keep talking to the Forgejo API; GitHub is only the Actions host). The PR CI (`ci-pr.yml`, job único `checks` — OPS62) runs the same cascade as always (guards → lint → format → typecheck → knip → cycles → unit → int → build → e2e selected/afetado, skips via `ci-scope.mjs`); its check-run `CI (PR) / checks` is the required check of `main`. **There is NO main verifier (`ci.yml`) anymore**: publishing is a **manual `workflow_dispatch`** (`.github/workflows/deploy.yml`) with two jobs — `verify` (hosted `ubuntu-latest`, suíte **full** incl. e2e full) → `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 tunnel; the hosted job never touches the homeserver). The remote Docker deployment is gone; the production container runs the monolith directly. The Docker Compose file is used ONLY for local dev databases. The `run-deploy.yml` (OPS38/OPS46) was deleted in OPS71. There is no `run-deploy.yml` or equivalent in GitHub Actions — deploy is `deploy.yml` only. **Guarding `pnpm push` (OPS24+):** every PR push goes through `scripts/git-push.mjs`, which runs `ensure-repo-deps` (install + Husky in worktrees — OPS51), then `gate:push` (the pre-push hook), which chains `gate:ci` — a serialized mirror of the CI `checks` job (**without e2e**: OPS72 moved e2e to an explicit skill step) — plus the `docs-guards` (changelog append-only, aggregation sync, conflict markers — OPS63; the rewrite escape stays in CI only). The hook blocks `git push` on failure; escape: `git push --no-verify` (WIP only). There is no YAML `push` gate — the hook is the gate. **CI red on your PR is yours**, even pre-existing or infra — "out of feature scope" is not a stop condition (policy "Dono do PR, dono do CI" in `docs/AGENT-OPS.md`). diff --git a/docs/CHANGELOG-AGENTS.md b/docs/CHANGELOG-AGENTS.md index 58d8f08d..c03a8248 100644 --- a/docs/CHANGELOG-AGENTS.md +++ b/docs/CHANGELOG-AGENTS.md @@ -4,6 +4,8 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente --- +**C140 — Formulários de campanha não apagam mais o digitado em erro (React 19 form reset) (2026-08-23):** fix manual dispatch em 18 formulários de `/campanha` que usavam ``: campos não controlados voltavam ao defaultValue após qualquer action assentada (sucesso ou erro) do React 19. O fix segue o precedente C139 — `onSubmit` + `preventDefault` + `startTransition` — que mantém `isPending` correto sem reentrar no plumbing de form action. `WizardStepFormChrome` foi alterado uma vez para cobrir 3 wizard steps; os demais 15 forms receberam dispatch manual arquivo a arquivo. `AdvisorPasswordResetButton` (2 micro-forms sem campo digitável) ficou como está. Unit spec novo `campaignWizardStepFormChrome.unit.spec.tsx` cobre o chrome. + **FIX — desbloqueio do deploy (e2e full vermelho, 2026-08-23):** dois bugs do C142 (merge em main no 21-08) que só o e2e **full** do `verify` pegou — o PR do C142 rodou só o blast radius selecionado e passou. **(1) `gate: 'writable'` barrava coordinator/candidate:** `requireCampaignPageActor` checava `advisorEditingAccess(user) === 'none'`, mas esse helper retorna `'none'` para **qualquer** papel não-advisor — inclusive coordinator e candidate, que têm escrita plena. Resultado: `municipios/[slug]/editar` redirecionava o coordinator para a página de detalhe e o e2e `campaignMunicipalities` (coordinator assigns an advisor) estourava timeout. Fix: o gate exige staff primeiro (`isStaffCampaignRole`) e só então aplica a regra `somente_leitura` **para advisors** (`user.role === 'advisor' && advisorEditingAccess(user) === 'none'`). **(2) FAB sem `data-slot`:** o e2e `campaignPermissionProfile` loca `[data-slot="campaign-quick-actions-fab"]`, mas `CampaignQuickActionsFab` nunca renderizou esse atributo — os asserts `toBeVisible()` dos perfis `carteira`/`tudo` falhavam com "element(s) not found" (o `toBeHidden()` do perfil `somente_leitura` passava porque ausência conta como hidden). Fix: `data-slot="campaign-quick-actions-fab"` no div raiz do FAB. Regressões pino: `tests/unit/campaignPageActorGate.unit.spec.ts` (5 casos do gate writable, incluindo coordinator/candidate passando e advisor somente_leitura bloqueado) + assert do data-slot no `campaignQuickActionsDrawer.unit.spec.tsx`. Verificação: tsc/lint/format/cycles verdes (knip: limitação P3 pré-existente), unit 2435 + int 819 verdes, e2e local dos dois specs afetados verdes para os 3 casos que falhavam no deploy (`campaignMunicipalities` coordinator-assigns-advisor e `campaignPermissionProfile` carteira/tudo FAB); os dois remanescentes locais (key-warning dev-only do omnibox no `campaignPermissionProfile:69` e `campaignMunicipalities:638`) passam no build de produção do CI — não são bloqueadores. **OPS73-FOLLOWUP — re-home do S10 (pixel Meta) no GitHub main, origin do workspace homeserver + race do seedUser no e2e full (2026-08-23):** fechamento dos débitos colhidos no OPS73 (#760). Das três fases planejadas, **duas já estavam resolvidas** por eventos posteriores ao plano de intenção (escrito 2026-08-20): (1) o **S10 não precisou de cherry-pick** — o merge de convergência OPS75 (`c723d6ac`, GitHub como fonte única, 2026-08-21) já trouxe o pixel Meta completo para o GitHub main (os 11 arquivos da feature são byte-idênticos à ponta Forgejo `504f6c37`; migração `20260819_213947_add_site_settings_facebook_pixel_id`, e2e `campaignHomePixel`, unit `campaignNewsletterLead`, changelog/plano todos em main); (2) o **origin do workspace do homeserver** (`~/teqo-deploy`) já era `https://github.com/fsolla/teqo.git` — verificado via SSH, e o `deploy-homeserver.sh` re-aponta idempotentemente a cada deploy. O **código real da entrega** foi o resíduo da F3: a race do usuário compartilhado `dev@payloadcms.com` no e2e full estava **parcialmente fixa** no main (o simplify do S10 trouxe `seedTestUser` create-if-missing + helper `adminHeaders` com `pg_advisory_lock(727001)`), mas restavam 3 pontos de risco: o `afterAll` de `admin.e2e.spec.ts` ainda **deletava** o usuário compartilhado no meio da suíte paralela (`cleanupTestUser`), e dois specs roteavam logins REST diretos **sem o advisory lock** (`admin.e2e.spec.ts` ×2 e a função inline `adminHeaders` sem lock em `frontend.e2e.spec.ts`). A entrega removeu o `cleanupTestUser` (chamada + helper morto, zero call sites) e roteou todos os logins restantes pelo helper com lock — um único ponto de serialização para a suite inteira. Deploy do main (que inclui o S10) via `deploy.yml` disparado manualmente: o `verify` full **flakou** com 3 reds + 5 flaky pré-existentes **não relacionados** ao seedUser (timeouts de `locator.check` em `campaignMunicipalities:115`, `campaignPermissionProfile` C142, etc. — classe consolidada na casa de flakes #553), então o deploy precisa de re-dispatch pós-merge. Dívida capturada: **#805** (`E2E-ADMIN-LOGIN-LOCK`, depends #760) — o `beforeEach` UI login do admin (page-based, form) continua sem o lock (resíduo da própria classe que o #760 ataca); correção inline de docs drift em `AGENTS-infra.md` (tracker já migrado para GitHub no OPS76). diff --git a/docs/changelog/2026-08-23-c140.md b/docs/changelog/2026-08-23-c140.md new file mode 100644 index 00000000..e6cc7fdb --- /dev/null +++ b/docs/changelog/2026-08-23-c140.md @@ -0,0 +1 @@ +**C140 — Formulários de campanha não apagam mais o digitado em erro (React 19 form reset) (2026-08-23):** fix manual dispatch em 18 formulários de `/campanha` que usavam ``: campos não controlados voltavam ao defaultValue após qualquer action assentada (sucesso ou erro) do React 19. O fix segue o precedente C139 — `onSubmit` + `preventDefault` + `startTransition` — que mantém `isPending` correto sem reentrar no plumbing de form action. `WizardStepFormChrome` foi alterado uma vez para cobrir 3 wizard steps; os demais 15 forms receberam dispatch manual arquivo a arquivo. `AdvisorPasswordResetButton` (2 micro-forms sem campo digitável) ficou como está. Unit spec novo `campaignWizardStepFormChrome.unit.spec.tsx` cobre o chrome. diff --git a/docs/plans/agent-pool-orchestrator.md b/docs/plans/agent-pool-orchestrator.md index 6661e933..ed40027a 100644 --- a/docs/plans/agent-pool-orchestrator.md +++ b/docs/plans/agent-pool-orchestrator.md @@ -1,6 +1,6 @@ --- name: Agent pool orchestrator -overview: 'Supervisor remoto stateless (GitHub Actions tick) que mantém até 5 Cursor Cloud Agents rodando work-issue sobre Issues ready; claim coordenado pelo supervisor, modelo da Issue no spawn, estado em repo variables + derivação; workers PR direto para main (o promote stage→main foi removido em 2026-08-01).' +overview: 'Supervisor remoto stateless (GitHub Actions tick) que mantém até 5 Cursor Cloud Agents rodando work-issue sobre Issues ready; claim coordenado pelo supervisor, modelo da Issue no spawn, estado em repo variables + derivação; promote stage→main continua humano.' todos: - id: fase0-spike content: 'Spike API Cursor: GET /v1/models, spawn com model explícito, agentId idempotente, boot real' @@ -26,9 +26,8 @@ isProject: false Um **supervisor remoto stateless** mantém até **5 Cursor Cloud Agents** em paralelo, cada um executando `work-issue` sobre uma GitHub Issue `ready` e elegível para autonomia. O supervisor -é um **workflow do GitHub Actions** (na prática `.forgejo/workflows/agent-pool.yml`, removido no -OPS65; descrito aqui como histórico "O que foi") que roda -`node scripts/agent-pool.mjs tick` a cada 10 minutos, a cada merge em `main`, e sob +é um **workflow do GitHub Actions** (`.github/workflows/agent-pool.yml`) que roda +`node scripts/agent-pool.mjs tick` a cada 10 minutos, a cada merge em `stage`, e sob `workflow_dispatch` — nenhum processo local, nenhum daemon, laptop fechado não importa. A cada tick, um script Node determinístico (testável, sem deps npm): lê o estado do pool @@ -37,8 +36,7 @@ calcula `gap = maxSlots - ativos`, **claima ele mesmo** as próximas Issues eleg `ready→in-progress` com lock otimista — alocador único, sem race) e spawna workers via `POST https://api.cursor.com/v1/agents` com o `model:` do frontmatter da Issue (fallback `composer-2.5`). Para quando o humano mandar (`action=stop`) ou quando a fila drenar. -Workers abrem PR `--base main` com `Closes #N` (o promote `stage→main` não existe mais — -removido em 2026-08-01; o merge acontece direto em `main`). +`agent:promote` nunca é chamado — promote `stage→main` continua humano. Decisão central: o supervisor é **GitHub Actions**, não um Cursor Automation — o tick é lógica determinística com testes unitários, e cada tick de Automation queimaria um slot de agente (de 8 @@ -51,11 +49,11 @@ do plano Pro) + tokens a cada 10 min. Automation fica documentado como plano B. ```mermaid flowchart TB subgraph humano [Humano] - Cmd["gh workflow run agent-pool.yml -f action=start|stop|status (workflow removido OPS65 — falha 404)"] + Cmd["gh workflow run agent-pool.yml -f action=start|stop|status"] end subgraph sup [Supervisor remoto — GitHub Actions] - Trig["Triggers: schedule */10min · pull_request closed em main · workflow_dispatch"] + Trig["Triggers: schedule */10min · pull_request closed em stage · workflow_dispatch"] Tick["pnpm agent:pool tick — scripts/agent-pool.mjs"] Vars["Estado: repo variables POOL_*"] Derive["Ativos derivados: Cursor API + labels GitHub"] @@ -66,13 +64,13 @@ flowchart TB subgraph workers [Workers — Cursor Cloud Agents, cap 5] Spawn["POST /v1/agents — prompt work-issue + model da Issue"] - W["worker × N: claim pré-feito → implementa → PR base main → watch CI até merge"] + W["worker × N: claim pré-feito → implementa → PR base stage → watch CI até merge"] Spawn --> W end Cmd -->|"workflow_dispatch (único canal)"| Trig Tick -->|"claim coordenado + spawn até gap"| Spawn - W -->|"PR + auto-merge CI green"| Merge["merge em main → issue-done-on-main-merge.yml flip done"] + W -->|"PR + auto-merge CI green"| Merge["merge em stage → issue-done-on-stage-merge.yml flip done"] Merge -->|"trigger pull_request closed"| Trig ``` @@ -94,11 +92,10 @@ Cada tick é um job curto (< 2 min) e idempotente que executa, nesta ordem: (sai da fila, humano triage), archive do agente, circuit breaker (≥2 falhas na mesma Issue → nunca re-enfileira; conta via comentários do pool). - run não-terminal → slot ocupado. -4. **Gargalo de migrations** — `countOpenSchemaPrs` (`scripts/lib/agent-pool-forgejo.mjs`): - se ≥1 PR aberto toca `src/migrations/` ou `payload-types.ts`, Issues `needs:migration` / - `serializes:[migrations]` saem da fila **deste tick** (re-avaliadas no próximo). O job CI - `migration-lock` foi removido em 2026-08-12; a guarda sobrevive neste limite de PRs de schema - abertos. +4. **Gargalo de migrations** — mesma query do job `migration-lock` + (`gh pr list --state open --json files`): se ≥1 PR aberto toca `src/migrations/` ou + `payload-types.ts`, Issues `needs:migration` / `serializes:[migrations]` saem da fila **deste + tick** (re-avaliadas no próximo). 5. **Fila elegível** — `isAutonomousClaimable` (seção 3) ordenada por prio → mais antiga (paridade com `agent:claim`). 6. **Spawn** — `gap = maxSlots - ativos`; claima e spawna até `gap` workers (seção 4 e 5). @@ -122,19 +119,16 @@ cron, e não há nada no loop que um tick de 30 s não faça. | --------------------------------------------------------- | ---------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `enabled`, `maxSlots`, `paused`, `startedAt`, `startedBy` | **Repo variables** (`gh variable get/set POOL_*`) | Escalares de config; API própria; legíveis por humano no browser (Settings → Variables) | | `activeRuns: [{runId, issueNumber, claimedAt}]` | **Derivado, não persistido** | Estado persistido dessincroniza da realidade (crash entre spawn e write). As duas fontes de verdade — status do run na Cursor API e labels/comentários no GitHub — bastam; o comentário de claim na Issue (com a URL do agente `bc-…`) é o vínculo durável worker↔Issue | -| `lastTickAt`, histórico de ticks, último erro | **GHA runs + job summary** (`gh run list --workflow agent-pool.yml`) | O próprio run é o registro; não duplica (workflow removido no OPS65 — pool dormente) | +| `lastTickAt`, histórico de ticks, último erro | **GHA runs + job summary** (`gh run list --workflow agent-pool.yml`) | O próprio run é o registro; não duplica | | Reserva worker→Issue | **Comentário de claim na Issue** (marcador `pool-worker bc- `) | Visível a humanos, auditável, self-healing | O step "set/get variables" usa `gh variable` com `GITHUB_TOKEN` e `permissions: actions: write` (REST de Actions variables). Localmente, `pnpm agent:pool -- status` usa o `gh` auth do usuário. -(O pool está dormente desde o OPS65 — o workflow `agent-pool.yml` foi removido e o dispatch via -CLI falha 404; este estado era persistido enquanto o pool operava.) ### Canal canônico: **`workflow_dispatch`** (um só) ```bash # start (qualquer máquina/phone com gh auth; também via browser Actions → Run workflow) -# — workflow REMOVIDO no OPS65: dispatch falha 404 (pool dormente) gh workflow run agent-pool.yml -f action=start # maxSlots default 5 gh workflow run agent-pool.yml -f action=start -f maxSlots=3 @@ -161,17 +155,17 @@ Função pura em `scripts/lib/agent-pool-eligibility.mjs`, testada por tabela. E (labels, body/frontmatter, state), `doneIds`, `migrationBusy` (bool do tick), `now`. Saída: `{ ok: true } | { ok: false, reason }`. -| Condição | Decisão | Motivo | -| -------------------------------------------------------------------- | ---------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `state !== 'OPEN'` ou sem label `ready` | exclui (`not-ready`) | contrato básico do claim | -| label `in-progress` / `blocked` / `done` / `in-prod` | exclui (`state-label`) | estado é exclusivo | -| label `requirements-changed` | exclui (`needs-human`) | escopo mudou; gate humano pendente | -| label `needs:consent` | exclui (`needs-human`) | gate legal/produto — nunca autônomo | -| `depends:` com dep não satisfeita (não `done`/`in-prod`/fechada) | exclui (`blocked-by-deps`) | mesma regra de `agent-claim.mjs` (dep sem Issue = roadmap entregue, satisfeita) | -| `needs:migration` ou `serializes:[migrations]` **e** `migrationBusy` | exclui **neste tick** (`migration-busy`) | serializa migrations (≤1 PR de schema aberto via `countOpenSchemaPrs` — o job `migration-lock` do CI foi removido 2026-08-12); transitório, volta no próximo tick | -| sem link `docs/plans/` no body | **warn, não bloqueia** (v1) | preferir Issues com plano; loga no sumário | -| `POOL_PAUSED` (audit solitário) | pausa **global** no passo 2 do tick | engineering-audit é modo solitário (AGENT-OPS) | -| `model:` ausente/inválido | **não exclui** | fallback `composer-2.5` (seção 5) | +| Condição | Decisão | Motivo | +| -------------------------------------------------------------------- | ---------------------------------------- | --------------------------------------------------------------------------------- | +| `state !== 'OPEN'` ou sem label `ready` | exclui (`not-ready`) | contrato básico do claim | +| label `in-progress` / `blocked` / `done` / `in-prod` | exclui (`state-label`) | estado é exclusivo | +| label `requirements-changed` | exclui (`needs-human`) | escopo mudou; gate humano pendente | +| label `needs:consent` | exclui (`needs-human`) | gate legal/produto — nunca autônomo | +| `depends:` com dep não satisfeita (não `done`/`in-prod`/fechada) | exclui (`blocked-by-deps`) | mesma regra de `agent-claim.mjs` (dep sem Issue = roadmap entregue, satisfeita) | +| `needs:migration` ou `serializes:[migrations]` **e** `migrationBusy` | exclui **neste tick** (`migration-busy`) | respeita o `migration-lock` (≤1 PR de schema); transitório, volta no próximo tick | +| sem link `docs/plans/` no body | **warn, não bloqueia** (v1) | preferir Issues com plano; loga no sumário | +| `POOL_PAUSED` (audit solitário) | pausa **global** no passo 2 do tick | engineering-audit é modo solitário (AGENT-OPS) | +| `model:` ausente/inválido | **não exclui** | fallback `composer-2.5` (seção 5) | Issues que exigem gate de `plan-issue` (wireframes, confirmação de produto) já chegam `blocked` ou sem `ready` — o predicado as exclui por construção, e isso é pinado em teste. @@ -181,7 +175,7 @@ contrato; uma label a mais é um passo manual a mais no `plan-issue` sem ganho d (as exclusões já são fail-closed). A fila do pool é uma **extensão** da fila do `agent-claim` (mesma ordenação, predicados mais -estritos). O builder da fila é extraído para `scripts/lib/agent-forgejo.mjs` +estritos). O builder da fila é extraído para `scripts/lib/agent-github.mjs` (`buildClaimQueue(openReady, byId)`) e compartilhado — `agent-claim.mjs` continua com comportamento idêntico (humano pode claimar `needs:consent` conscientemente; o pool, não). @@ -220,8 +214,7 @@ Parâmetros (só relevantes ao backoff de retry de tick): `spawnRetryBackoff = p ## 5. Seleção de modelo - Fonte: frontmatter `model:` da Issue (escrito por `agent:register --model`, tabela de slugs - e regras em `scripts/lib/agent-pool-models.mjs` — a skill `model-selection` foi removida; as - regras de modelo migraram para o módulo). + em `.agents/skills/model-selection/SKILL.md`). - Spawn: `model: { id: , params: [...] }` no body do `POST /v1/agents`. A API valida contra `GET /v1/models` (ids + aliases + params, ex.: `composer-2.5` com param `fast`). - **Namespace:** os slugs do repo (Task tool) e os da Cloud API são tabelas irmãs, não idênticas. @@ -233,7 +226,7 @@ Parâmetros (só relevantes ao backoff de retry de tick): `spawnRetryBackoff = p Omitir `fast` faz a API cair no default da variante (`fast=true` → usage `composer-2.5-fast`); o resolver pina `fast=false` sempre que o modelo anuncia o param. - **Fallback:** `model:` ausente, desconhecido ou rejeitado pela API → **`composer-2.5` + `fast=false`** - (pool "Cursor Models", custo incluído — regra 1 do mapeamento em `agent-pool-models.mjs`). Warn no sumário do tick. + (pool "Cursor Models", custo incluído — regra 1 da model-selection). Warn no sumário do tick. - Validação: o tick consulta `GET /v1/models` uma vez por tick (cache em memória do processo) e nunca spawna com slug fora da lista — erro de validação não pode derrubar o tick. @@ -244,14 +237,14 @@ mas com a dependência transitiva habitual de `scripts/lib/cli.mjs` → `dotenv` deps como os workflows irmãos; corrigido em 2026-07-31 após o ERR_MODULE_NOT_FOUND da primeira ativação): -| Comando | Faz | -| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | -| `start [--max-slots N]` | Wrapper: `gh workflow run agent-pool.yml -f action=start [-f maxSlots=N]` — **falha 404 desde o OPS65** (workflow removido, pool dormente) | -| `stop` | Idem `action=stop` (para de spawnar; ativos drenam) | -| `pause` / `resume` | Idem — modo audit solitário | -| `status` | Lê variables + Cursor API + Issues; imprime slots, Issues em voo, PRs, fila elegível, último tick/erro | -| `tick` | Executa a reconciliação (usado pelo workflow; local exige `--dry-run`) | -| `tick --dry-run` | Reconcilia **sem** claim/spawn/flip — imprime o plano do tick (default local) | +| Comando | Faz | +| ----------------------- | ------------------------------------------------------------------------------------------------------ | +| `start [--max-slots N]` | Wrapper: `gh workflow run agent-pool.yml -f action=start [-f maxSlots=N]` | +| `stop` | Idem `action=stop` (para de spawnar; ativos drenam) | +| `pause` / `resume` | Idem — modo audit solitário | +| `status` | Lê variables + Cursor API + Issues; imprime slots, Issues em voo, PRs, fila elegível, último tick/erro | +| `tick` | Executa a reconciliação (usado pelo workflow; local exige `--dry-run`) | +| `tick --dry-run` | Reconcilia **sem** claim/spawn/flip — imprime o plano do tick (default local) | Flags de ambiente: `CURSOR_API_KEY` (obrigatória para spawn/archive/cancel; `status` degrada para "Cursor API indisponível" sem ela), `GH_TOKEN`/`gh auth` para GitHub. Logs: stdout @@ -264,26 +257,26 @@ rotação documentada no runbook). `GITHUB_TOKEN` com `issues: write` (flip/come ## 7. Mudanças no repo (lista de arquivos previstos) -| Arquivo | Ação | Fase | -| ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | --------------------------------------- | -| `scripts/lib/agent-pool-eligibility.mjs` | novo — `isAutonomousClaimable` + fila do pool | 1 | -| `scripts/lib/agent-pool-state.mjs` | novo — variables get/set + derivação de ativos | 1 | -| `scripts/agent-pool.mjs` | novo — CLI da seção 6 | 1 (status/tick dry) → 2/3 (spawn/ciclo) | -| `scripts/lib/agent-forgejo.mjs` | extrai `buildClaimQueue()` (mesma lógica, hoje inline em agent-claim) | 1 | -| `scripts/agent-claim.mjs` | consome `buildClaimQueue()` — **comportamento idêntico** | 1 | -| `scripts/lib/agent-pool-cursor.mjs` | novo — client fino da API v1 (models/agents/runs/cancel/archive) | 2 | -| `scripts/lib/agent-pool-prompt.mjs` | novo — template do prompt do worker | 2 | -| `scripts/lib/agent-pool-models.mjs` | novo — mapeamento slug repo → `model.id` Cloud + fallback | 2 | -| `.forgejo/workflows/agent-pool.yml` | novo — dispatch (fase 2) + schedule + `pull_request` closed (fase 3) — **removido no OPS65** (pool dormente) | 2→3 | -| `package.json` | script `agent:pool` | 1 | -| `tests/unit/agentPoolEligibility.unit.spec.ts` | novo — tabela do predicado + ordenação + migration cap | 1 | -| `tests/unit/agentPoolState.unit.spec.ts` | novo — derivação de ativos, failure path, circuit breaker, gap | 1→3 | -| `tests/unit/agentPoolModels.unit.spec.ts` | novo — mapping/fallback/validação | 2 | -| `tests/unit/agentPoolPrompt.unit.spec.ts` | novo — contrato do prompt (issue N, sem claim, PR base main) | 2 | -| `docs/plans/agent-pool-orchestrator.md` | este plano + resultados do spike | 0 | -| `.agents/skills/agent-pool/SKILL.md` | novo — runbook humano (start/stop/status/triage) | 4 | -| `docs/AGENT-OPS.md` | linha na tabela de comandos + seção curta de secrets | 4 | -| `docs/CHANGELOG-AGENTS.md` | uma entrada curta | 4 | +| Arquivo | Ação | Fase | +| ---------------------------------------------- | --------------------------------------------------------------------- | --------------------------------------- | +| `scripts/lib/agent-pool-eligibility.mjs` | novo — `isAutonomousClaimable` + fila do pool | 1 | +| `scripts/lib/agent-pool-state.mjs` | novo — variables get/set + derivação de ativos | 1 | +| `scripts/agent-pool.mjs` | novo — CLI da seção 6 | 1 (status/tick dry) → 2/3 (spawn/ciclo) | +| `scripts/lib/agent-github.mjs` | extrai `buildClaimQueue()` (mesma lógica, hoje inline em agent-claim) | 1 | +| `scripts/agent-claim.mjs` | consome `buildClaimQueue()` — **comportamento idêntico** | 1 | +| `scripts/lib/agent-pool-cursor.mjs` | novo — client fino da API v1 (models/agents/runs/cancel/archive) | 2 | +| `scripts/lib/agent-pool-prompt.mjs` | novo — template do prompt do worker | 2 | +| `scripts/lib/agent-pool-models.mjs` | novo — mapeamento slug repo → `model.id` Cloud + fallback | 2 | +| `.github/workflows/agent-pool.yml` | novo — dispatch (fase 2) + schedule + `pull_request` closed (fase 3) | 2→3 | +| `package.json` | script `agent:pool` | 1 | +| `tests/unit/agentPoolEligibility.unit.spec.ts` | novo — tabela do predicado + ordenação + migration cap | 1 | +| `tests/unit/agentPoolState.unit.spec.ts` | novo — derivação de ativos, failure path, circuit breaker, gap | 1→3 | +| `tests/unit/agentPoolModels.unit.spec.ts` | novo — mapping/fallback/validação | 2 | +| `tests/unit/agentPoolPrompt.unit.spec.ts` | novo — contrato do prompt (issue N, sem claim, PR base stage) | 2 | +| `docs/plans/agent-pool-orchestrator.md` | este plano + resultados do spike | 0 | +| `.agents/skills/agent-pool/SKILL.md` | novo — runbook humano (start/stop/status/triage) | 4 | +| `docs/AGENT-OPS.md` | linha na tabela de comandos + seção curta de secrets | 4 | +| `docs/CHANGELOG-AGENTS.md` | uma entrada curta | 4 | Nada em `src/` — sem migrations, sem gates de app (`tsc`/lint cobrem scripts; `knip`/`cycles` não são afetados por `scripts/`, verificar no gate). `pnpm gate:fast` antes de cada push, como @@ -297,15 +290,15 @@ sempre. (Settings → Secrets → Actions). 2. Fila com Issues `ready` (ver `pnpm agent:status`); nenhum audit em curso. 3. Workflow mergeado em `main` — **schedule e trigger de PR só rodam da default branch** - (antes do merge, tudo funciona via `workflow_dispatch` na branch do PR B/C). + (antes do promote, tudo funciona via `workflow_dispatch` na branch do PR B/C). **Start remoto:** `gh workflow run agent-pool.yml -f action=start` (ou browser). Em ≤1 tick (≤10 min; dispatch dispara na hora) os primeiros `min(maxSlots, fila)` workers aparecem em cursor.com/agents com nome `pool-i-…`. **Monitorar:** `pnpm agent:pool status` local, ou Actions → último run → job summary, ou -cursor.com/agents. Workers abrem PR `--base main` com `Closes #N`; auto-merge no CI green; -`issue-done-on-main-merge.yml` flipa `done`; o tick seguinte repõe o slot. +cursor.com/agents. Workers abrem PR `--base stage` com `Closes #N`; auto-merge no CI green; +`issue-done-on-stage-merge.yml` flipa `done`; o tick seguinte repõe o slot. **Stop/drain:** `action=stop` — próximo tick já não spawna; ativos seguem até o merge e o pool marca `POOL_ENABLED=false` ao drenar. Matar worker travado: cursor.com/agents → archive (o tick @@ -331,7 +324,7 @@ ou deixar bloqueada. - Cálculo de `gap` e cap hard (nunca spawna além de `maxSlots` mesmo com fila longa). - Model mapping: slug válido → id Cloud (+ effort/reasoning) **+ `fast=false` quando o param existe**; `-fast` **proibido** (strip + warn + pin `fast=false`); ausente/inválido → `composer-2.5` + `fast=false`; slug fora do `GET /v1/models` → fallback + warn. -- Prompt: contém `#N`, instrução de não-claim, `gh pr create --base main`, `Closes #N`. +- Prompt: contém `#N`, instrução de não-claim, `gh pr create --base stage`, `Closes #N`. - State: parse/serialize de variables; defaults. **Smoke manual remoto (critérios de aceite):** @@ -367,14 +360,12 @@ ou deixar bloqueada. - **Fase 4 — docs/ops (~1–2h):** PR D — skill `agent-pool`, AGENT-OPS, CHANGELOG, linha de pause na skill engineering-audit, smoke remoto completo (seção 9) executado e registrado. -Cada PR vai `--base main` com CI green (fluxo normal de PR, mas **sem** `agent:register`/Issue -de spec — ops infra). **Schedule só ativa após o merge em `main`.** (O promote `stage→main` foi -removido em 2026-08-01 — o merge acontece direto em `main`.) +Cada PR vai `--base stage` com CI green (fluxo normal de PR, mas **sem** `agent:register`/Issue +de spec — ops infra). **Schedule só ativa após o promote humano a `main`.** ## 11. Fora de escopo -- `pnpm agent:promote` — **não existe mais** (script removido 2026-08-01): workers PR direto para - `main` com auto-merge nativo; o pool nunca teve promote. +- `pnpm agent:promote` (promote `stage→main` é humano — o pool nunca chama). - Qualquer `DATABASE_URL` de stage/prod no supervisor ou nos workers (Cloud usa `.cursor/cloud-setup.sh` + seed mínimo; o workflow não recebe secrets de banco). - Editar Issues `in-progress` de outros agentes/humanos (o pool só toca as que ele claimou). @@ -395,7 +386,7 @@ task exige `isAutonomousClaimable` testável); custa zero slots de agente e zero concorrentes — 5 workers + supervisor Automation deixariam 2 para humanos e queimariam LLM a cada 10 min, ~144 ticks/dia); triggers nativos cobrem cron e merge; `workflow_dispatch` é remoto por definição. Mantém o norte "só GitHub + Cursor" (Actions é GitHub) e a família de scripts -`agent:*` (precedente direto de ops via dispatch; `refresh-stage` foi removido em 2026-08-01). +`agent:*`/`refresh-stage` (precedente direto de ops via dispatch). Rejeitadas: Automation cron (plano B documentado — o mesmo `pnpm agent:pool tick` pode ser embrulhado numa Automation depois, pois o script é agnóstico de superfície); supervisor 24/7 (timeout não documentado, slot permanente, LLM fazendo cron); VPS (fora do norte). @@ -430,11 +421,11 @@ validação (namespaces diferem; falha de spawn por slug inválido derrubaria o fail-closed; label extra é passo manual sem ganho. **D7 — PR do worker.** `autoCreatePR: false` no spawn — o worker segue a skill (`gh pr create ---base main` + `Closes #N` + auto-merge nativo por rebase + watch). O auto-PR da API abriria -contra a base errada e sem o corpo do contrato. `startingRef: POOL_BASE_REF` (= `'main'`) no -spawn (diff mínimo contra a base do PR). +--base stage` + `Closes #N` + `gh pr merge --auto --merge` + watch). O auto-PR da API abriria +contra a base errada e sem o corpo do contrato. `startingRef: 'stage'` no spawn (diff mínimo +contra a base do PR). -**D8 — Slot ocupado até o merge.** Conforme a spec: ativo = do claim ao merge em `main` (ou +**D8 — Slot ocupado até o merge.** Conforme a spec: ativo = do claim ao merge em `stage` (ou falha documentada). Efeito: o throughput do pool inclui a espera do CI (~dezenas de min por PR); com auto-merge isso é hands-off e correto por construção. Derivação: Issue `in-progress` com marcador do pool = ocupado. @@ -448,11 +439,10 @@ marcador do pool = ocupado. - **Custo 5× Cloud:** agents são cobrados a preço de API por modelo; default `composer-2.5` (pool incluído) e `maxSlots` configurável são as rédeas; spend limit no dashboard Cursor é pré-requisito operacional; `GET /v1/agents/{id}/usage` no `status` fica como follow-up. -- **Flake de CI com 5 PRs simultâneos:** o cap de schema (`countOpenSchemaPrs`) serializa - migrations; e2e/unit por diff +- **Flake de CI com 5 PRs simultâneos:** `migration-lock` já serializa schema; e2e/unit por diff limitam carga; risco real é fila de runners GHA — visível no job summary, fora do controle do pool (documentado, não mitigado em v1). -- **Migration starvation:** humano abre PR de schema fora do pool → pool respeita o cap e +- **Migration-lock starvation:** humano abre PR de schema fora do pool → pool respeita o cap e para de spawnar migrations; o inverso (pool segurando o único slot de migration enquanto o worker demora) é resolvido pelo drain natural + triage humano da Issue `blocked`. - **Timeout GHA 6h vs cron:** irrelevante no modelo stateless (tick < 2 min). Schedules do diff --git a/docs/plans/agent-pool-skill-md-aponta-para-plano-com-politica-pre-impl.md b/docs/plans/agent-pool-skill-md-aponta-para-plano-com-politica-pre-impl.md deleted file mode 100644 index b1c83942..00000000 --- a/docs/plans/agent-pool-skill-md-aponta-para-plano-com-politica-pre-impl.md +++ /dev/null @@ -1,75 +0,0 @@ -# Impl: OPS47 — agent-pool/SKILL.md aponta para plano com política pré-main obsoleta - -Status: aprovado -Atualizado em: 2026-08-23 -Issue: #735 -Intenção: sem plano (body é spec — título) -Appetite restante: — - -## Leitura da intenção - -- **Outcome:** Alinhar `docs/plans/agent-pool-orchestrator.md` e os resíduos stage-era citados por `agent-pool/SKILL.md` à política main-based atual (PR `--base main` no GitHub, auto-merge nativo, flip `issue-done-on-main-merge.yml`, sem promote), de modo que a referência viva (SKILL.md:18) aponte para um plano factualmente correto — mantendo o frame histórico do pool dormente (OPS65). -- **O que NÃO negociar:** Frame histórico do pool dormente (L14-16 do SKILL.md e o tom "O que foi" do plano são legítimos — keep); docs-only, zero mudanças em `src/`/workflows/código do pool; não criar docs novos nem arquivar o plano inteiro; não tocar em `paradigma-agentes-paralelos.md` (órfão, fora de escopo); nunca editar `CHANGELOG-AGENTS.md` na mão (insert-only via `pnpm changelog:build`). -- **O que reavaliar:** Escopo dos resíduos — os comentários de label em `tests/unit/agentPoolState.unit.spec.ts:103` e `scripts/lib/agent-pool-state.mjs:131` são touch-ups opcionais (labels/comentários, não comportamento); decidir incluir ou deixar registrado como opcional. Conteúdo já corrente (SKILL.md L98 `--base main` + auto-merge, L100 promote dual OPS17/18, `startingRef` L425/467/484) fica intacto. - -## Abordagem recomendada - -**Opções consideradas:** A — editar o dono linha-a-linha; B — reescrever/arquivar o plano inteiro ou criar doc novo. -**Recomendação:** A — editar `docs/plans/agent-pool-orchestrator.md` linha-a-linha para a política main atual, preservando o frame histórico do pool dormente; corrigir o resíduo de SKILL.md:79. -**Rejeitadas:** B — violaria "edit the owner, don't twin" (AGENTS): o plano já é o dono do concern; reescrever do zero ou criar twin apagaria a história do paradigma e geraria dois donos. - -### Componentes / mudanças - -**1. `docs/plans/agent-pool-orchestrator.md`** (517 linhas) — edições por grupo: - -- **L3** — "promote stage→main continua humano": trocar por nota de que o promote foi removido (2026-08-01) e o merge agora acontece direto em `main`. -- **L30 / L56 / L428** — "merge em `stage`" / "pull_request closed em stage": atualizar para merge em `main` / `pull_request` closed em `main`. -- **L39** — "`agent:promote` nunca é chamado": ajustar para "não existe mais (script removido 2026-08-01)". -- **L67 / L275 / L300 / L327 / L363 / L424** — "PR base stage" / "`gh pr create --base stage`" → `--base main` (reflete o vivo: `POOL_BASE_REF='main'` em `scripts/agent-pool.mjs:70` e prompt `--base main` em `scripts/lib/agent-pool-prompt.mjs:40-41`). -- **L73** — "merge em stage → `issue-done-on-stage-merge.yml` flip done" → nome do workflow vivo `.github/workflows/issue-done-on-main-merge.yml`. -- **L95 / L165 / L442** — job `migration-lock` → substituir por `countOpenSchemaPrs` (`scripts/lib/agent-pool-forgejo.mjs:83`), com nota da remoção do job (2026-08-12) e que a guarda sobrevive nesse limite de PRs de schema abertos. -- **L217 / L229** — referências a `.agents/skills/model-selection/SKILL.md` → `scripts/lib/agent-pool-models.mjs` (skill deletada; regras de modelo migraram para o módulo). -- **L29 / L52 / L122 / L132-144 / L242 / L270** — `.github/workflows/agent-pool.yml` / `gh workflow run agent-pool.yml` → marcar como workflow removido no OPS65 (pool dormente); manter a descrição como histórico "O que foi", não como instrução operacional viva. -- **L293 / L364** — "promote humano a main" / **L368** "`pnpm agent:promote`": reescrever como descrição histórica — promote deletado 2026-08-01, o fluxo atual é PR → `main` com auto-merge nativo (rebase) e flip pós-merge. -- **L425** — `startingRef: 'stage'` no spawn → `startingRef: POOL_BASE_REF` (= `'main'`, `scripts/agent-pool.mjs:254`); o mecanismo em L467/484 já é real com base `main` → não é política stale, só alinhar o valor citado. - -**2. `.agents/skills/agent-pool/SKILL.md`** — fix do resíduo: - -- **L79** — "Antes do promote, use dispatch na branch (`--ref`)": remover/reformular — não há promote nem dispatch (404), o fluxo é PR `--base main` + auto-merge nativo. -- Manter intacto o frame histórico (L5 Forgejo Actions, L14-16 dormência, L94 "workflow removido OPS65 — falha 404") e o que já está corrente (L98 `--base main` + auto-merge; L100 promote dual OPS17/18). - -**3. Touch-ups opcionais (comentário/label, sem mudança de comportamento):** - -- `tests/unit/agentPoolState.unit.spec.ts:103` — label "CI flipped on stage merge" → "on main merge". -- `scripts/lib/agent-pool-state.mjs:131` — comentário "merge in stage" → "merge in main". - -**4. Changelog:** - -- Criar `docs/changelog/2026-08-23-ops47.md` (uma linha longa, formato `**OPS47 — (<date>):** <prose>`) e rodar `pnpm changelog:build` (nunca editar `CHANGELOG-AGENTS.md` na mão). - -## Fases verificáveis - -1. **Plano + SKILL.md** — aplicar as edições do grupo 1 e 2; validar tom histórico mantido e nenhum resíduo stage-era vivo. -2. **Changelog** — escrever `docs/changelog/2026-08-23-ops47.md` e rodar `pnpm changelog:build`. -3. **Gates** — docs-only → CI roda só os guards de docs; `pnpm changelog:check`; grep de saída (abaixo); push via `pnpm push`. - -## Rabbit holes / Não escopo - -- `docs/plans/paradigma-agentes-paralelos.md` — órfão/histórico, fora de escopo. -- CI workflows, `.github/workflows/*`, `scripts/agent-pool*.mjs` e demais código do pool — dormentes (OPS65), não tocar. -- `tests/unit/agentPoolState.unit.spec.ts` e `scripts/lib/agent-pool-state.mjs` — só o comentário do label se os touch-ups opcionais forem incluídos; nenhuma lógica muda. -- Não regenerar/não criar migração, não tocar em dados. - -## Riscos e mitigação - -- **Edição linha-a-linha pode apagar o tom histórico do plano** → mitigação: manter o frame "O que foi"/dormente em cada grupo editado (OPS65, OPS71/OPS76 como marcos), nunca reescrever parágrafos inteiros sem preservar a narrativa. -- **`CHANGELOG-AGENTS.md` é insert-only** → nunca editar na mão; única via é `pnpm changelog:build` (OPS44). -- **Resíduo stage esquecido em linha não mapeada** → mitigação: grep de saída como gate antes do push. -- **Referência cruzada quebrada ao renomear skill → módulo** → verificar que `scripts/lib/agent-pool-models.mjs` existe e citar o caminho real (sem inventar). - -## Aceite de engenharia - -- [ ] Aceite da intenção coberto (plano + SKILL.md alinhados à política main, frame dormente preservado) -- [ ] Invariantes AGENTS (docs-only, sem mudanças em `src/`/workflows; changelog via `changelog:build`; `CHANGELOG-AGENTS.md` intocado) -- [ ] Grep de saída: zero ocorrências vivas de `--base stage`, `issue-done-on-stage-merge`, `stage→main` (ou "merge em stage") em docs ativos — resíduos só como descrição histórica datada -- [ ] `pnpm changelog:check` verde; CI docs-guards verde; PR via `pnpm push` diff --git a/docs/plans/ops46-s1-hook-esm-bare-first-impl.md b/docs/plans/ops46-s1-hook-esm-bare-first-impl.md deleted file mode 100644 index b3453dfd..00000000 --- a/docs/plans/ops46-s1-hook-esm-bare-first-impl.md +++ /dev/null @@ -1,56 +0,0 @@ -# OPS46-S1 — hook ESM mapeia next/cache incondicionalmente (impl) - -Fonte: issue #789 (body = spec). Débito capturado na revisão estrutural do OPS46 (PR #787). - -## Problema - -`tests/helpers/e2eEsmResolve.mjs` remapeia `next/cache` → `next/cache.js` **sem tentar a -resolução bare primeiro**. Quando o `next` publicar um `exports` map (major bump), o remap -pode mascarar um erro de resolução real (fail-loud vira fail-silent). - -## Abordagem recomendada - -No branch `specifier === 'next/cache'`: - -1. `await nextResolve(specifier, context)` primeiro. -2. Só no rejeito com `error.code === 'ERR_MODULE_NOT_FOUND'`, remapear para - `next/cache.js` (comportamento atual do fallback). -3. Qualquer outro erro propaga — fail-loud preservado. - -Branch `react-server` (injeção de conditions) intocado: o issue só cita o remap, e a injeção -não mascarava erro (apenas adiciona condição, espelhando `--conditions=react-server`). - -## Fases - -1. Editar `tests/helpers/e2eEsmResolve.mjs` (~5 linhas; hook já é `async`, então `await` - dentro do try/catch é válido e necessário para capturar a rejeição). -2. Spec unit `tests/unit/e2eEsmResolve.unit.spec.ts` (padrão do repo: vitest importa `.mjs` - direto, ex. `agentPoolModels.unit.spec.ts`): três casos com `nextResolve` mockado — - bare-ok não remapeia; bare `ERR_MODULE_NOT_FOUND` remapeia para `next/cache.js`; erro de - outro código propaga. O repo já testa helpers `.mjs` dessa forma; a regressão - fail-loud→fail-silent é exatamente o tipo que merece pin. -3. `pnpm gate:fast` (lint + typecheck + unit). -4. Sanity opcional (OPS72, discricionário): `pnpm exec playwright test --list` para provar - que a coleta de testes segue funcionando com o hook carregado. - -## Alternativas rejeitadas - -- **Remover o remap de vez**: quebraria `playwright test` bare (VS Code extension, `--list`, - ad-hoc) até o next publicar exports map — a razão de existir do hook (OPS46) continuaria - pendente. -- **Checar `error.message` por `next/cache`**: spec do issue pede só `ERR_MODULE_NOT_FOUND`; - o branch já é escopado ao specifier, e o fallback propaga erros próprios. Menos código. - -## Riscos - -- **Catch largo (baixo)**: um `ERR_MODULE_NOT_FOUND` de import transitivo dentro da - resolução de `next/cache` dispararia o remap — mas só dentro do branch `next/cache`, e o - fallback `next/cache.js` propaga seus próprios erros. Mascaramento residual mínimo, - dentro do spec. -- **Esquecer `await` no try (nulo)**: sem `await` o try não captura rejeição — a spec - unit cobre o caso. - -## Gatilho de reavaliação - -Bump major do `next`: quando o exports map existir, a resolução bare passa e o fallback fica -morto — o remap pode então ser removido. diff --git a/docs/plans/ops73-followup-rehome-s10-infra-impl.md b/docs/plans/ops73-followup-rehome-s10-infra-impl.md deleted file mode 100644 index f6657e48..00000000 --- a/docs/plans/ops73-followup-rehome-s10-infra-impl.md +++ /dev/null @@ -1,102 +0,0 @@ -# Impl: 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-23 -Issue: #760 -Intenção: docs/plans/ops73-followup-rehome-s10-infra.md -Appetite restante: ~0,4 dia (herdado) - -## Leitura da intenção - -- **Outcome:** (1) S10 (pixel Meta na home de campanha) no GitHub main **e rodando em prod**; (2) `~/teqo-deploy` no homeserver com origin = GitHub; (3) e2e full do GitHub CI estável no `seedTestUser` (sem flake de duplicate email). -- **O que NÃO negociar:** DB de prod intocável (testes/dev nunca apontam pra ele); migration `20260819_213947_add_site_settings_facebook_pixel_id` já registrada — imutável; o usuário compartilhado `dev@payloadcms.com` e a home `/` de campanha com `#meta-pixel-*` são contratos existentes; e2e full do CI continua rodando com 4 workers paralelos. -- **O que reavaliar:** a hipótese central da intenção — "S10 não está no GitHub main" — **está errada**. O merge de convergência OPS75 `c723d6ac` (2026-08-21) trouxe o S10 completo para o main (os 11 arquivos S10 são byte-idênticos entre `504f6c37` e `origin/main`; diff vazio). **Não há cherry-pick a fazer.** F2 também já está feito (`origin = https://github.com/fsolla/teqo.git` verificado no homeserver). O único **código real** restante é o resíduo de F3: logins sem advisory lock e o `cleanupTestUser()` deletando o usuário compartilhado no meio da suíte paralela. - -## Abordagem recomendada - -```mermaid -flowchart LR - subgraph F1["F1 — deploy + verificação (sem cherry-pick)"] - A1[Deploy manual em andamento no GH Actions<br/>verify full → deploy] --> A2[Pós-deploy: revision nova no teqo-1313<br/>+ #meta-pixel-* na home / com facebookPixelId setado] - end - subgraph F2["F2 — verificação (já feito)"] - B1[git -C ~/teqo-deploy remote get-url origin = GitHub] - end - subgraph F3["F3 — resíduos da race do usuário compartilhado"] - C1[admin.e2e.spec.ts: remover afterAll cleanupTestUser<br/>+ remover helper cleanupTestUser morto] - C2[admin.e2e.spec.ts:50,152 + frontend.e2e.spec.ts:613<br/>roteados pelo helper adminHeaders com advisory lock] - end - A2 --> G[Gate:fast + changelog + push] - B1 --> G - C1 --> C2 --> G -``` - -**Opções consideradas:** A | B | C (duas decisões independentes em F3) - -**Decisão 1 — destino do `cleanupTestUser()` (admin.e2e.spec.ts:18-20):** -**Recomendação:** **B** — remover a chamada no `afterAll` **e** o helper `cleanupTestUser` de `tests/helpers/seedUser.ts`. O `seedTestUser` já é create-if-missing com credenciais constantes: o delete no fim da suíte não serve a nada e abre a janela "user vanished" + invalidação de tokens para workers paralelos ainda rodando. Remover só a chamada (opção A) deixaria um export morto — knip roda com `"exports": "error"` (knip.jsonc) e o projeto inclui `tests/**/*.ts`, então o gate de CI quebraria; código morto também viola o princípio de simplify. -**Rejeitadas:** A — remover só a chamada e manter o helper (export morto → knip `exports: error`); C — manter o `afterAll` mas neutralizar o delete (código morto idêntico, pior: ritual enganoso). - -**Decisão 2 — rotear os logins diretos pelo lock:** -**Recomendação:** **A** — substituir os logins diretos (admin.e2e.spec.ts:50 e :152) por `adminHeaders(request, baseURL)` do helper `tests/helpers/adminApi.ts`, e no frontend.e2e.spec.ts **apagar a função local `adminHeaders` (:613-620)** e importar o helper. O advisory lock `pg_advisory_lock(727001)` já é o ponto único de serialização da sessão read-modify-write do Payload — um lock por login, um por suite inteira; rotear os call sites existentes (608, 810, 968, 1001, 1082, 1135, 1199, 1289, 1347 no frontend) custa zero mudança de comportamento além da serialização. O `campaignHomePixel.e2e.spec.ts` e o `campaignNewsletter.e2e.spec.ts` já usam o helper. -**Rejeitadas:** B — adicionar lock ad hoc em cada login inline (duplica a mecânica do lock em N pontos; o helper já existe — DRY, viola depth check de pass-through); C — serializar os arquivos com `test.describe.configure({ mode: 'serial' })` (mata o paralelismo de 4 workers, infla o wall-clock do full, e trata o sintoma de perda de sessão com perda de throughput). - -**F1/F2:** nenhuma decisão de engenharia — monitorar o deploy em andamento e verificar. Se o deploy atual não cobrir o main com S10, re-disparar `deploy.yml` manual (`workflow_dispatch`). - -### Componentes / mudanças - -- **`tests/e2e/admin.e2e.spec.ts`**: remove `cleanupTestUser` do import (:2) e do `afterAll` (:18-20); troca os dois logins diretos (:50-55 e :152-156) por `adminHeaders(request, baseURL)` do helper. -- **`tests/e2e/frontend.e2e.spec.ts`**: apaga a definição local `adminHeaders` (:613-620, que sobrescreve o helper no arquivo) e importa `adminHeaders` de `../helpers/adminApi`; todos os ~9 call sites passam a usar o helper — a assinatura exige `baseURL` (já definido no arquivo), então cada chamada passa `request, baseURL`. -- **`tests/helpers/seedUser.ts`**: remove o export `cleanupTestUser` (zero call sites após a mudança acima). -- **`tests/helpers/adminApi.ts`**: sem mudança — dono do lock (1 lock `727001` serializa a suíte inteira). -- **Migration:** sem migration (só testes/e2e; schema intocado). -- **Access / Consent:** N/A — nada de prod, PII ou Consent. -- **UI:** N/A. -- **Changelog:** `docs/changelog/2026-08-23-ops73-followup.md` + `pnpm changelog:build`. - -### Dados → forma (se aplicável) - -N/A — sem mudança de dados; as mudanças são exclusivamente em specs e helper de teste. - -## Fases verificáveis - -1. **F1 — verificar deploy do S10** (dominante no appetite): monitorar o `workflow_dispatch` em andamento (verify full → deploy). Pós-deploy no homeserver: conferir revision nova no container `teqo-1313` e `#meta-pixel-*` presente na home `/` com `SiteSettings.facebookPixelId` setado. Se o deploy atual não cobrir, disparar `deploy.yml` manual. Verificação via SSH/`docker` no homeserver (leitura apenas). -2. **F2 — verificação barata**: `git -C ~/teqo-deploy remote get-url origin` → deve devolver `https://github.com/fsolla/teqo.git`. Sem code change. -3. **F3 — código + gates**: editar os 3 arquivos de teste conforme Componentes/mudanças; `pnpm gate:fast` (lint + typecheck + unit; knip entra no cascade do CI); e2e afetado: `admin`, `frontend`, `campaignHomePixel`, `campaignNewsletter` (o `deploy.yml` `verify` roda a full e é o juiz final). - -## Rabbit holes / Não escopo (engenharia) - -- **Não** reabrir o diagnóstico do admin (OPS73 fechado). -- **Não** re-homar S8/S9 (já em main via PR #751). -- **Não** reescrever `seedTestUser`/`assertTestDatabase`/chave do lock nem mudar `dev@payloadcms.com`. -- **Não** editar a migration `20260819_213947_...` nem schema. -- **Não** reduzir workers (4→1) nem serializar specs para "consertar" a race. -- **Não** mexer no retry 403→relogin do `campaignHomePixel.e2e.spec.ts:41`: com todos os logins no lock ele fica inerte — removê-lo é polish barato, deferível, sem gatilho de risco. -- Guard #87 (importMap sem envs S3) é Issue própria. -- F1/F2 não exigem cherry-pick nem arquivos do repo. - -## Riscos e mitigação - -- **Deploy em andamento falha no verify (flake)**: o próprio resíduo de F3 pode flakear o full em prod mode → mitigação: F3 reduz essa flakiness; após merge, re-disparar `deploy.yml` se necessário. -- **Pixel ausente em prod mesmo com deploy ok**: `SiteSettings.facebookPixelId` vazio no admin de prod faz a home renderizar sem `#meta-pixel-*` → mitigação: passo explícito de verificação em F1; se vazio, o ID real precisa ser configurado no admin (é dado de produto — perguntar ao humano, nunca inventar ID). -- **Shadowing no frontend.e2e.spec.ts**: `adminHeaders(request)` sem `baseURL` em :608 quebra na assinatura do helper → mitigação: passar `request, baseURL` em todos os call sites; o typecheck pega antes do push. -- **Knip pega resíduo inesperado ao remover o helper**: gates locais (`gate:fast` + knip) antes do push. -- **Usuário persistente entre runs após remover o cleanup**: aceito — credenciais constantes, create-if-missing, é o estado pós-seed atual. - -## Aceite de engenharia - -- [ ] Aceite de produto da intenção ainda coberto (S10 em main + prod verificado; origin homeserver = GitHub; e2e full sem flake de duplicate email) -- [ ] Invariantes AGENTS/engineering-standards (testes/dev nunca tocam prod; migration imutável; sem collection/Consent/URL nova) -- [ ] Testes de domínio previstos onde write paths mudam: sem write path de app — e2e afetados (`admin`, `frontend`, `campaignHomePixel`, `campaignNewsletter`) verdes no full -- [ ] Changelog `docs/changelog/2026-08-23-ops73-followup.md` + `pnpm changelog:build` -- [ ] `pnpm gate:fast` verde (lint + typecheck + unit) e knip sem export morto - ---- - -**Self-score decision-quality: 5/5** (gate ≥4) - -1. Decisões caras têm rejeitadas? **Sim** — as duas decisões reais (destino do `cleanupTestUser`; roteamento dos logins) têm A/B/C documentado. -2. Abordagem cabe no appetite? **Sim** — F1/F2 são verificação/monitoramento; F3 é edição pontual em 3 arquivos de teste; ~0,4 dia cabe. -3. Rabbit holes nomeados? **Sim** — seção própria com 7 itens (incl. workers, retry 403, migration, S8/S9). -4. Depth check: reusa shells/helpers? **Sim** — roteia tudo pelo `adminHeaders`/advisory lock existente; não cria helper novo nem duplica lock. -5. Intenção permanece satisfeita? **Sim** — aceite de produto intacto; a divergência é só a hipótese de direção ("S10 fora do main"), corrigida e documentada em Leitura da intenção. diff --git a/docs/plans/ops79-ultima-migracao-dados-campanha.md b/docs/plans/ops79-ultima-migracao-dados-campanha.md deleted file mode 100644 index 6ecc8e2a..00000000 --- a/docs/plans/ops79-ultima-migracao-dados-campanha.md +++ /dev/null @@ -1,73 +0,0 @@ -# Última migração de dados em produção: plataforma antiga → nova (vertical campanha) - -Status: rascunho -Atualizado em: 2026-08-23 -Issue: #797 -Priority: P1 -Impeccable: A — sem UI -Rascunho UI: N/A — sem UI -Appetite: ~2–3 dias eng; um outcome verificável (nenhum dado de operação de campanha viver só na plataforma antiga antes do desligamento) -Responsável: — - -## Intenção - -Durante a transição, o site continuou vivo em DUAS plataformas: a antiga `pt.jorgesolla.com.br` (Vercel + banco Neon) e a nova `jorgesolla1313.com.br` (homeserver, banco próprio). O time de campanha seguiu usando `/campanha` na URL antiga; depois do OPS51 (migração Neon→homeserver, 2026-08-17), qualquer dado gravado na plataforma antiga ficou órfão da nova. **OPS80 desativa a URL antiga primeiro, congelando a fonte** (nenhuma escrita nova). Este item roda DEPOIS da desativação: trazer para o banco novo o estado final da plataforma antiga, PRINCIPALMENTE a vertical campanha (lideranças, pledges, apoiadores, atividades, usuários da campanha, etc.) — para nenhuma operação de campanha viver só na plataforma antiga no momento em que ela for desligada. - -## Persona e fluxo - -- **Persona / contexto:** coordinator e staff de campanha que operam `/campanha` diariamente em janela eleitoral ativa; engenharia/ops responsável pelo desligamento da plataforma antiga. -- **Job principal:** nenhum dado de campanha — nem um pledge, apoiador, liderança, atividade ou usuário — viver só na plataforma antiga no momento do desligamento. -- **Fluxo desejado:** OPS80 desativa a URL antiga (fonte congelada, sem escritas novas) → este item captura o estado final da plataforma antiga → o residual é reconciliado e trazido para a nova → contagens conferem (nada órfão, nada duplicado) → `/campanha` segue operando só na plataforma nova, sem perda e sem regressão → a URL antiga permanece desligada. -- **Anti-goals de produto:** NÃO re-migrar tudo por desconfiança; NÃO duplicar municípios/seed nem dados públicos já sincronizados; NÃO virar "conserto" de divergências históricas; NÃO criar superfície nova de edição de dados (isso é planilha-modo). - -## Objetivo e aceite - -- Após o item, todos os dados de operação de campanha existentes na plataforma antiga existem e estão íntegros na nova — contagens conferem e nenhum registro ficou para trás. -- Nenhuma operação de campanha registrada na plataforma antiga (pledges, apoiadores, lideranças, atividades, demandas, decisões de alocação) precisa ser refeita manualmente na nova. -- Usuários da campanha continuam acessando `/campanha` na plataforma nova sem perda de acesso (senhas/sessões preservadas ou re-emitidas sem re-cadastro). -- Guardrails: dados nominais de cidadãos (PII/LGPD) só migram com o mesmo fail-closed do app (consentimento por chave estável); municípios/semeados não são duplicados; a vertical não regride (assimetria de votos, lockdown de liderança, leitura relativa/local preservadas). - -## Dados (intenção) - -- **Vou apresentar dados?** Não — migração move dados, não apresenta superfície de decisão. -- **Decisões desbloqueadas:** nada novo que dependa de um dashboard; o que importa é que decisões JÁ existentes continuem íntegras pós-migração: (a) conta da cadeira, (b) mapas e leitura territorial, (c) pledges com `declaredVotes` vs `estimatedVotes` e seus agregados devem refletir o MESMO estado que o time via na plataforma antiga. -- **Forma:** _adiada ao plano de implementação_ — aqui só a restrição de produto: o pós-migração precisa produzir a mesma leitura relativa/local que a plataforma antiga mostrava (sem "conserto" silencioso de números). - -## Direção no codebase (hipótese) - -- **Áreas prováveis:** coleções da vertical campanha (`src/collections/`) e a área de campanha (`src/app/(campaign)/…`, `src/utilities/access/*`); precedente de tooling de dados em `scripts/` (ex.: `scripts/db-pull.mjs`, `pnpm db:pull`). -- **Precedente a olhar:** OPS51 (dump Neon→homeserver, contagens por coleção), OPS58 (sincronização de posts públicos), OPS52 (media) — docs de ops em `docs/ops/` e `docs/plans/`. -- **Risco de acoplamento:** o executor deve respeitar as guardas de dev/test (nunca apontar dev/test para prod) e o fail-closed de consentimento/LGPD — esta migração é operação manual de infra em prod, fora dos guards de dev. - -## Dependências - -- **OPS80 (duro):** a desativação/congelamento da URL antiga vem ANTES — a fonte precisa estar quiescida para que nenhum dado novo entre entre a última migração e o desligamento. -- Nenhuma dependência suave. - -## Fora de escopo - -- Cutover de DNS/hospedagem de `pt.jorgesolla.com.br` (OPS80 desativa + OPS81 redireciona). -- Migração de media — já feita em OPS52 (Garage S3); referências antigas são exceção a registrar, não re-migração. -- Cancelamento da conta Neon (pós-OPS80). -- Dados públicos já sincronizados (ex.: posts — OPS58). - -## Rabbit holes de produto - -- **Merge de delta vs reconciliação limpa.** Se alguém "só completar" com o delta pós-OPS51, uma divergência silenciosa anterior continua escondida; se reconciliar tudo, vira re-migração completa. **Corte neste item:** reconciliar a vertical inteira uma última vez (contagens vs dump OPS51 como linha de base), com o delta como piso — sem re-migrar o que já está íntegro. -- **Copiar dados públicos duplicados.** Trazer posts/assinaturas/signatures de novo criaria duplicidade com o que já foi sincronizado. **Corte:** apenas a vertical campanha é o foco; o resto só se aparecer residual real e verificável. -- **"Consertar" divergências históricas de IDs.** Em vez de aceitar que a fonte de verdade é a nova, alguém pode tentar reconciliar históricos de IDs que já divergiram. **Corte:** nenhuma correção de IDs pós-fato; o aceite é integridade de dados de campanha, não identidade perfeita entre plataformas. -- **Scope creep para re-migrar tudo.** A desconfiança pós-OPS51 pode inflar o item para "re-fazer a migração inteira". **Corte:** escopo é o residual + reconciliação da vertical campanha; fora disso, OPS80 decide. - -## Questões em aberto (produto) - -- **Qual é a base real da plataforma antiga hoje (Vercel ainda aponta para o Neon)?** **Opções:** (a) Neon — assumir que toda escrita pós-OPS51 foi no Neon e migrar esse residual; (b) outra base — a plataforma antiga já foi repontada e o gap é outro; (c) plataforma antiga inativa desde o OPS51. **Recomendação:** verificação viva (read-only, escrita de teste e leitura de volta em ambas as plataformas) ANTES do cutoff de OPS80 — sem essa verificação, a migração pode estar varrendo o banco errado. A cópia em si roda depois, sobre a fonte congelada. _(assumido — validar com engenharia no momento do desligamento)_ -- **Migrar só o delta pós-OPS51 ou reconciliar a vertical inteira?** **Opções:** (a) só delta — rápido, mas deixa divergência silenciosa possível; (b) reconciliar a vertical inteira — mais demorado, mas fecha o risco de dado órfão anterior ao OPS51; (c) delta + reconciliação por contagem. **Recomendação:** (c) — reconciliar por contagem (a mesma verificação do OPS51) com o delta como objeto da cópia. É o único caminho que dá o aceite "nada órfão, nada duplicado" sem re-migrar tudo. -- **O que fazer com senhas/sessões de usuários da campanha?** **Opções:** (a) preservar hashes e sessões como estão — nenhuma interrupção no meio da janela eleitoral; (b) exigir re-login após a migração; (c) re-emitir convites. **Recomendação:** (a) — campanha em janela eleitoral ativa; forçar re-login em massa seria a pior hora para interromper operação. Se a base de origem não for confiável para credenciais, cair em (b), nunca em (c). -- **Referências a media antiga em registros migrados?** **Opções:** (a) migrar apontando para o objeto já no Garage S3; (b) manter URL antiga e registrar exceção; (c) quebrar a referência. **Recomendação:** (a) quando houver equivalente já migrado (OPS52), (b) como exceção registrada caso contrário — nunca (c), imagem quebrada é regressão visível. - -## Referências - -- GitHub Issue #— (após `pnpm agent:register`) -- OPS51 (migração Neon→homeserver, contagens por coleção) e seu rollback documentado em `docs/ops/teqo-1313-deploy.md` -- OPS52 (media → Garage S3), OPS58 (sincronização de posts públicos), OPS80 (desativa a URL antiga — pré-requisito que congela a fonte), OPS81 (redireciona a URL antiga — roda após esta migração) -- `AGENTS.md` / `AGENTS-campaign.md` — vertical campanha, LGPD (fail-closed de consentimento), guardas de dev/test diff --git a/docs/plans/ops80-cutoff-desativar-url-antiga.md b/docs/plans/ops80-cutoff-desativar-url-antiga.md deleted file mode 100644 index f4e8fd9b..00000000 --- a/docs/plans/ops80-cutoff-desativar-url-antiga.md +++ /dev/null @@ -1,71 +0,0 @@ -# Cutoff de transição (fase 1) — desativar o acesso ao site pela URL pt.jorgesolla.com.br (congelar a fonte) - -Status: rascunho -Atualizado em: 2026-08-23 -Issue: #796 -Priority: P1 -Impeccable: A — sem UI -Rascunho UI: N/A — sem UI -Appetite: ~1 dia eng; um outcome verificável (a URL antiga deixa de servir o app — fonte congelada, sem escritas novas) -Responsável: — - -## Intenção - -A transição acabou: o site novo vive em `jorgesolla1313.com.br` (homeserver + túnel Cloudflare), mas `pt.jorgesolla.com.br` continua no ar servindo o app pela plataforma antiga (Vercel) — DUAS fontes de verdade, tráfego dividido, e qualquer dado gravado na URL antiga fica órfão da nova. **Fase 1 do cutoff (este item):** o site deixa de ser servido pela URL antiga — o que **congela a fonte** (nenhuma escrita nova na plataforma antiga). O redirecionamento NÃO entra aqui: ele acontece só DEPOIS da última migração de dados (OPS79), na fase 2 (OPS81) — decisão do humano, para ninguém cair no domínio canônico antes de ele ter os dados finais. - -## Persona e fluxo - -- **Persona / contexto:** (1) time de campanha — assessores e lideranças que ainda usam `pt.jorgesolla.com.br/campanha`; (2) ops/eng — hoje obrigada a manter e vigiar duas plataformas. -- **Job principal:** congelar a plataforma antiga — parar de servir o app pela URL antiga e impedir qualquer escrita nova — abrindo caminho para a última migração (OPS79) sobre uma fonte estável. -- **Fluxo desejado:** o cutoff desliga o app na URL antiga (fonte congelada) → OPS79 captura o estado final e traz para a nova → (fase 2, OPS81) a URL antiga passa a redirecionar para a canônica. Neste item, a URL antiga fica num estado de transição (fora do ar/sem interação), não servindo o app nem ainda redirecionando. -- **Anti-goals de produto:** não deixar a URL antiga servindo o app em qualquer pedaço; não redirecionar já (redirect é OPS81, pós-migração); não interromper o acesso do time a `/campanha` **no domínio canônico** (que segue no ar normalmente). - -## Objetivo e aceite - -- `pt.jorgesolla.com.br` deixa de servir o app — um acesso pela URL antiga **não** inicia mais sessão, formulário ou qualquer escrita (fonte congelada). -- Nenhuma escrita nova é possível via URL antiga a partir do cutoff — o gap "dados novos entre a última migração e a desativação" fica estruturalmente impossível. -- A URL antiga **não** redireciona ainda para o canônico (isso é OPS81, após OPS79) — estado de transição aceito, janela curta. -- A vertical de campanha no domínio canônico (`jorgesolla1313.com.br/campanha`) continua funcionando sem regressão. - -## Dados (intenção) - -- **Vou apresentar dados?** Não. -- **Decisões desbloqueadas:** o cutoff é uma decisão de política (uma única fonte de verdade), não de dado. -- **Forma:** _adiada ao plano de implementação_ — sem restrição de produto além do acima. - -## Direção no codebase (hipótese) - -- **Áreas prováveis:** superfície que referencia `pt.jorgesolla.com.br` no repo — scripts de verificação (`scripts/check-push-chain.mjs`), testes unit/int (WebAuthn config, json mutation route, multi-phones, aiMarkdownLinks), docs históricos (ops50/51, c98-impl, revalidate-secret, push-notificacoes, c115, CUTOVER-MAIN-ONLY) e personas de agentes (`.opencode/agent/`). A desativação em si é infra de hosting (Vercel/DNS), fora do app. -- **Precedente a olhar:** histórico de OPS50 (remoção de artefatos Vercel), OPS51/OPS52 (cutover adiado, "Excluir a store do Vercel Blob — só após cutover estável"). -- **Risco de acoplamento:** a URL canônica (`NEXT_PUBLIC_SITE_URL`) já é obrigatória em prod e governa invite, calendarFeed, syncs, passwordReset e WebAuthn — o executor deve manter o app 100% canônico e não criar caminhos dependentes da URL antiga. - -## Dependências - -- **Nenhuma** — este item é o PRIMEIRO da sequência do cutoff (desativação → migração → redirect). -- OPS79 (migração) **depende** deste: a fonte precisa estar congelada antes da última migração. - -## Fora de escopo - -- Última migração de dados (OPS79) — roda depois, sobre a fonte congelada. -- Redirecionamento da URL antiga (OPS81) — só após a migração. -- Cancelamento financeiro das contas Neon/Vercel — decisão de infra pós-cutoff, fora deste item. -- Remoção de `@vercel/analytics`/`@vercel/speed-insights` — não relacionada ao domínio. -- SEO/canonical tags do site público — não é este item. - -## Rabbit holes de produto - -- **Manter a plataforma antiga viva "por precaução".** Se alguém "só completar" mantendo a Vercel no ar, seguem duas fontes de verdade e o congelamento não acontece. **Corte neste item:** a URL antiga deixa de servir o app; ponto. -- **Já redirecionar na fase 1.** Apressar o redirect deixaria o canônico (ainda sem a migração) como destino de quem vem da URL antiga — exatamente o gap de dados intermediários que o humano quer evitar. **Corte:** o redirect é OPS81, após OPS79. -- **SEO/canonical vira redesenho.** Melhorar ranking/`<link rel=canonical>` do site público é outro esforço. **Corte neste item:** foco na transição de domínio; SEO é plano próprio. - -## Questões em aberto (produto) - -- **O que a URL antiga deve responder no intervalo até o redirect (OPS81)?** **Opções:** (A) estado neutro estático (hold: "site em migração", sem formulários/escritas); (B) fora do ar (não responde / erro). **Recomendação:** (A) — informa quem caiu de paraquedas sem servir o app nem aceitar escrita; janela curta, em seguida OPS79 migra e OPS81 redireciona. _(assumido — validar com produto)_ -- **Como efetivar a desativação?** **Opções:** (A) remover o domínio/alias da plataforma antiga (Vercel) e derrubar o deployment; (B) repontar DNS para estado morto. **Recomendação:** decisão do executor; o requisito é o app não servir a URL antiga e nenhuma escrita ser possível. - -## Referências - -- Estado vivo (2026-08-23): `pt.jorgesolla.com.br` responde 200 via Vercel; `jorgesolla1313.com.br` responde 200 via Cloudflare (túnel do homeserver); sem host-redirect no app. -- Superfície que referencia a URL antiga no repo: `scripts/check-push-chain.mjs:13,47,49`; `tests/unit/campaignWebAuthnConfig.unit.spec.ts:17-52`; `tests/unit/campaignJsonMutationRoute.unit.spec.ts:27,54`; `tests/int/campaignMultiPhones.int.spec.ts:336`; `tests/unit/aiMarkdownLinks.unit.spec.ts:66`; `.opencode/agent/designer-campanha-solla.md:9,21`; `.opencode/agent/solla-comunicacao.md:19`. -- Docs históricos do cutover: `docs/plans/ops50*`, `ops51*`, `ops52-...-impl.md` (linha 104 — Vercel Blob pós-cutover), `CUTOVER-MAIN-ONLY.md`. -- `AGENTS.md` (Teqo context) — OPS50 removeu artefatos Vercel; `NEXT_PUBLIC_SITE_URL` obrigatória em prod via `getCampaignInviteBaseURL`. diff --git a/docs/plans/ops81-cutoff-redirecionar-url-antiga.md b/docs/plans/ops81-cutoff-redirecionar-url-antiga.md deleted file mode 100644 index 1533af07..00000000 --- a/docs/plans/ops81-cutoff-redirecionar-url-antiga.md +++ /dev/null @@ -1,72 +0,0 @@ -# Cutoff de transição (fase 2) — redirecionar pt.jorgesolla.com.br/campanha para jorgesolla1313.com.br/campanha - -Status: rascunho -Atualizado em: 2026-08-23 -Issue: #798 -Priority: P1 -Impeccable: A — sem UI -Rascunho UI: N/A — sem UI -Appetite: ~0,5–1 dia eng; um outcome verificável (a URL antiga termina na canônica, com path preservado) -Responsável: — - -## Intenção - -Depois da desativação (OPS80, que congelou a fonte) e da última migração de dados (OPS79), falta a **fase 2 do cutoff**: dar vida útil à URL antiga como mero redirecionador. Quem ainda acessar `pt.jorgesolla.com.br/campanha` — bookmarks, links de WhatsApp/QR, material impresso — cai direto na vertical de campanha do domínio canônico `jorgesolla1313.com.br/campanha`. O redirecionamento vem SÓ depois da migração, por decisão do humano: assim ninguém cai no canônico antes de ele ter os dados finais (nenhum "dado intermediário" fica de fora da leitura). - -## Persona e fluxo - -- **Persona / contexto:** (1) time de campanha — assessores e lideranças com bookmarks/links antigos, em campo, acessando `/campanha` no celular; (2) visitante público com link antigo (WhatsApp/QR/email antigo); (3) ops/eng — fecha o cutoff com uma única fonte de verdade. -- **Job principal:** quem ainda usar a URL antiga chega ao destino certo na canônica — especialmente `/campanha` — sem página morta e sem dados faltando. -- **Fluxo desejado:** usuário abre/digita a URL antiga → é redirecionado para a URL canônica preservando o path (`/campanha` → `/campanha`) → segue navegando em `jorgesolla1313.com.br` com os dados finais já migrados. -- **Anti-goals de produto:** não deixar a URL antiga servindo o app em qualquer pedaço; não redirecionar antes da migração (a ordem desativação→migração→redirect é decisão do humano); não transformar o cutoff num redesenho de SEO/canonical. - -## Objetivo e aceite - -- `pt.jorgesolla.com.br/campanha` redireciona para `jorgesolla1313.com.br/campanha` preservando o destino — o time segue operando com links antigos, sem perder sessão de trabalho. -- Idealmente toda rota da URL antiga redireciona para a canônica com path preservado (o resto do site também deixa de ter URL morta) — `/campanha` é o caso obrigatório. -- A URL antiga continua **não** servindo o app (nenhuma escrita possível — fonte segue congelada). -- A vertical de campanha no domínio canônico continua funcionando — WebAuthn e sessão seguem válidas no host que serve a página (rpID/origin casam com o domínio canônico), sem regressão. -- Ops/eng deixa de manter a URL antiga como plataforma viva (deixa de existir decisão de "qual URL é a canônica"). - -## Dados (intenção) - -- **Vou apresentar dados?** Não. -- **Decisões desbloqueadas:** nenhuma decisão de produto depende de métrica aqui; o cutoff é uma decisão de política (uma única fonte de verdade), não de dado. -- **Forma:** _adiada ao plano de implementação_ — sem restrição de produto além do acima. - -## Direção no codebase (hipótese) - -- **Áreas prováveis:** infra de hosting da URL antiga (o redirecionador roda fora do app — Cloudflare/DNS/host estático; o repo não tem host-redirect hoje, sem `src/middleware.ts`/`proxy.ts`; todos os `redirect()` são por path relativo). Superfície de repo a rever: `scripts/check-push-chain.mjs`, testes unit/int que pinam a URL antiga e docs/personas de agentes (`.opencode/agent/`). -- **Precedente a olhar:** histórico de OPS50 (remoção de artefatos Vercel), OPS51/OPS52 (cutover adiado). -- **Risco de acoplamento:** a URL canônica (`NEXT_PUBLIC_SITE_URL`) já é obrigatória em prod e governa invite, calendarFeed, syncs, passwordReset e WebAuthn — o executor deve manter o app 100% canônico e não criar caminhos dependentes da URL antiga. - -## Dependências - -- **OPS79 (dura)** — o redirecionamento só acontece DEPOIS da última migração: quem cair no canônico precisa já ver os dados finais. Sem isso, o redirect criaria o exato gap de dados intermediários que a ordem desativação→migração→redirect evita. -- OPS80 (desativação) é pré-requisito estrutural da sequência (já concluído quando este item roda). - -## Fora de escopo - -- Desativação da URL antiga (OPS80) — já feita, fase 1. -- Última migração de dados (OPS79) — já feita, entre as duas fases. -- Cancelamento financeiro das contas Neon/Vercel — decisão de infra pós-cutoff, fora deste item. -- Remoção de `@vercel/analytics`/`@vercel/speed-insights` — não relacionada ao domínio. -- SEO/canonical tags do site público — não é este item. - -## Rabbit holes de produto - -- **Redirecionar só `/campanha`.** Deixaria o resto da URL antiga morto sem destino — usuário com link antigo de artigo/capa cairia em erro. **Corte neste item:** path preservado para todas as rotas; `/campanha` é o caso obrigatório, o resto é grátis. -- **Redirecionar antes da migração.** Apressar o redirect devolveria o canônico sem os dados finais para quem vem da URL antiga. **Corte:** ordem travada — desativação (OPS80) → migração (OPS79) → redirect (este item). -- **SEO/canonical vira redesenho.** Melhorar ranking/`<link rel=canonical>` do site público é outro esforço. **Corte neste item:** foco na transição de domínio; SEO é plano próprio. - -## Questões em aberto (produto) - -- **Redirecionar TODAS as rotas da URL antiga ou só `/campanha`?** **Opções:** (A) todas as rotas, preservando path; (B) só `/campanha`. **Recomendação:** (A) — `/campanha` é caso obrigatório; o resto é grátis e elimina qualquer URL morta. _(assumido — validar com produto)_ -- **Onde terminar o redirecionamento?** **Opções:** (A) no nível de hosting/DNS (fora do app); (B) dentro do app. **Recomendação:** (A) — o app continua 100% canônico e o cutoff não depende de deploy; decisão de onde exatamente é do executor. _(assumido — validar com produto)_ - -## Referências - -- Estado vivo (2026-08-23): `pt.jorgesolla.com.br` responde 200 via Vercel (antes da fase 1); `jorgesolla1313.com.br` responde 200 via Cloudflare (túnel do homeserver); sem host-redirect no app. -- Superfície que referencia a URL antiga no repo: `scripts/check-push-chain.mjs:13,47,49`; `tests/unit/campaignWebAuthnConfig.unit.spec.ts:17-52`; `tests/unit/campaignJsonMutationRoute.unit.spec.ts:27,54`; `tests/int/campaignMultiPhones.int.spec.ts:336`; `tests/unit/aiMarkdownLinks.unit.spec.ts:66`; `.opencode/agent/designer-campanha-solla.md:9,21`; `.opencode/agent/solla-comunicacao.md:19`. -- Docs históricos do cutover: `docs/plans/ops50*`, `ops51*`, `ops52-...-impl.md` (linha 104 — Vercel Blob pós-cutover), `CUTOVER-MAIN-ONLY.md`. -- `AGENTS.md` (Teqo context) — OPS50 removeu artefatos Vercel; `NEXT_PUBLIC_SITE_URL` obrigatória em prod via `getCampaignInviteBaseURL`. diff --git a/docs/plans/react-19-form-reset-campanha-impl.md b/docs/plans/react-19-form-reset-campanha-impl.md new file mode 100644 index 00000000..ae674e6e --- /dev/null +++ b/docs/plans/react-19-form-reset-campanha-impl.md @@ -0,0 +1,116 @@ +# Impl: C140 — Formulários de campanha apagam o digitado em erro de validação (React 19 form reset) + +Status: rascunho +Atualizado em: 2026-08-23 +Issue: #740 +Intenção: docs/plans/react-19-form-reset-campanha.md +Appetite restante: herdado (~1 dia eng) + +## Leitura da intenção + +- **Outcome:** nenhum form com `useActionState` em `/campanha` apaga o digitado quando a action retorna erro (fieldErrors ou message); o fix segue o precedente C139 (controlado **ou** dispatch manual, decidido por form); cobertura e2e/unit do form mais representativo por família; specs existentes das famílias tocadas seguem verdes sem alteração. +- **O que NÃO negociar:** sem mudança de comportamento de sucesso (redirect, toast, refresh, `onSaved`); sem mudança de schemas/`FormData`/ladders; forms do domínio `contacts` já resolvidos no C139 ficam como estão; forms fora de `/campanha` fora de escopo. +- **O que reavaliar:** o grep acusou 22 hits mas 2 são comentários — são **20 forms** reais com `action={submitAction}` (18 a mudar + 2 micro-forms de advisor). `WizardLeadershipForm` **não** usa `WizardStepFormChrome` (é `<form>` próprio, sucesso via `savedRef`), então entra na fila de dispatch manual, não no fix do chrome. + +## Abordagem recomendada + +```mermaid +flowchart LR + A["20 forms /campanha<br/>action={submitAction}"] --> B["3 wizard steps via chrome<br/>WizardStepFormChrome modo action<br/>→ dispatch manual interno (1 mudança)"] + A --> C["15 forms<br/>dispatch manual por arquivo<br/>onSubmit + preventDefault + startTransition<br/>campos NÃO controlados mantidos"] + A --> D["2 micro-forms advisor<br/>sem campo digitável → deixa como está"] + B --> E["mesmo submitAction → mesmo ladder<br/>isPending correto via startTransition (C139)"] + C --> E + E --> F["sucesso inalterado: redirect / toast / savedRef / refresh"] + E --> G["erro: digitado permanece no DOM nativo"] + G --> H["e2e/unit por família: erro → valores mantidos<br/>+ regressão verde"] +``` + +### Decisão 1 — padrão do fix (cara de reverter) + +- **Opções:** + - **A) Dispatch manual puro** por arquivo: `onSubmit` + `event.preventDefault()` + `startTransition(() => submitAction(new FormData(event.currentTarget)))`, campos permanecem não controlados. Exatamente o que o C139 fez em `ContactCreateRow.tsx:63-70`. + - **B) Extrair hook compartilhado** `useCampaignFormAction(submitAction)` que encapsula o dispatch (e possivelmente o `useActionState`). + - **C) Campos controlados em massa**. +- **Recomendação: A** — mecânico, diff mínimo por arquivo, risco de acoplamento zero (cada form é independente), precedente já aprovado e coberto por e2e no C139. O snippet é literalmente 3 linhas constantes; padroniza-se pelo **comentário** (mesmo texto do C139), não por módulo novo. +- **Rejeitadas:** + - **B** porque o "helper" é raso (pass-through de 3 linhas sem volatilidade). Depth check (`decision-quality.md`): "Pass-through raso → não criar"; o valor duplicado é um snippet fixo, não conhecimento que muda junto. O trio `state`/`submitAction`/`isPending` é consumido de formas diferentes por site (alguns passam `submitAction` como prop pra filho; o chrome do wizard consome `action` como prop; outros nem renderizam `state`), então o hook retornaria `{ state, submitAction, isPending, onSubmit }` — reduziria 0 superfície e adicionaria indireção. O que vale padronizar é o comentário, como o C139 já fez. **Gatilho de revisitação:** se o padrão reaparecer em volume fora de `/campanha`, extrair o hook então. + - **C** porque é invasivo, alto risco de regressão (dezenas de campos `defaultValue` → `value`+`onChange`), estoura o appetite de ~1 dia. Só se aplica onde os campos **já** são controlados (PhonesFieldEditor, RelationMultiSelect, StrictCombobox, polarity hidden) — e aí nem precisa de mudança. + +### Decisão 2 — `WizardStepFormChrome` (um lugar cobre 3 forms) + +- **Opções:** + - **A) Mudar o chrome**: no modo `action`, renderizar `<form onSubmit>` em vez de `<form action>`, com o dispatch manual **interno** (`preventDefault` + `startTransition` + `props.action(new FormData(...))`). Os 3 steps (trend-note, update-body, register-demand) não mudam nada; a prop `action` continua aceita (backward-compatible). `WizardExpectedVotesStep` usa o modo `onCtaClick` (não-form) e fica intocado. + - **B) Fix por step**: cada step monta seu próprio `<form>` — duplica o chrome. + - **C) Criar modo novo** (`mode: 'manual'`) — mais superfície que o necessário. +- **Recomendação: A** — o chrome **é** o dono do elemento `<form>` no modo action; é o módulo profundo que já encapsula pending/announcement/submit-bar. Uma mudança, 3 forms, zero diff nos steps. `onSubmit` captura o submit nativo antes do plumbing do form action (mesma mecânica C139). +- **Rejeitadas:** **B** (duplica chrome e espalha a decisão em 3 arquivos) e **C** (enum novo sem ganho; um handler opcional interno basta). + +### Decisão 3 — `AdvisorPasswordResetButton` (2 micro-forms) + +- **Decisão: deixar como está** (`action={submitAction}` nas linhas 45 e 68), documentado como exceção deliberada. Justificativa: só há `<input type="hidden" name="advisorId">` + botão — **nenhum campo digitável**. O reset do React 19 só apaga valores de campos não controlados; o hidden é constante e re-renderiza idêntico, então zero perda de dado visível. Alterar agrega diff sem efeito observável e mexe em sucesso (toast de erro) sem ganho. **Escape hatch barato:** se o aceite for lido estritamente como "nenhum form", o custo é 6 linhas (dispatch manual nos 2) e pode ser incluído na Fase 3 sem reabrir decisão. + +### Decisões por família (fix aplica em todas — o reset é visível porque TODOS os forms ficam na tela em erro; o redirect/savedRef só ocorre no sucesso) + +| Família | Forms | Padrão | +| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Wizard chrome (3) | `WizardTrendNoteStep`, `WizardUpdateBodyStep`, `WizardRegisterDemandStep` | Decisão 2 — chrome muda, steps intactos | +| Wizard step próprio (1) | `WizardLeadershipForm` (`savedRef`→`onSaved`) | Decisão 1 (A) — dispatch manual; campos não controlados mantidos | +| Página sem redirect (1) | `MunicipalityAdvisorsForm` (`/municipios/[slug]/editar`, fica aberto) | Decisão 1 (A) — checkboxes `defaultChecked` permanecem | +| Página com redirect (5) | `DemandForm`, `LeadershipForm`, `OrganizationForm`, `StateDeputyForm`, `TourComposerForm` | Decisão 1 (A) — redirect só no sucesso; em erro o form fica e o reset seria visível | +| Cards inline (8) | `DemandDescriptionEditor`, `DemandResponsiblesCard`, `LeadershipInternalForm`, `MunicipalityUpdateForm`, `MunicipalityListUpdateControl`, `CampaignUpdatesCreateModal`, `DeclareVotesForm`, `PledgeEstimateForm` | Decisão 1 (A) — controles já controlados (RelationMultiSelect, StrictCombobox, polarity hidden, `stopsJson`) são imunes e não mudam; inputs numéricos de VotePledge **continuam não controlados** (sem formatação nova) | +| Já imunes (0 mudança) | `PhonesFieldEditor` FORM mode (rows em `useState`), contacts C139 | conferir por call site que está em FORM mode (`name={name}`), sem `saveAction` | + +Total: **18 forms mudam** (1 mudança de chrome + 15 dispatch manual + `WizardLeadershipForm`) e **2 ficam** (advisor). + +### Componentes / mudanças + +- **`WizardStepFormChrome`** (`src/components/campaign/shared/WizardStepFormChrome.tsx:80-83`): no branch `action`, trocar `<form action={props.action}>` por `<form onSubmit={handleSubmit}>`, com `handleSubmit` = `preventDefault` + `startTransition(() => props.action(new FormData(event.currentTarget)))`. Importa `startTransition` e `type FormEvent` de `react`. Nada muda nos 3 steps nem no modo `onCtaClick`. +- **15 forms com dispatch manual** (3 linhas cada, mesmo comentário do C139): `DemandForm`, `LeadershipForm`, `OrganizationForm`, `StateDeputyForm`, `TourComposerForm`, `MunicipalityAdvisorsForm`, `WizardLeadershipForm`, `DemandDescriptionEditor`, `DemandResponsiblesCard`, `LeadershipInternalForm`, `MunicipalityUpdateForm`, `MunicipalityListUpdateControl`, `CampaignUpdatesCreateModal`, `DeclareVotesForm`, `PledgeEstimateForm`. Cada um: adicionar `startTransition` (e `type FormEvent` quando não houver) ao import de `react`, trocar `action={submitAction}` por `onSubmit={handleSubmit}`, definir o handler. Sucesso (toast/redirect/`savedRef`/`formRef.reset()`/remount por `formKey`) fica intocado. +- **Helpers reusados (nenhum novo):** `campaignFormActionError.ts` (ladders `runCampaignFormAction`/`runCampaignRedirectFormAction` intactos), `campaignFormFields.ts` (`fieldError`/`firstFormActionMessage`/`errorProps` intactos), `useCampaignFormSuccessToast` intacto. +- **Migration:** sem migration — nenhuma mudança de schema. +- **Access / Consent:** nenhum — forms e ladders existentes; RBAC intacto. +- **UI:** Impeccable B já coberto na intenção (perda de dado em erro); nenhuma mudança de layout/craft. + +### Dados → forma (se aplicável) + +Não se aplica — nenhuma mudança de dados. O contrato `FormData` e os ladders são idênticos; o reset é fenômeno de UI. A "forma" dos campos é decisão de engenharia por form: **não controlado + dispatch manual** (padrão dominante, o DOM nativo preserva o digitado) ou **controlado onde já é** (PhonesFieldEditor e comboboxes, imunes). Nenhum `defaultValue` é convertido em `value` a menos que o form já o faça. + +## Fases verificáveis + +1. **Tracer + chrome do wizard** — mudar `WizardStepFormChrome` (cobre 3 steps de uma vez); fix 1 card inline (`DemandDescriptionEditor`) e 1 página (`DemandForm`) como prova do padrão. **Verificação:** unit spec novo `campaignWizardStepFormChrome.unit.spec.tsx` (render modo action, stub action que resolve erro → type no textarea → submit → assert valor persiste + `aria-busy` durante pending); `pnpm gate:fast` (unit) verde; wizard e2e existente (`campaignHomeActions.e2e.spec.ts:337-361` e `:435`) verde. +2. **Cards inline restantes (mecânico)** — `DemandResponsiblesCard`, `LeadershipInternalForm`, `MunicipalityUpdateForm`, `MunicipalityListUpdateControl`, `CampaignUpdatesCreateModal`, `DeclareVotesForm`, `PledgeEstimateForm`. **Verificação:** estender asserções "erro → valores mantidos" nos cenários já existentes de `campaignMunicipalities.e2e.spec.ts` (`:115-141` assessores, `:591-633` list update control, `:638-709` declare+estimate) e `campaignDemandVisibility.e2e.spec.ts:62-95` (responsáveis); suíte e2e afetada verde. +3. **Páginas restantes + micro-forms (mecânico)** — `LeadershipForm`, `OrganizationForm`, `StateDeputyForm`, `TourComposerForm`, `MunicipalityAdvisorsForm`, `WizardLeadershipForm`. Advisor fica (Decisão 3). **Verificação:** regressão de `campaignLeaderships.e2e.spec.ts`; **e2e novo `campaignTours.e2e.spec.ts`** cobrindo `TourComposerForm` (o único form grande com redirect sem cobertura hoje — gap): erro de validação (ex. nome do giro) → valores digitados mantidos → corrige → sucesso redireciona; unit opcional `campaignDemandDescriptionEditor.unit.spec.tsx` (form puro inline com stub action) se o appetite permitir. +4. **Cobertura por família + regressão** — auditar que cada família tem ≥1 spec: contacts (C139, só regressão), wizard (unit chrome + `campaignHomeActions`), página demand (`campaignRegisterDemand.e2e.spec.ts:12` + estender com asserção de erro), municípios (extensões da Fase 2), votes (declare/estimate), giros (e2e novo), org/deputy (padrão idêntico ao DemandForm, coberto por regressão existente). Rodar a suíte e2e selecionada/afetada. +5. **Gates** — `pnpm gate:fast` (guards → lint → format → typecheck → knip → cycles → unit → int → build → e2e selecionado) e `pnpm push`. + +## Rabbit holes / Não escopo (engenharia) + +- Extrair hook/helper compartilhado — rejeitado na Decisão 1 (depth check: pass-through raso); o comentário padronizado C139 é o suficiente. +- Controlar campos em massa — rejeitado na Decisão 1. +- Formatação/validação nova de inputs numéricos (VotePledge) — ficam não controlados; a ação já valida e ecoa fieldErrors. +- Forms fora de `/campanha` (frontend público) — fora do escopo da intenção. +- Refactor dos ladders `campaignFormActionError`/`campaignFormFields` ou do echo de `values` — intactos. +- Comportamento de sucesso (toasts, redirects, `savedRef`, `formRef.reset()`, remount por `formKey`) — nenhum toque; o dispatch manual não impede reset manual. +- `WizardExpectedVotesStep` — já usa `useTransition` + fetch (não-form), fora do problema. +- "Consertar" controles já controlados (PhonesFieldEditor, RelationMultiSelect, StrictCombobox) — são imunes; mexer é desperdício. +- `AdvisorPasswordResetButton` — deixa como está (Decisão 3), com escape hatch de 6 linhas. + +## Riscos e mitigação + +- **Chrome compartilhado afeta 3 steps de uma vez** — mitigado: mudança backward-compatible (prop `action` mantida, só o mecanismo de submit muda), unit spec do chrome na Fase 1, e2e de wizard existente como regressão. +- **`isPending` incorreto sem `startTransition`** — o dispatch manual SÓ funciona com `startTransition` (provado C139); garantir o import em cada arquivo. Erro de compilação/typecheck pega omissão. +- **Regressão de sucesso** — mesmas actions/ladders/`FormData`; as specs de sucesso existentes das famílias tocadas precisam ficar verdes **sem alteração** (aceite). Rodar e2e afetado por família antes do push. +- **Enter/submit nativo** — `onSubmit` captura o submit antes do plumbing; `preventDefault` é obrigatório em todo handler novo. +- **Dupla submissão** — botões mantêm `disabled={isPending}`; nenhum handler novo adiciona guard próprio (o `isPending` já cobre). +- **Inputs numéricos (VotePledge)** — com `defaultValue`, o DOM nativo preserva o digitado após o dispatch manual; sem mudança de `inputMode`/máscara. +- **Forms que ecoam `values` para repopular** — nenhum form alvo controla campos pelo echo; com não-controlado + dispatch manual o repopulo é nativo (melhor que o echo). Não alterar ladders. +- **Reset intencional no sucesso** — `ContactCreateRow`-style `formRef.reset()` e remount por `formKey` continuam válidos com `onSubmit` manual; nenhum handler novo remove reset. + +## Aceite de engenharia + +- [ ] Aceite de produto da intenção ainda coberto — 18 forms em `/campanha` com `useActionState` não apagam o digitado em erro; cobertura por família (unit chrome + e2e estendidos + e2e novo de giros); regressão das famílias tocadas verde sem alteração; advisor documentado como exceção sem campo digitável. +- [ ] Invariantes AGENTS/engineering-standards — padrão C139 seguido (dispatch manual + comentário; controlado só onde já é); sem migration, schema, access/consent novos; identificadores em inglês; pt só em strings de UI; ladders `campaignFormActionError`/`campaignFormFields` intactos. +- [ ] Testes de domínio previstos — unit spec do `WizardStepFormChrome`; asserções "erro → valores mantidos" nos e2e representativos por família (municípios, demand, wizard, votes); e2e novo `campaignTours.e2e.spec.ts`; regressão e2e completa das famílias tocadas. + +Self-score decision-quality: **5/5** — decisão central (A/B/C) com rejeitadas fundamentadas no depth check; decisões por família e do chrome deliberadas; cabe no appetite (~1 dia, mecânico); rabbit holes nomeados; reusa helpers e o precedente C139 sem abstração nova; intenção preservada (nenhuma mudança de sucesso/schema). diff --git a/docs/plans/worktree-launch-default-llm-max-gateway.md b/docs/plans/worktree-launch-default-llm-max-gateway.md index b4c63c32..11a50c83 100644 --- a/docs/plans/worktree-launch-default-llm-max-gateway.md +++ b/docs/plans/worktree-launch-default-llm-max-gateway.md @@ -1,7 +1,7 @@ # Default de launch do worktree opencode passa a usar o DeepSeek na variante max servido pelo gateway da Vercel Status: rascunho -Atualizado em: 2026-08-23 (fix de variantes) +Atualizado em: 2026-08-23 Issue: #784 Priority: P2 Impeccable: A — N/A sem UI @@ -32,9 +32,8 @@ Dados: N/A — item de tooling/launch, sem superfície de dados. ## Direção no codebase (hipótese) - **Áreas prováveis:** `scripts/lib/worktree.mjs:23` (constante do preset, emitida em `:143`); testes `tests/unit/worktree.unit.spec.ts:151-197` e `tests/unit/opencodeCommands.unit.spec.ts:37`; repo `opencode.json` (adicionar provider OpenAI-compatible do gateway e expor a variante max); textos de doc `.agents/shell/worktree.sh:10`, `scripts/worktree.mjs:27,778`, `.agents/skills/worktree-next-issue/SKILL.md:34`. -- **Fix de variantes (observação ao vivo):** o provider `vercel` (Vercel AI Gateway) é **built-in** do opencode (documentado em `opencode.ai/docs/providers/#vercel-ai-gateway`), mas o catálogo built-in **não define `variants`** para `deepseek-v4-flash` — por isso as variantes `low`/`high`/`max` não aparecem no TUI. O SDK do opencode permite `variants` no model config (tipo `types.gen.d.ts:1453`), mas o built-in não os expõe. **Fix:** sobrescrever o model config no repo `opencode.json` adicionando `provider.vercel.models["deepseek/deepseek-v4-flash"].variants` com as definições (low/high/max com `reasoningEffort`). O built-in já lista `vercel/deepseek/deepseek-v4-flash` como modelo disponível; a sobrescreve só adiciona as variantes sem quebrar o routing existente. - **Precedente a olhar:** OPS26 (criou o preset e o mecanismo "trocar modelo = editar a constante"), OPS31/OPS25 (extensões do launch). -- **Risco de acoplamento:** baixo — a constante é single source e os testes a seguram; qualquer mudança de string quebra testes, o que é a verificação do item. Fix de variantes é config-only em `opencode.json`. +- **Risco de acoplamento:** baixo — a constante é single source e os testes a seguram; qualquer mudança de string quebra testes, o que é a verificação do item. ## Dependências @@ -51,12 +50,11 @@ Dados: N/A — item de tooling/launch, sem superfície de dados. - **"Só completar a string nos comandos".** Se alguém apenas trocar o modelo nos frontmatters dos comandos, muda a execução de Issues (fora de escopo) e ignora o provider do gateway. **Corte neste item:** a mudança é no default de LAUNCH do worktree (preset + provider no repo), não no cardápio de execução. - **"Configurar o gateway no global".** Se alguém criar o provider no opencode.jsonc global, vira config de máquina do humano e contamina todas as sessões. **Corte neste item:** provider no repo `opencode.json`, apenas para expor o caminho que o preset usa. -- **"Adicionar o provider sem variantes".** Se alguém adicionar o provider Vercel AI Gateway no `opencode.json` mas esquecer o campo `variants` no model config, as variantes não aparecem no TUI e a max não pode ser selecionada. **Corte neste item:** ao sobrescrever o model config, o campo `variants` DEVE incluir as definições low/high/max (cada uma com `reasoningEffort`). O fix é config-only em `opencode.json` — sem migration, sem code. ## Questões em aberto (produto) - **Os comandos de execução de Issues acompanham o novo default ou ficam como estão?** **Opções:** A) ficam como estão (execução = escolha estável, só o launch muda) | B) alinhar também. **Recomendação:** A — o item é o default de LAUNCH; o cardápio de execução é outro mecanismo e está fora de escopo. -- **Onde expor a variante max: no provider built-in ou como override no repo?** **Opções:** A) sobrescrever o model config do provider `vercel` no repo `opencode.json` (adicionar `variants`) | B) flag nova (`--variant max`) na emissão do launch. **Recomendação:** A — sobrescrever o model config do provider built-in, mantendo a emissão atual de `--model` intacta e o teste unitário como guarda. O provider `vercel` é built-in; a sobrescreve só adiciona as variantes sem quebrar o routing. +- **Onde expor a variante max: no provider do repo ou como flag no preset?** **Opções:** A) default no provider | B) flag nova (`--variant max`) na emissão do launch. **Recomendação:** A — default explícito no provider, mantendo a emissão atual de `--model` intacta e o teste unitário como guarda. ## Referências diff --git a/scripts/agent-pool.mjs b/scripts/agent-pool.mjs index f1240e5f..82db00d5 100644 --- a/scripts/agent-pool.mjs +++ b/scripts/agent-pool.mjs @@ -4,9 +4,9 @@ * * The pool keeps up to POOL_MAX_SLOTS (default 5) Cursor Cloud Agents running * agent-work-issue over `ready` autonomous-eligible issues. The supervisor is - * REMOTE: a stateless GitHub Actions tick (`.forgejo/workflows/agent-pool.yml`, - * removed OPS65 — pool dormant) runs `workflow --action …` / `tick --live`; humans - * drive it via `gh workflow run` (canonical channel). Local usage is inspection + + * REMOTE: a stateless GitHub Actions tick (`.github/workflows/agent-pool.yml`) + * runs `workflow --action …` / `tick --live`; humans drive it via + * `gh workflow run` (canonical channel). Local usage is inspection + * dispatch: * * pnpm agent:pool -- status # config, slots, fila elegível diff --git a/scripts/lib/agent-pool-state.mjs b/scripts/lib/agent-pool-state.mjs index abd1f3c5..c0b07113 100644 --- a/scripts/lib/agent-pool-state.mjs +++ b/scripts/lib/agent-pool-state.mjs @@ -128,7 +128,7 @@ export const POOL_SPAWN_GRACE_MS = 15 * 60 * 1000 * by the pool (no claim marker) — those belong to humans/manual agents and the * pool never touches them. * - * Occupied means "from the claim to the merge in main (or a documented + * Occupied means "from the claim to the merge in stage (or a documented * terminal failure)" — a terminal run with an open PR is still occupied * (auto-merge finishes the job hands-off), per the plan's slot definition. * diff --git a/scripts/lib/github-api.mjs b/scripts/lib/github-api.mjs index 7be10d1e..2ae14a50 100644 --- a/scripts/lib/github-api.mjs +++ b/scripts/lib/github-api.mjs @@ -88,7 +88,7 @@ export const createApi = ({ } } - const request = async (path, { method = 'GET', body, query, ignoreNotFound = false } = {}) => { + const request = async (path, { method = 'GET', body, query } = {}) => { const qs = query ? '?' + Object.entries(query) @@ -131,7 +131,6 @@ export const createApi = ({ } if (response.status === 404 && method === 'GET') return null if (!response.ok) { - if (ignoreNotFound && response.status === 404) return null throw new Error(`GitHub API ${method} ${path} → ${response.status}: ${text.slice(0, 400)}`) } if (!text) return null @@ -238,19 +237,13 @@ export const createApi = ({ body: { labels }, }), - /** - * Removes labels by NAME (GitHub DELETE is by label name). Idempotent: - * removing an already-absent label (404) is the desired end state — a - * re-run of the flip/recovery path must not fail (live finding OPS46-S1, - * dispatch re-run after a manual flip 404'd on `in-progress`). - */ + /** Removes labels by NAME (GitHub DELETE is by label name). */ removeLabels: async (number, labels) => { for (const label of labels) { await request( `/repos/${owner}/${name}/issues/${number}/labels/${encodeURIComponent(label)}`, { method: 'DELETE', - ignoreNotFound: true, }, ) } diff --git a/src/components/campaign/demand/DemandDescriptionEditor.tsx b/src/components/campaign/demand/DemandDescriptionEditor.tsx index adffdb3e..dbdebbd0 100644 --- a/src/components/campaign/demand/DemandDescriptionEditor.tsx +++ b/src/components/campaign/demand/DemandDescriptionEditor.tsx @@ -1,7 +1,7 @@ 'use client' import { useRouter } from 'next/navigation' -import { useActionState, useState } from 'react' +import { startTransition, useActionState, useState, type FormEvent } from 'react' import { CampaignFormActionMessage } from '@/components/campaign/shared/CampaignFormActionMessage' import { useCampaignFormSuccessToast } from '@/components/campaign/shared/useCampaignFormSuccessToast' @@ -61,10 +61,18 @@ export const DemandDescriptionEditor = ({ ) } + // C140 — manual dispatch (no `action={submitAction}`): React 19 resets + // uncontrolled fields after any settled form action, wiping typed values + // on a validation error — the editor stays open, so the wipe showed. + const handleSubmit = (event: FormEvent<HTMLFormElement>) => { + event.preventDefault() + startTransition(() => submitAction(new FormData(event.currentTarget))) + } + return ( <section aria-label="Editar descrição da demanda" className="rounded-xl border p-4"> <form - action={submitAction} + onSubmit={handleSubmit} className="flex flex-col gap-4" aria-busy={isPending || undefined} data-pending={isPending ? '' : undefined} diff --git a/src/components/campaign/demand/DemandForm.tsx b/src/components/campaign/demand/DemandForm.tsx index bd7d10ba..1693b00d 100644 --- a/src/components/campaign/demand/DemandForm.tsx +++ b/src/components/campaign/demand/DemandForm.tsx @@ -1,6 +1,6 @@ 'use client' -import { useActionState, useCallback, useState } from 'react' +import { startTransition, useActionState, useCallback, useState, type FormEvent } from 'react' import { searchDemandResponsibleOptions } from '@/app/(campaign)/campanha/(app)/demandas/responsibleSearchActions' import { DemandFields, type DemandActivityValue } from '@/components/campaign/demand/DemandFields' @@ -53,8 +53,16 @@ export const DemandForm = ({ [parsedMunicipalityId], ) + // C140 — manual dispatch (no `action={submitAction}`): React 19 resets + // uncontrolled fields after any settled form action, wiping typed values + // on a validation error — the page stays visible, so the wipe showed. + const handleSubmit = (event: FormEvent<HTMLFormElement>) => { + event.preventDefault() + startTransition(() => submitAction(new FormData(event.currentTarget))) + } + return ( - <form action={submitAction} className="flex max-w-3xl flex-col gap-4"> + <form onSubmit={handleSubmit} className="flex max-w-3xl flex-col gap-4"> <DemandFields idPrefix="demand" state={state} diff --git a/src/components/campaign/demand/DemandResponsiblesCard.tsx b/src/components/campaign/demand/DemandResponsiblesCard.tsx index 1ee05dbd..48b5d96e 100644 --- a/src/components/campaign/demand/DemandResponsiblesCard.tsx +++ b/src/components/campaign/demand/DemandResponsiblesCard.tsx @@ -1,6 +1,6 @@ 'use client' -import { useActionState, useEffect, useState } from 'react' +import { startTransition, useActionState, useEffect, useState, type FormEvent } from 'react' import { searchDemandResponsibleOptions } from '@/app/(campaign)/campanha/(app)/demandas/responsibleSearchActions' import { CampaignFormActionMessage } from '@/components/campaign/shared/CampaignFormActionMessage' @@ -74,8 +74,16 @@ export const DemandResponsiblesCard = ({ ) } + // C140 — manual dispatch (no `action={submitAction}`): React 19 resets + // uncontrolled fields after any settled form action, wiping typed values + // on a validation error — the card stays open, so the wipe showed. + const handleSubmit = (event: FormEvent<HTMLFormElement>) => { + event.preventDefault() + startTransition(() => submitAction(new FormData(event.currentTarget))) + } + return ( - <form action={submitAction} className="flex flex-col gap-3 rounded-xl border p-4"> + <form onSubmit={handleSubmit} className="flex flex-col gap-3 rounded-xl border p-4"> <div className="flex flex-col gap-1"> <h2 className="text-base font-medium">{CAMPAIGN_DEMAND_RESPONSIBLES_LABEL}</h2> <p className="text-sm text-muted-foreground">Só quem é responsável vê esta demanda.</p> diff --git a/src/components/campaign/leadership/LeadershipForm.tsx b/src/components/campaign/leadership/LeadershipForm.tsx index f1952658..9d2adf9f 100644 --- a/src/components/campaign/leadership/LeadershipForm.tsx +++ b/src/components/campaign/leadership/LeadershipForm.tsx @@ -1,6 +1,6 @@ 'use client' -import { useActionState } from 'react' +import { startTransition, useActionState, type FormEvent } from 'react' import { CampaignFormActionMessage } from '@/components/campaign/shared/CampaignFormActionMessage' import { PhonesFieldEditor } from '@/components/campaign/shared/PhonesFieldEditor' @@ -40,8 +40,16 @@ export const LeadershipForm = ({ }: LeadershipFormProps) => { const [state, submitAction, isPending] = useActionState(formAction, {}) + // C140 — manual dispatch (no `action={submitAction}`): React 19 resets + // uncontrolled fields after any settled form action, wiping typed values + // on a validation error — the page stays visible, so the wipe showed. + const handleSubmit = (event: FormEvent<HTMLFormElement>) => { + event.preventDefault() + startTransition(() => submitAction(new FormData(event.currentTarget))) + } + return ( - <form action={submitAction} className="flex max-w-2xl flex-col gap-4"> + <form onSubmit={handleSubmit} className="flex max-w-2xl flex-col gap-4"> <Field> <FieldLabel htmlFor="leadership-name">Nome completo</FieldLabel> <Input diff --git a/src/components/campaign/leadership/LeadershipInternalForm.tsx b/src/components/campaign/leadership/LeadershipInternalForm.tsx index ef2db427..b25d1eaf 100644 --- a/src/components/campaign/leadership/LeadershipInternalForm.tsx +++ b/src/components/campaign/leadership/LeadershipInternalForm.tsx @@ -1,6 +1,6 @@ 'use client' -import { useActionState } from 'react' +import { startTransition, useActionState, type FormEvent } from 'react' import { CampaignFormActionMessage } from '@/components/campaign/shared/CampaignFormActionMessage' import { @@ -40,8 +40,16 @@ export const LeadershipInternalForm = ({ }: LeadershipInternalFormProps) => { const [state, submitAction, isPending] = useActionState(formAction, {}) + // C140 — manual dispatch (no `action={submitAction}`): React 19 resets + // uncontrolled fields after any settled form action, wiping typed values + // on a validation error — the card stays open, so the wipe showed. + const handleSubmit = (event: FormEvent<HTMLFormElement>) => { + event.preventDefault() + startTransition(() => submitAction(new FormData(event.currentTarget))) + } + return ( - <form action={submitAction} className="flex max-w-2xl flex-col gap-4"> + <form onSubmit={handleSubmit} className="flex max-w-2xl flex-col gap-4"> <input type="hidden" name="leadershipId" value={leadership.id} /> <RelationMultiSelect diff --git a/src/components/campaign/leadership/WizardLeadershipForm.tsx b/src/components/campaign/leadership/WizardLeadershipForm.tsx index 02288c42..29a1d6af 100644 --- a/src/components/campaign/leadership/WizardLeadershipForm.tsx +++ b/src/components/campaign/leadership/WizardLeadershipForm.tsx @@ -1,6 +1,6 @@ 'use client' -import { useActionState, useEffect, useRef } from 'react' +import { startTransition, useActionState, useEffect, useRef, type FormEvent } from 'react' import { createLeadershipWizardFormAction, @@ -55,8 +55,16 @@ export const WizardLeadershipForm = ({ } }, [onSaved, state.status]) + // C140 — manual dispatch (no `action={submitAction}`): React 19 resets + // uncontrolled fields after any settled form action, wiping typed values + // on a validation error — the wizard step stays visible, so the wipe showed. + const handleSubmit = (event: FormEvent<HTMLFormElement>) => { + event.preventDefault() + startTransition(() => submitAction(new FormData(event.currentTarget))) + } + return ( - <form action={submitAction} className="flex flex-col gap-4" aria-busy={isPending}> + <form onSubmit={handleSubmit} className="flex flex-col gap-4" aria-busy={isPending}> <input type="hidden" name="municipalityId" value={municipalityId} /> <input type="hidden" name="municipalitySlug" value={municipalitySlug} /> {!isCreate ? <input type="hidden" name="leadershipId" value={leadership.id} /> : null} diff --git a/src/components/campaign/municipality/CampaignUpdatesCreateModal.tsx b/src/components/campaign/municipality/CampaignUpdatesCreateModal.tsx index a5bbe7bf..631454a3 100644 --- a/src/components/campaign/municipality/CampaignUpdatesCreateModal.tsx +++ b/src/components/campaign/municipality/CampaignUpdatesCreateModal.tsx @@ -1,6 +1,13 @@ 'use client' -import { useActionState, useEffect, useMemo, useState } from 'react' +import { + startTransition, + useActionState, + useEffect, + useMemo, + useState, + type FormEvent, +} from 'react' import { createCampaignUpdatesFormAction } from '@/app/(campaign)/campanha/(app)/atualizacoes/createFormActions' import { MunicipalityUpdateFields } from '@/components/campaign/municipality/MunicipalityUpdateFields' @@ -71,6 +78,14 @@ export const CampaignUpdatesCreateModal = ({ const municipalityError = fieldError(state.fieldErrors, 'municipalityId') + // C140 — manual dispatch (no `action={submitAction}`): React 19 resets + // uncontrolled fields after any settled form action, wiping typed values + // on a validation error — the modal stays open, so the wipe showed. + const handleSubmit = (event: FormEvent<HTMLFormElement>) => { + event.preventDefault() + startTransition(() => submitAction(new FormData(event.currentTarget))) + } + // Mobile sheet: list-style form — no visible labels/borders, full-bleed // divider rows, descriptive placeholders (C107). The label stays for the // accessible name; the sheet auto-sizes to the content with its ceiling at @@ -78,7 +93,7 @@ export const CampaignUpdatesCreateModal = ({ // content outgrows the viewport (e.g. open keyboard). Desktop keeps the // labeled dialog chrome. const createForm = ( - <form action={submitAction} className={cn('flex flex-col', !isMobile && 'gap-4')}> + <form onSubmit={handleSubmit} className={cn('flex flex-col', !isMobile && 'gap-4')}> <input type="hidden" name="municipalityId" value={municipalityId} /> {isMobile ? ( <> diff --git a/src/components/campaign/municipality/MunicipalityAdvisorsForm.tsx b/src/components/campaign/municipality/MunicipalityAdvisorsForm.tsx index ff244a08..1acd1799 100644 --- a/src/components/campaign/municipality/MunicipalityAdvisorsForm.tsx +++ b/src/components/campaign/municipality/MunicipalityAdvisorsForm.tsx @@ -1,6 +1,6 @@ 'use client' -import { useActionState } from 'react' +import { startTransition, useActionState, type FormEvent } from 'react' import { CampaignFormActionMessage } from '@/components/campaign/shared/CampaignFormActionMessage' import { Button } from '@/components/ui/button' @@ -32,8 +32,16 @@ export const MunicipalityAdvisorsForm = ({ const [state, submitAction, isPending] = useActionState(formAction, {}) const currentSet = new Set(currentAdvisorIDs) + // C140 — manual dispatch (no `action={submitAction}`): React 19 resets + // uncontrolled fields after any settled form action, wiping typed values + // on a validation error — the page stays visible, so the wipe showed. + const handleSubmit = (event: FormEvent<HTMLFormElement>) => { + event.preventDefault() + startTransition(() => submitAction(new FormData(event.currentTarget))) + } + return ( - <form action={submitAction} className="flex flex-col gap-4 rounded-xl border p-4"> + <form onSubmit={handleSubmit} className="flex flex-col gap-4 rounded-xl border p-4"> <div className="flex flex-col gap-1"> <h2 className="text-base font-medium">Assessores do município</h2> <p className="text-sm text-muted-foreground"> diff --git a/src/components/campaign/municipality/MunicipalityListUpdateControl.tsx b/src/components/campaign/municipality/MunicipalityListUpdateControl.tsx index da183fca..d7f7920b 100644 --- a/src/components/campaign/municipality/MunicipalityListUpdateControl.tsx +++ b/src/components/campaign/municipality/MunicipalityListUpdateControl.tsx @@ -1,6 +1,13 @@ 'use client' -import { useActionState, useId, useState, type ReactNode } from 'react' +import { + startTransition, + useActionState, + useId, + useState, + type FormEvent, + type ReactNode, +} from 'react' import { MunicipalityUpdateFields } from '@/components/campaign/municipality/MunicipalityUpdateFields' import { @@ -75,6 +82,14 @@ export const MunicipalityListUpdateControl = ({ return children } + // C140 — manual dispatch (no `action={submitAction}`): React 19 resets + // uncontrolled fields after any settled form action, wiping typed values + // on a validation error — the overlay stays open, so the wipe showed. + const handleSubmit = (event: FormEvent<HTMLFormElement>) => { + event.preventDefault() + startTransition(() => submitAction(new FormData(event.currentTarget))) + } + return ( <CampaignCellEditOverlay variant={variant} @@ -101,7 +116,7 @@ export const MunicipalityListUpdateControl = ({ <form key={formKey} id={formId} - action={submitAction} + onSubmit={handleSubmit} className={cn('flex flex-col', !isSheet && 'gap-3')} aria-busy={isPending || undefined} > diff --git a/src/components/campaign/municipality/MunicipalityUpdateForm.tsx b/src/components/campaign/municipality/MunicipalityUpdateForm.tsx index 566daa7f..0804c1de 100644 --- a/src/components/campaign/municipality/MunicipalityUpdateForm.tsx +++ b/src/components/campaign/municipality/MunicipalityUpdateForm.tsx @@ -1,6 +1,6 @@ 'use client' -import { useActionState, useState } from 'react' +import { startTransition, useActionState, useState, type FormEvent } from 'react' import { CampaignFormActionMessage } from '@/components/campaign/shared/CampaignFormActionMessage' import { Button } from '@/components/ui/button' @@ -30,8 +30,16 @@ export const MunicipalityUpdateForm = ({ const [state, submitAction, isPending] = useActionState(formAction, {}) const [polarity, setPolarity] = useState<MunicipalityUpdatePolarity>('neutra') + // C140 — manual dispatch (no `action={submitAction}`): React 19 resets + // uncontrolled fields after any settled form action, wiping typed values + // on a validation error — the card stays open, so the wipe showed. + const handleSubmit = (event: FormEvent<HTMLFormElement>) => { + event.preventDefault() + startTransition(() => submitAction(new FormData(event.currentTarget))) + } + return ( - <form action={submitAction} className="flex flex-col gap-4 rounded-xl border p-4"> + <form onSubmit={handleSubmit} className="flex flex-col gap-4 rounded-xl border p-4"> <h3 className="text-base font-medium">Registrar atualização</h3> <input type="hidden" name="municipalityId" value={municipalityID} /> diff --git a/src/components/campaign/organization/OrganizationForm.tsx b/src/components/campaign/organization/OrganizationForm.tsx index 495b801d..293aaa0f 100644 --- a/src/components/campaign/organization/OrganizationForm.tsx +++ b/src/components/campaign/organization/OrganizationForm.tsx @@ -1,6 +1,6 @@ 'use client' -import { useActionState } from 'react' +import { startTransition, useActionState, type FormEvent } from 'react' import { CampaignFormActionMessage } from '@/components/campaign/shared/CampaignFormActionMessage' import { @@ -41,8 +41,16 @@ export const OrganizationForm = ({ const [state, submitAction, isPending] = useActionState(formAction, {}) const isEdit = Boolean(initial) + // C140 — manual dispatch (no `action={submitAction}`): React 19 resets + // uncontrolled fields after any settled form action, wiping typed values + // on a validation error — the page stays visible, so the wipe showed. + const handleSubmit = (event: FormEvent<HTMLFormElement>) => { + event.preventDefault() + startTransition(() => submitAction(new FormData(event.currentTarget))) + } + return ( - <form action={submitAction} className="flex max-w-2xl flex-col gap-4"> + <form onSubmit={handleSubmit} className="flex max-w-2xl flex-col gap-4"> {initial ? <input type="hidden" name="organizationId" value={initial.id} /> : null} {isEdit ? ( <p className="text-sm text-muted-foreground"> diff --git a/src/components/campaign/shared/WizardStepFormChrome.tsx b/src/components/campaign/shared/WizardStepFormChrome.tsx index 784b8f84..04892aab 100644 --- a/src/components/campaign/shared/WizardStepFormChrome.tsx +++ b/src/components/campaign/shared/WizardStepFormChrome.tsx @@ -1,6 +1,6 @@ 'use client' -import type { ReactNode } from 'react' +import { startTransition, type FormEvent, type ReactNode } from 'react' import { Button } from '@/components/ui/button' import { Spinner } from '@/components/ui/Spinner' @@ -77,8 +77,20 @@ export const WizardStepFormChrome = ( </> ) + // C140 — manual dispatch (no `action={props.action}`): React 19 resets + // uncontrolled fields after any settled form action, wiping typed values + // on a validation error — the step stays visible, so the wipe showed. + // `startTransition` keeps `isPending` correct for the imperative dispatch. + const handleSubmit = + 'action' in props + ? (event: FormEvent<HTMLFormElement>) => { + event.preventDefault() + startTransition(() => props.action(new FormData(event.currentTarget))) + } + : undefined + return 'action' in props ? ( - <form action={props.action} className="flex flex-col gap-6" {...pendingAttrs}> + <form onSubmit={handleSubmit} className="flex flex-col gap-6" {...pendingAttrs}> {content} </form> ) : ( diff --git a/src/components/campaign/stateDeputy/StateDeputyForm.tsx b/src/components/campaign/stateDeputy/StateDeputyForm.tsx index b5457e5d..9b55c1cf 100644 --- a/src/components/campaign/stateDeputy/StateDeputyForm.tsx +++ b/src/components/campaign/stateDeputy/StateDeputyForm.tsx @@ -1,6 +1,6 @@ 'use client' -import { useActionState } from 'react' +import { startTransition, useActionState, type FormEvent } from 'react' import { CampaignFormActionMessage } from '@/components/campaign/shared/CampaignFormActionMessage' import { Button } from '@/components/ui/button' @@ -29,8 +29,16 @@ export const StateDeputyForm = ({ formAction, initial, initialName }: StateDeput const [state, submitAction, isPending] = useActionState(formAction, {}) const isEdit = Boolean(initial) + // C140 — manual dispatch (no `action={submitAction}`): React 19 resets + // uncontrolled fields after any settled form action, wiping typed values + // on a validation error — the page stays visible, so the wipe showed. + const handleSubmit = (event: FormEvent<HTMLFormElement>) => { + event.preventDefault() + startTransition(() => submitAction(new FormData(event.currentTarget))) + } + return ( - <form action={submitAction} className="flex max-w-2xl flex-col gap-4"> + <form onSubmit={handleSubmit} className="flex max-w-2xl flex-col gap-4"> {initial ? <input type="hidden" name="stateDeputyId" value={initial.id} /> : null} {!isEdit ? ( <Field> diff --git a/src/components/campaign/tour/TourComposerForm.tsx b/src/components/campaign/tour/TourComposerForm.tsx index fca9213e..7069dc80 100644 --- a/src/components/campaign/tour/TourComposerForm.tsx +++ b/src/components/campaign/tour/TourComposerForm.tsx @@ -2,7 +2,7 @@ import { ExternalLinkIcon } from 'lucide-react' import Link from 'next/link' -import { useActionState, useMemo, useState } from 'react' +import { startTransition, useActionState, useMemo, useState, type FormEvent } from 'react' import { Alert, AlertDescription, AlertTitle } from '@/components/ui/Alert' import { Badge } from '@/components/ui/Badge' @@ -161,8 +161,16 @@ export const TourComposerForm = ({ checked ? [...current, slug] : current.filter((entry) => entry !== slug), ) + // C140 — manual dispatch (no `action={submitAction}`): React 19 resets + // uncontrolled fields after any settled form action, wiping typed values + // on a validation error — the page stays visible, so the wipe showed. + const handleSubmit = (event: FormEvent<HTMLFormElement>) => { + event.preventDefault() + startTransition(() => submitAction(new FormData(event.currentTarget))) + } + return ( - <form action={submitAction} className="flex flex-col gap-5"> + <form onSubmit={handleSubmit} className="flex flex-col gap-5"> <input type="hidden" name="stopsJson" value={stopsJson} /> <div className="rounded-xl border"> diff --git a/src/components/campaign/votePledge/DeclareVotesForm.tsx b/src/components/campaign/votePledge/DeclareVotesForm.tsx index 773a60e5..80a1e8e7 100644 --- a/src/components/campaign/votePledge/DeclareVotesForm.tsx +++ b/src/components/campaign/votePledge/DeclareVotesForm.tsx @@ -1,6 +1,6 @@ 'use client' -import { useActionState } from 'react' +import { startTransition, useActionState, type FormEvent } from 'react' import { CampaignFormActionMessage } from '@/components/campaign/shared/CampaignFormActionMessage' import { Button } from '@/components/ui/button' @@ -29,8 +29,16 @@ export const DeclareVotesForm = ({ }: DeclareVotesFormProps) => { const [state, submitAction, isPending] = useActionState(formAction, {}) + // C140 — manual dispatch (no `action={submitAction}`): React 19 resets + // uncontrolled fields after any settled form action, wiping typed values + // on a validation error — the card stays open, so the wipe showed. + const handleSubmit = (event: FormEvent<HTMLFormElement>) => { + event.preventDefault() + startTransition(() => submitAction(new FormData(event.currentTarget))) + } + return ( - <form action={submitAction} className="flex flex-col gap-3"> + <form onSubmit={handleSubmit} className="flex flex-col gap-3"> <input type="hidden" name="municipalityId" value={municipalityID} /> {leadershipID !== undefined ? ( <input type="hidden" name="leadershipId" value={leadershipID} /> diff --git a/src/components/campaign/votePledge/PledgeEstimateForm.tsx b/src/components/campaign/votePledge/PledgeEstimateForm.tsx index 4b2fa846..8e53b2f8 100644 --- a/src/components/campaign/votePledge/PledgeEstimateForm.tsx +++ b/src/components/campaign/votePledge/PledgeEstimateForm.tsx @@ -1,6 +1,6 @@ 'use client' -import { useActionState } from 'react' +import { startTransition, useActionState, type FormEvent } from 'react' import { CampaignFormActionMessage } from '@/components/campaign/shared/CampaignFormActionMessage' import { VoteEstimateScenarioInputs } from '@/components/campaign/votePledge/VoteEstimateScenarioInputs' @@ -31,8 +31,16 @@ export const PledgeEstimateForm = ({ }: PledgeEstimateFormProps) => { const [state, submitAction, isPending] = useActionState(formAction, {}) + // C140 — manual dispatch (no `action={submitAction}`): React 19 resets + // uncontrolled fields after any settled form action, wiping typed values + // on a validation error — the card stays open, so the wipe showed. + const handleSubmit = (event: FormEvent<HTMLFormElement>) => { + event.preventDefault() + startTransition(() => submitAction(new FormData(event.currentTarget))) + } + return ( - <form action={submitAction} className="flex flex-col gap-3"> + <form onSubmit={handleSubmit} className="flex flex-col gap-3"> <input type="hidden" name="pledgeId" value={pledgeID} /> <VoteEstimateScenarioInputs fieldPrefix="estimatedVotes" diff --git a/tests/e2e/admin.e2e.spec.ts b/tests/e2e/admin.e2e.spec.ts index 9ce1a1e1..eeb06e7b 100644 --- a/tests/e2e/admin.e2e.spec.ts +++ b/tests/e2e/admin.e2e.spec.ts @@ -1,6 +1,5 @@ -import { adminHeaders } from '../helpers/adminApi' import { login } from '../helpers/login' -import { seedTestUser, testUser } from '../helpers/seedUser' +import { cleanupTestUser, seedTestUser, testUser } from '../helpers/seedUser' import { instagramStubUrlFor } from '../helpers/socialStub' import { expect, test } from './fixtures/e2eTest' @@ -16,6 +15,10 @@ test.describe('Admin Panel', () => { await login({ page, serverURL: baseURL, user: testUser }) }) + test.afterAll(async () => { + await cleanupTestUser() + }) + test('can navigate to dashboard', async ({ page }) => { await page.goto(`${baseURL}/admin`) await expect(page).toHaveURL(`${baseURL}/admin`) @@ -44,7 +47,12 @@ test.describe('Admin Panel', () => { // Seed the social-feed global with an Instagram snapshot so the exclusion // picker (S3) has posts to show. The snapshot is plain data — the picker // lists it and writes `excludedItems` (the same mechanism YouTube uses). - const headers = await adminHeaders(request, baseURL) + const login = await request.post(`${baseURL}/api/users/login`, { + data: { email: testUser.email, password: testUser.password }, + }) + expect(login.ok()).toBeTruthy() + const { token } = await login.json() + const headers = { cookie: `payload-token=${token}` } const snapshot = { username: 'depjorgesolla', @@ -141,7 +149,12 @@ test.describe('Admin Panel', () => { } await setStub('ok') - const headers = await adminHeaders(request, baseURL) + const login = await request.post(`${baseURL}/api/users/login`, { + data: { email: testUser.email, password: testUser.password }, + }) + expect(login.ok()).toBeTruthy() + const { token } = await login.json() + const headers = { cookie: `payload-token=${token}` } const seed = await request.post(`${baseURL}/api/globals/social-feed-settings`, { headers, diff --git a/tests/e2e/campaignHomePixel.e2e.spec.ts b/tests/e2e/campaignHomePixel.e2e.spec.ts index 9fb983e9..da768970 100644 --- a/tests/e2e/campaignHomePixel.e2e.spec.ts +++ b/tests/e2e/campaignHomePixel.e2e.spec.ts @@ -33,10 +33,11 @@ test.describe('Campaign home Meta pixel', () => { headers: Record<string, string>, pixelId: string | null, ) => { - // Insurance against the same-user concurrent-login session race during - // retries of this very spec's own non-serialized write: a 403 means the - // session row was lost to another worker — re-login mints a fresh one. - // The locked helper already serializes the specs that use it. + // Insurance against the same-user concurrent-login session race with + // specs that do NOT route their login through the advisory-locked + // `adminHeaders` helper (e.g. the frontend spec's inline logins): a 403 + // means the session row was lost to another worker — re-login mints a + // fresh one. The locked helper already serializes the specs that use it. for (let attempt = 0; attempt < 2; attempt += 1) { const response = await request.post(`${baseURL}/api/globals/site-settings`, { headers, diff --git a/tests/e2e/campaignLeaderships.e2e.spec.ts b/tests/e2e/campaignLeaderships.e2e.spec.ts index 8047a3d7..149c068f 100644 --- a/tests/e2e/campaignLeaderships.e2e.spec.ts +++ b/tests/e2e/campaignLeaderships.e2e.spec.ts @@ -80,6 +80,7 @@ test.describe('campaign leaderships list', () => { await campaign.login(page, coordinator.email!, password) await page.goto(`${campaign.baseURL}/campanha/liderancas?q=${encodeURIComponent(contactName)}`) await expect(campaignPageChrome(page, 'Lideranças')).toBeVisible() + await page.waitForLoadState('networkidle') // The chip is still a link to the município — editing did not cost the // navigation the column had before. diff --git a/tests/e2e/frontend.e2e.spec.ts b/tests/e2e/frontend.e2e.spec.ts index 72555176..b0e7fd27 100644 --- a/tests/e2e/frontend.e2e.spec.ts +++ b/tests/e2e/frontend.e2e.spec.ts @@ -1,7 +1,6 @@ import type { APIRequestContext, Locator, Page } from '@playwright/test' -import { adminHeaders } from '../helpers/adminApi' -import { seedTestUser } from '../helpers/seedUser' +import { seedTestUser, testUser } from '../helpers/seedUser' import { instagramStubUrlFor, youtubeStubUrlFor } from '../helpers/socialStub' import { expect, test } from './fixtures/e2eTest' @@ -606,11 +605,20 @@ test.describe('Campaign home content section', () => { // Start every run with the social feed unconfigured so the empty-state // test stays deterministic even when a previous S2 run left settings or a // persisted snapshot behind (same reasoning as the `posts` bust below). - const headers = await adminHeaders(request, baseURL) + const headers = await adminHeaders(request) await resetSocialFeedSettings(request, headers) await bustSocialFeed(request) }) + const adminHeaders = async (request: APIRequestContext): Promise<Record<string, string>> => { + const login = await request.post(`${baseURL}/api/users/login`, { + data: { email: testUser.email, password: testUser.password }, + }) + expect(login.ok()).toBeTruthy() + const { token } = await login.json() + return { cookie: `payload-token=${token}` } + } + const createTag = async ( request: APIRequestContext, headers: Record<string, string>, @@ -799,7 +807,7 @@ test.describe('Campaign home content section', () => { request, }) => { await seedTestUser() - const headers = await adminHeaders(request, baseURL) + const headers = await adminHeaders(request) const visibleTag = await createTag(request, headers, categoryName, categorySlug) const hiddenTag = await createTag(request, headers, hiddenTagName, hiddenTagSlug, true) @@ -957,7 +965,7 @@ test.describe('Campaign home content section', () => { await page.goto('about:blank') await expect(page).toHaveURL(/about:blank/) } finally { - const headers2 = await adminHeaders(request, baseURL).catch(() => undefined) + const headers2 = await adminHeaders(request).catch(() => undefined) if (headers2) { for (const id of createdPosts) { await request @@ -990,7 +998,7 @@ test.describe('Campaign home content section', () => { request, }) => { await seedTestUser() - const headers = await adminHeaders(request, baseURL) + const headers = await adminHeaders(request) await setYouTubeStubState(request, 'ok') const mixTag = await createTag(request, headers, `E2e Mix ${runSuffix}`, `e2e-mix-${runSuffix}`) @@ -1071,7 +1079,7 @@ test.describe('Campaign home content section', () => { request, }) => { await seedTestUser() - const headers = await adminHeaders(request, baseURL) + const headers = await adminHeaders(request) await setYouTubeStubState(request, 'fail') const fallbackTag = await createTag( @@ -1124,7 +1132,7 @@ test.describe('Campaign home content section', () => { test('keeps the last snapshot while the API is down', async ({ page, request }) => { await seedTestUser() - const headers = await adminHeaders(request, baseURL) + const headers = await adminHeaders(request) await setYouTubeStubState(request, 'ok') const baseSettings = { enabled: true, @@ -1188,7 +1196,7 @@ test.describe('Campaign home content section', () => { request, }) => { await seedTestUser() - const headers = await adminHeaders(request, baseURL) + const headers = await adminHeaders(request) await setYouTubeStubState(request, 'ok') await setInstagramStubState(request, 'ok') @@ -1278,7 +1286,7 @@ test.describe('Campaign home content section', () => { request, }) => { await seedTestUser() - const headers = await adminHeaders(request, baseURL) + const headers = await adminHeaders(request) await setInstagramStubState(request, 'fail') const fallbackTag = await createTag( @@ -1336,7 +1344,7 @@ test.describe('Campaign home content section', () => { test('keeps the last Instagram snapshot while the API is down', async ({ page, request }) => { await seedTestUser() - const headers = await adminHeaders(request, baseURL) + const headers = await adminHeaders(request) await setInstagramStubState(request, 'ok') const baseSettings = { enabled: true, @@ -1407,7 +1415,7 @@ test.describe('Campaign home content section', () => { request, }) => { await seedTestUser() - const headers = await adminHeaders(request, baseURL) + const headers = await adminHeaders(request) await setYouTubeStubState(request, 'ok') await setInstagramStubState(request, 'ok') @@ -1472,7 +1480,7 @@ test.describe('Campaign home content section', () => { context, }) => { await seedTestUser() - const headers = await adminHeaders(request, baseURL) + const headers = await adminHeaders(request) await setYouTubeStubState(request, 'ok') await setInstagramStubState(request, 'ok') diff --git a/tests/helpers/e2eEsmResolve.mjs b/tests/helpers/e2eEsmResolve.mjs index f4dd0436..625bf3f8 100644 --- a/tests/helpers/e2eEsmResolve.mjs +++ b/tests/helpers/e2eEsmResolve.mjs @@ -14,19 +14,10 @@ * * Registered in the main process by `playwright.config.ts` and passed to the * worker processes via `NODE_OPTIONS=--import=...` (see `e2eEsmLoader.mjs`). - * - * OPS46-S1 — the `next/cache` remap only fires when the bare resolution - * actually fails with `ERR_MODULE_NOT_FOUND`: once next ships an `exports` - * map, bare resolution succeeds and the remap must not mask a real error. */ export async function resolve(specifier, context, nextResolve) { if (specifier === 'next/cache') { - try { - return await nextResolve(specifier, context) - } catch (error) { - if (error?.code !== 'ERR_MODULE_NOT_FOUND') throw error - return await nextResolve('next/cache.js', context) - } + return nextResolve('next/cache.js', context) } if (!context.conditions.includes('react-server')) { return nextResolve(specifier, { diff --git a/tests/helpers/seedUser.ts b/tests/helpers/seedUser.ts index f3059de5..e251aedd 100644 --- a/tests/helpers/seedUser.ts +++ b/tests/helpers/seedUser.ts @@ -35,3 +35,20 @@ export async function seedTestUser(): Promise<void> { // A parallel worker already seeded the shared user; nothing to reset. } } + +/** + * Cleans up test user after tests + */ +export async function cleanupTestUser(): Promise<void> { + assertTestDatabase(process.env.DATABASE_URL) + const payload = await getPayload({ config }) + + await payload.delete({ + collection: 'users', + where: { + email: { + equals: testUser.email, + }, + }, + }) +} diff --git a/tests/unit/agentPoolState.unit.spec.ts b/tests/unit/agentPoolState.unit.spec.ts index 5da7e50d..c3a4d4d1 100644 --- a/tests/unit/agentPoolState.unit.spec.ts +++ b/tests/unit/agentPoolState.unit.spec.ts @@ -100,7 +100,7 @@ describe('classifyPoolClaim', () => { expect(classifyPoolClaim({ events: [], now })).toBeNull() }) - it('frees the slot once the issue is done (CI flipped on main merge)', () => { + it('frees the slot once the issue is done (CI flipped on stage merge)', () => { expect(classifyPoolClaim({ events: [claimEvent], issueDone: true, now })).toEqual({ class: POOL_CLAIM_CLASS.freed, }) diff --git a/tests/unit/campaignWizardStepFormChrome.unit.spec.tsx b/tests/unit/campaignWizardStepFormChrome.unit.spec.tsx new file mode 100644 index 00000000..294062c0 --- /dev/null +++ b/tests/unit/campaignWizardStepFormChrome.unit.spec.tsx @@ -0,0 +1,80 @@ +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { WizardStepFormChrome } from '@/components/campaign/shared/WizardStepFormChrome' + +afterEach(() => cleanup()) + +describe('WizardStepFormChrome C140', () => { + it('renders a <form> in action mode', () => { + render( + <WizardStepFormChrome + action={vi.fn()} + isPending={false} + pendingAnnouncement="Salvando…" + ctaLabel="Salvar" + > + <input type="text" name="title" defaultValue="abc" /> + </WizardStepFormChrome>, + ) + + const form = document.querySelector('form') + expect(form).not.toBeNull() + expect(form!.querySelector('input[name="title"]')).not.toBeNull() + }) + + it('renders a <div> in onCtaClick mode (not a form)', () => { + render( + <WizardStepFormChrome + onCtaClick={vi.fn()} + isPending={false} + pendingAnnouncement="Salvando…" + ctaLabel="Salvar" + > + Step content + </WizardStepFormChrome>, + ) + + expect(document.querySelector('form')).toBeNull() + }) + + it('calls the action with FormData on submit, preserving uncontrolled field values', () => { + const action = vi.fn().mockResolvedValue({}) + + render( + <WizardStepFormChrome + action={action} + isPending={false} + pendingAnnouncement="Salvando…" + ctaLabel="Salvar" + > + <input type="text" name="title" defaultValue="keep this" /> + </WizardStepFormChrome>, + ) + + const form = document.querySelector('form')! + fireEvent.submit(form) + + expect(action).toHaveBeenCalledTimes(1) + const formData = action.mock.calls[0][0] as FormData + expect(formData.get('title')).toBe('keep this') + }) + + it('sets aria-busy and data-pending when isPending is true', () => { + render( + <WizardStepFormChrome + action={vi.fn()} + isPending={true} + pendingAnnouncement="Salvando…" + ctaLabel="Salvar" + > + Step + </WizardStepFormChrome>, + ) + + const form = document.querySelector('form')! + expect(form.getAttribute('aria-busy')).toBe('true') + expect(form.hasAttribute('data-pending')).toBe(true) + expect(screen.getAllByText('Salvando…').length).toBeGreaterThanOrEqual(1) + }) +}) diff --git a/tests/unit/e2eEsmResolve.unit.spec.ts b/tests/unit/e2eEsmResolve.unit.spec.ts deleted file mode 100644 index e55cf8d1..00000000 --- a/tests/unit/e2eEsmResolve.unit.spec.ts +++ /dev/null @@ -1,46 +0,0 @@ -// @vitest-environment node - -import { describe, expect, it } from 'vitest' - -import { resolve } from '../helpers/e2eEsmResolve.mjs' - -const CONTEXT = { conditions: ['react-server', 'node'] } - -type NodeError = Error & { code: string } - -describe('e2eEsmResolve', () => { - it('resolves next/cache bare when the exports map exists (no remap)', async () => { - const nextResolve = async (specifier: string) => { - expect(specifier).toBe('next/cache') - return { url: 'file:///node_modules/next/cache' } - } - await expect(resolve('next/cache', CONTEXT, nextResolve)).resolves.toEqual({ - url: 'file:///node_modules/next/cache', - }) - }) - - it('remaps next/cache to next/cache.js only on ERR_MODULE_NOT_FOUND', async () => { - const nextResolve = async (specifier: string, context: unknown) => { - expect(context).toBe(CONTEXT) - if (specifier === 'next/cache') { - const error = new Error(`Cannot find module '${specifier}'`) as NodeError - error.code = 'ERR_MODULE_NOT_FOUND' - throw error - } - expect(specifier).toBe('next/cache.js') - return { url: 'file:///node_modules/next/cache.js' } - } - await expect(resolve('next/cache', CONTEXT, nextResolve)).resolves.toEqual({ - url: 'file:///node_modules/next/cache.js', - }) - }) - - it('propagates non-ERR_MODULE_NOT_FOUND errors from the bare resolution', async () => { - const broken = async () => { - const error = new Error('boom') as NodeError - error.code = 'ERR_SOMETHING_ELSE' - throw error - } - await expect(resolve('next/cache', CONTEXT, broken)).rejects.toThrow('boom') - }) -})