fix(campaign): C140 — manual dispatch in 18 forms prevents React 19 form reset on error
Fix manual dispatch em 18 formulários de /campanha que usavam <form action={submitAction}>: 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)
This commit is contained in:
parent
a97cd56fce
commit
9467e0f292
42 changed files with 521 additions and 663 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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`).
|
||||
|
||||
|
|
|
|||
|
|
@ -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 `<form action={submitAction}>`: 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).
|
||||
|
|
|
|||
1
docs/changelog/2026-08-23-c140.md
Normal file
1
docs/changelog/2026-08-23-c140.md
Normal file
|
|
@ -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 `<form action={submitAction}>`: 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.
|
||||
|
|
@ -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-<id> <agentUrl>`) | 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: <slug>, 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<N>-…`.
|
||||
|
||||
**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
|
||||
|
|
|
|||
|
|
@ -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 — <title> (<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`
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
|
|
@ -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`.
|
||||
|
|
@ -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`.
|
||||
116
docs/plans/react-19-form-reset-campanha-impl.md
Normal file
116
docs/plans/react-19-form-reset-campanha-impl.md
Normal file
|
|
@ -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).
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
||||
|
|
|
|||
|
|
@ -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, {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
|
|
|
|||
80
tests/unit/campaignWizardStepFormChrome.unit.spec.tsx
Normal file
80
tests/unit/campaignWizardStepFormChrome.unit.spec.tsx
Normal file
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue