diff --git a/AGENTS.md b/AGENTS.md index 17ef92f4..5cd9cd00 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,7 +33,7 @@ These are product/architecture constraints — not a ban on editing existing cod - Schema changes go through committed Payload migrations — `push: false` everywhere, never flip it on against a remote DB. Edit configs → `pnpm migrate:create ` → commit `src/migrations/*` (both `.ts` and `.json`, plus `index.ts`) → `pnpm migrate` locally. `pnpm build` runs `payload migrate`, so **every Vercel deploy applies pending migrations to prod**. Prod is baselined at migration `20260715_163458_initial` — never regenerate or replace the initial migration. Full workflow (incl. hand-written data/reconciliation migrations) lives in the `payload-migrations` skill (`.agents/skills/payload-migrations`). - `Consent.text` was reconciled from `varchar` to `jsonb` via `20260715_163500_consent_text_to_jsonb` (matching the `richText` field). Any future field-type change is a migration, not a manual DB edit. - To copy production content locally, use `pnpm db:pull` (`scripts/db-pull.mjs`): it only reads prod, only writes local, and excludes supporter PII (`contact`/`signature`/`subscription` row data). -- `pnpm db:doctor` diagnoses local database connectivity for the dev AND test targets: it names who holds the port, points at exited/crash-looping containers, and flags Postgres containers sharing one data volume (two postmasters on one PGDATA corrupt the WAL — this destroyed the local cluster's checkpoint on 2026-07-25; the canonical setup is ONE compose container on 5432 hosting `teqo` + `teqo_test`). `pnpm dev` runs the same preflight via `guard-dev-db` and refuses to start with a named remedy instead of a later ECONNREFUSED. `db:start`/`db:stop` pin the compose project with `-p teqo`, so they always target that ONE container even from a worktree directory. +- `pnpm db:doctor` diagnoses local database connectivity for the dev AND test targets: it names who holds the port, points at exited/crash-looping containers, and flags Postgres containers sharing one data volume (two postmasters on one PGDATA corrupt the WAL — this destroyed the local cluster's checkpoint on 2026-07-25; the canonical setup is ONE compose container on 5432 hosting `teqo` + `teqo_test`). `pnpm dev` runs the same preflight via `guard-dev-db` and refuses to start with a named remedy instead of a later ECONNREFUSED. `db:start`/`db:stop` pin the compose project with `-p teqo`, so they always target that ONE container even from a worktree directory. **`db:start` is idempotent (OPS37):** with `teqo-postgres-1` up and healthy it is a no-op — a plain `compose up` from another worktree would recreate the shared container and kill every live dev/e2e connection (the compose file's relative `./docker/postgres/init` bind mount makes the config hash differ per worktree); apply a real config/image change on purpose with `pnpm db:start --force-recreate`. <<<<<<< HEAD - # **Per-worktree environments (`pnpm worktree next`, 2026-08-08; default-go 2026-08-10):** parallel agents each get a deterministic isolated env — no fighting over port 3000, the shared `teqo_test`, or a second Postgres container. `worktree next [--stay]` provisions: a dev-server port `3100+slot` (slot = numeric part of the issue code, hash fallback, bumped on collision among live worktrees), plus dedicated databases `teqo_wt` (dev) and `teqo_wt_test` (tests) created on the shared container with the full migration chain AND the minimal seed applied (`pnpm migrate` + `pnpm db:seed:minimal` per database — OPS28 parity: CI runs the same pair before e2e, so a worktree's `pnpm test:e2e` mirrors CI instead of failing on seed-dependent specs; the seed is idempotent and local-guarded, and `--no-migrate` skips both); writes `.env.local` + `.env.test.local` (gitignored, marker `# teqo worktree env (generated by pnpm worktree next)`; manual envs are never overwritten) and copies `PAYLOAD_SECRET`/Blob/VAPID from the main repo. **`next`/`plan`/`new` print `cd ` as the last line BY DEFAULT** — the caller applies it (shell function `worktree()` in `.agents/shell/worktree.sh`, or the `/worktree` opencode command); `--stay` suppresses the cd, `--go` explicit stays accepted as a no-op (it was the old default), and `kill` ends with `cd
` so the shell never sits in a destroyed directory. **In the interactive terminal the shell function sets `TEQO_WORKTREE_TERMINAL=1` and `next`/`plan`/`new` additionally print a `launch opencode --model deepseek/deepseek-v4-flash --auto [--prompt ]` directive BEFORE the `cd` line, which the function executes after the cd (OPS26, 2026-08-10; OPS31 for `plan`): `next` opens the opencode TUI in the worktree with `/work-issue` already sent (the OPS25 command runs the whole cycle), `plan` opens with `/plan-issue` already sent (the TUI lands straight in the planning flow), `new` opens with no prompt ("apenas conversar", no skill — the opencode CLI's `--prompt` always auto-submits, there is no prefill-without-submit; that stays an upstream-only feature request, annotated in the opencode proposition). The launch is without `exec` (exiting the TUI returns to the shell in the worktree, no auto-`kill`), `--stay` suppresses both the cd and the launch, the presets are constants in `scripts/lib/worktree.mjs` (`OPENCODE_PRESET_MODEL`, `OPENCODE_SKILL_COMMAND_BY_PURPOSE`, unit-tested), and **the `/worktree` opencode command never launches a TUI** (no marker — no nested TUI). `worktree plan [bag] [--stay]` (2026-08-08) is the sibling for the `/plan-issue` skill: **each invocation creates a DIFFERENT planning worktree** so parallel `/plan-issue` sessions never share one — branch `plans/plan-issue-` when a `bag` slug is given (suffixed `-2`/`-3` if that name is taken) or the next free sequential `plans/plan-issue-` when not (`PLAN_BRANCH_PREFIX`/`planBranchName`, `scripts/lib/worktree.mjs`); the lowercase `plans/…` prefix deliberately never matches `next`'s uppercase-led `-`, so a later `next` never collides in branch or slot. `worktree new [bag] [--stay]` (OPS27, 2026-08-10) is the NEUTRAL sibling: a worktree with **no pre-defined function\*\* (explore an idea, chat, plan without registering anything) — also a DIFFERENT one per invocation, branch `work/` (suffixed `-2`/`-3` on collision) or the next free sequential `work/` (`WORK_BRANCH_PREFIX`/`workBranchName`, same `namespaceBranchName` core as `plan`); the lowercase `work/…` prefix collides with neither `next` nor `plan`, and the provisioning is identical with `purpose: 'new'` (env comment `gerado por pnpm worktree new`; the frozen env marker keeps its historical `…next` text). `kill` drops the worktree's generated databases best-effort. Tests read `.env.test.local` after `.env.test` (vitest.setup.ts / playwright.config.ts / db-doctor / gate-ci mirror the layering), and `assertTestDatabase` accepts `teqo_test` or `teqo__test` on localhost only — still fail-closed on remote. Pure derivation lives in `scripts/lib/worktree-env.mjs` (unit-tested). Without Docker (Cursor Cloud) it degrades to the shared `teqo`/`teqo_test` with a warning — and still runs migrate + seed mínimo on those shared databases (same parity; the seed re-pins campaignGoals and 5 municipalities there, overwriting manual tweaks). - **Per-worktree environments (`pnpm worktree next`, 2026-08-08; default-go 2026-08-10):** parallel agents each get a deterministic isolated env — no fighting over port 3000, the shared `teqo_test`, or a second Postgres container. **`next` claims BEFORE creating the worktree (OPS33, 2026-08-10):** the same queue/order and optimistic lock as `pnpm agent:claim` (shared `claimIssue` in `scripts/lib/agent-github.mjs`); a failed claim prints the reason and stops with no orphan worktree; `--issue ` claims the directed issue (`ready`) or **reopens** an already-claimed one (`in-progress`, no re-claim, worktree reuse stays idempotent); the claim brief is printed and the output warns "já claimada — não rodar `pnpm agent:claim`" (`plan`/`new`/`kill` never touch Issues; the pool's coordinated `claimIssueForPool` is untouched). `worktree next [--issue N] [--stay]` provisions: a dev-server port `3100+slot` (slot = numeric part of the issue code, capped at 999 with hash fallback, bumped on collision among live worktrees), plus dedicated databases `teqo_wt` (dev) and `teqo_wt_test` (tests) created on the shared container with the full migration chain AND the minimal seed applied (`pnpm migrate` + `pnpm db:seed:minimal` per database — OPS28 parity: CI runs the same pair before e2e, so a worktree's `pnpm test:e2e` mirrors CI instead of failing on seed-dependent specs; the seed is idempotent and local-guarded, and `--no-migrate` skips both); writes `.env.local` + `.env.test.local` (gitignored, marker `# teqo worktree env (generated by pnpm worktree next)`; manual envs are never overwritten) and copies `PAYLOAD_SECRET`/Blob/VAPID from the main repo. **`next`/`plan`/`new` print `cd ` as the last line BY DEFAULT** — the caller applies it (shell function `worktree()` in `.agents/shell/worktree.sh`, or the `/worktree` opencode command); `--stay` suppresses the cd, `--go` explicit stays accepted as a no-op (it was the old default), and `kill` ends with `cd
` so the shell never sits in a destroyed directory. **In the interactive terminal the shell function sets `TEQO_WORKTREE_TERMINAL=1` and `next`/`plan`/`new` additionally print a `launch opencode --model deepseek/deepseek-v4-flash --auto [--prompt "/work-issue --issue "]` directive BEFORE the `cd` line, which the function executes after the cd (OPS26, 2026-08-10; OPS33: `next` sends `/work-issue --issue ` — the claimed issue — with the prompt value quoted, and the shell tokenizes the line via xargs, quote-aware, never eval; OPS31 for `plan`): `next` opens the opencode TUI in the worktree with `/work-issue --issue ` already sent (the OPS25 command runs the whole cycle; the skill reads the rest from the Issue), `plan` opens with `/plan-issue` already sent (the TUI lands straight in the planning flow), `new` opens with no prompt ("apenas conversar", no skill — the opencode CLI's `--prompt` always auto-submits, there is no prefill-without-submit; that stays an upstream-only feature request, annotated in the opencode proposition). The launch is without `exec` (exiting the TUI returns to the shell in the worktree, no auto-`kill`), `--stay` suppresses both the cd and the launch (the claim still happens), the presets are constants in `scripts/lib/worktree.mjs` (`OPENCODE_PRESET_MODEL`, `OPENCODE_SKILL_COMMAND_BY_PURPOSE`, unit-tested), and **the `/worktree` opencode command never launches a TUI** (no marker — no nested TUI). `worktree plan [bag] [--stay]` (2026-08-08) is the sibling for the `/plan-issue` skill: **each invocation creates a DIFFERENT planning worktree** so parallel `/plan-issue` sessions never share one — branch `plans/plan-issue-` when a `bag` slug is given (suffixed `-2`/`-3` if that name is taken) or the next free sequential `plans/plan-issue-` when not (`PLAN_BRANCH_PREFIX`/`planBranchName`, `scripts/lib/worktree.mjs`); the lowercase `plans/…` prefix deliberately never matches `next`'s uppercase-led `-`, so a later `next` never collides in branch or slot. `worktree new [bag] [--stay]` (OPS27, 2026-08-10) is the NEUTRAL sibling: a worktree with **no pre-defined function\*\* (explore an idea, chat, plan without registering anything) — also a DIFFERENT one per invocation, branch `work/` (suffixed `-2`/`-3` on collision) or the next free sequential `work/` (`WORK_BRANCH_PREFIX`/`workBranchName`, same `namespaceBranchName` core as `plan`); the lowercase `work/…` prefix collides with neither `next` nor `plan`, and the provisioning is identical with `purpose: 'new'` (env comment `gerado por pnpm worktree new`; the frozen env marker keeps its historical `…next` text). `kill` drops the worktree's generated databases best-effort. Tests read `.env.test.local` after `.env.test` (vitest.setup.ts / playwright.config.ts / db-doctor / gate-ci mirror the layering), and `assertTestDatabase` accepts `teqo_test` or `teqo__test` on localhost only — still fail-closed on remote. Pure derivation lives in `scripts/lib/worktree-env.mjs` (unit-tested). Without Docker (Cursor Cloud) it degrades to the shared `teqo`/`teqo_test` with a warning — and still runs migrate + seed mínimo on those shared databases (same parity; the seed re-pins campaignGoals and 5 municipalities there, overwriting manual tweaks). diff --git a/docs/CHANGELOG-AGENTS.md b/docs/CHANGELOG-AGENTS.md index b33b3d8e..5b600663 100644 --- a/docs/CHANGELOG-AGENTS.md +++ b/docs/CHANGELOG-AGENTS.md @@ -4,6 +4,8 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente --- +**Recently resolved (2026-08-10):** **OPS37** (Issue #605) — o container Postgres compartilhado **não é mais recriado** por um worktree vizinho: `pnpm db:start` virou `scripts/db-start.mjs` (CLI) sobre `scripts/lib/db-start.mjs` — preflight "container `teqo-postgres-1` Up + `healthy`/`starting` → **no-op** (compose nem roda); senão, `compose -p teqo up -d --wait --wait-timeout 120 postgres` como antes. **Causa raiz provada por medição:** o bind mount **relativo** `./docker/postgres/init` do compose resolve um path absoluto por worktree → `config_hash` diferente por worktree (OPS37 `39e89956…` / B195 `cf3cb522…` / C101 `917e11bb…`; diff do `config` = só o `source:` do bind) → qualquer `compose up` cru de outro worktree fazia `replace` e matava dev+e2e de todos (`terminating connection due to administrator command`) — o incidente se repetiu **ao vivo durante o gate**: o worktree `work/4` recriou o container no meio da suíte int (17 testes mortos; os 3 arquivos re-rodaram verdes e a suíte completa 691/691 passou em seguida). **Verificação do aceite:** `StartedAt` inalterado após `db:start` repetido, inclusive rodado de **outro worktree** (D9, hash diferente) — o caso que antes recriava. Recriação legítima (bump de imagem/config) segue por `pnpm db:start --force-recreate` (explícito; não testado live para não matar a run e2e do D9 em voo — flag é pass-through de 1 arg). `worktree.mjs` provisionamento delega para a mesma lib (mesmo risco no `worktree next`; `dockerComposeUp`/`SHARED_COMPOSE_PROJECT` órfãos removidos). Skip deliberado em `starting` (container que um peer acabou de criar): **espera** o boot ficar `healthy` (60s, polling 2s) em vez de recriar — e cai no compose up se travou; **não** skip em `unhealthy`/sem healthcheck (volta à config atual exatamente uma vez); `db:stop`/`db:doctor`/compose file/CI intactos. Unit novos (`dbStart.unit.spec.ts`, 17: matrix running×health + parse do inspect + wrapper mockado — skip não chama compose, argv do up/force-recreate, espera de `starting`). Sem migration/access/Consent/UI. Intenção [`container-postgres-compartilhado-compose-replace.md`](plans/container-postgres-compartilhado-compose-replace.md); impl [`container-postgres-compartilhado-compose-replace-impl.md`](plans/container-postgres-compartilhado-compose-replace-impl.md). + **Recently resolved (2026-08-10):** **B195** (Issue #591) — wizard "Registrar pedido" completo (A5, adiado no B85): o passo final saiu do stub e virou formulário funcional no shell do wizard — município no header do app (chrome `municipalityLabel`), **sem título grande de passo** (`stepTitle={null}` + `contentFocus="none"`), Tipo → Atividade (opcional) → **campo único** "O que você precisa?"; salvar ("Abrir demanda") → toast + retorno à origem (`returnPath`). **Mudança de modelo da demanda:** o cadastro (wizard e `/demandas/nova`, sem divergência) tem UM campo de texto (`description` required, min 2, max 4000 — constante `CAMPAIGN_DEMAND_BODY_MAX_LENGTH`); o **título virou derivado por IA** (`src/utilities/ai/campaignDemandTitle.ts` — `generateText` DeepSeek `deepseek-v4-flash` como o Sollinha, timeout 4s, sem key → `null`, prompt com rótulo do tipo) com **fallback = texto truncado** (`src/lib/demandTitle.ts`, puro: colapsa whitespace, ≤160) no create e **keep-previous na edição** (novo `updateCampaignDemandRecord` + editor inline no detalhe — recalc só quando a descrição muda; slug preservado pela invariante canônica); `campaignDemandCreateSchema` perdeu `title` (só os drafts de demanda da atividade mantêm título digitado — vertical fora de escopo, schema desacoplado). **Unicidade de slug defensiva só no create das forms** (`ensureUniqueDemandSlug`, opt-in `context.campaignDemandUniqueSlug` fail-closed em transação ativa — precedente `skipContactPhoneInvariant`): probe único da janela `-2…-20` + advisory lock `campaign-demand-slug:` contra race; activity drafts/admin mantêm a constraint estrita (contrato de rollback C90 pinado em teste). Page: branch `register-demand` no router + **staff gate ANTES do load do escopo** (`gate: 'staff'` → líder vai a `/campanha/contatos`, B43 — o 403 do scope load pré-existente virava erro não determinístico; stub `WizardMunicipalitySelectedStub` removido, placeholder "em breve" apagado). **Bug latente corrigido no hook compartilhado** `useCampaignFormSuccessToast` (dependência `[state.message, state.status]` → `[state]`: segundo save com a mesma mensagem — caso do editor — não disparava toast/refresh). Copy compartilhada em `schemas/campaignDemand.ts`; parser único `parseCampaignDemandCreateFormData` (`utilities/demand/`). **Sem migration** (título continua required, preenchido pelo record). Testes: unit do fallback/limites exatos; int (create deriva, colisão `-2`/`-3`, min 2, update keep-previous/skip, leader edit bloqueado, pin "sem `DEEPSEEK_API_KEY`" no `beforeAll`); e2e novo `campaignRegisterDemand` (fluxo completo com título na lista, back-nav à busca, lockdown de líder). Intenção [`registrar-pedido-wizard.md`](plans/registrar-pedido-wizard.md); impl [`registrar-pedido-wizard-impl.md`](plans/registrar-pedido-wizard-impl.md). **Recently resolved (2026-08-10):** **OPS33** (Issue #595) — `worktree next` **claima deterministicamente** antes de criar o worktree: mesma fila/ordem e lock otimista do `pnpm agent:claim`, com o claim extraído para `scripts/lib/agent-github.mjs` (`claimIssue` — lock + flip + comentário; `claimBriefLines` — brief compartilhado; `claimQueueEntry` — shape para o reopen; `claimTargetVerdict` — decisão pura testada) e o `agent-claim.mjs` virou CLI fino com a **mesma saída**; claim falhou (fila vazia/race/`--issue` inválido) → motivo e saída **sem worktree órfão** (branch validada antes do claim; hint de falha pós-claim). `--issue ` claima a Issue direcionada (`ready`) ou **reabre** uma já claimada (`in-progress` — sem re-claim, idempotente; recupera também falha pós-claim); a saída imprime o brief e avisa "já claimada — NÃO rodar `pnpm agent:claim`". O **launch** do terminal agora envia `/work-issue --issue ` (prompt citado na diretiva) e a função `worktree()` de `.agents/shell/worktree.sh` passou a **tokenizar a diretiva via xargs** (quote-aware, nunca eval) — o valor do `--prompt` carrega espaço. `--stay` suprime cd+launch mas o claim acontece; o comando `/worktree` roda o mesmo script (claim incluso, sem launch). Guard `-B` contra reset de commits de sessão no reopen. Pool intacto (`claimIssueForPool` coordenado). Docs: AGENTS.md ("Per-worktree environments"), header/uso do script, `.opencode/commands/worktree.md`, skill `worktree-next-issue` (sai "read-only"), AGENT-OPS. Sem migration/dados/access/UI. Intenção [`worktree-next-claima-issue-deterministicamente.md`](plans/worktree-next-claima-issue-deterministicamente.md); impl [`worktree-next-claima-issue-deterministicamente-impl.md`](plans/worktree-next-claima-issue-deterministicamente-impl.md). diff --git a/docs/plans/container-postgres-compartilhado-compose-replace-impl.md b/docs/plans/container-postgres-compartilhado-compose-replace-impl.md new file mode 100644 index 00000000..fcf3387b --- /dev/null +++ b/docs/plans/container-postgres-compartilhado-compose-replace-impl.md @@ -0,0 +1,86 @@ +# Impl: Container Postgres compartilhado é recriado por outro worktree — mata dev+e2e de todos em voo + +Status: aprovado +Atualizado em: 2026-08-10 +Issue: #605 +Intenção: docs/plans/container-postgres-compartilhado-compose-replace.md +Appetite restante: herdado (~0,5 dia eng; um outcome verificável) + +## Leitura da intenção + +- **Outcome:** `pnpm db:start` com `teqo-postgres-1` saudável é no-op (não recria, não derruba conexões de outros worktrees); recriação legítima segue possível por comando explícito; nada de CI/produção muda. +- **O que NÃO negociar:** sem serialização/locks de agentes; sem tocar em containers de outros projetos; fix no **owner** do container (`db:start`), não na educação dos agentes. +- **O que reavaliar:** a pergunta em aberto da intenção — "por que o config_hash divergiu?" — foi **respondida com evidência** (ver abaixo): é o bind mount relativo `./docker/postgres/init`, único campo que difere entre worktrees. Isto descarta o fix alternativo "pinar o compose para não depender do workdir" como frágil; o fix no `db:start` cobre o mecanismo inteiro. + +## Diagnóstico (verificado em 2026-08-10) + +`docker-compose.yml` linha 27: `- ./docker/postgres/init:/docker-entrypoint-initdb.d:ro` — bind mount **relativo**, resolvido por compose contra o diretório do projeto (o root do worktree). Medido: + +- `docker compose -p teqo config --hash postgres` → OPS37: `39e89956…`; B195: `cf3cb522…`; C101: `917e11bb…` — três worktrees, três hashes. +- `docker compose -p teqo config` diff OPS37×B195: **única** diferença é `source:` do bind mount (path absoluto do worktree). +- Container atual roda com o hash do OPS37 (`com.docker.compose.config-hash` label) — qualquer `db:start` de outro worktree → hash difere → compose faz `replace` → `terminating connection due to administrator command` para todos. + +O bind mount só importa na **primeira** inicialização do volume (`01-create-test-db.sql` cria `teqo_test`); depois é letra morta — mas compose não sabe disso e o inclui no hash. + +## Abordagem recomendada + +```mermaid +flowchart LR + A[pnpm db:start / worktree provisioning] --> B{container Up + healthy?} + B -- sim --> C[no-op: nada a recriar] + B -- não / --force-recreate --> D[compose -p teqo up -d --wait postgres] + D --> E[container de pé, nenhuma conexão viva foi derrubada] +``` + +**Opções consideradas:** A | B | C +**Recomendação:** **A** — preflight "container saudável → skip do compose" no owner `db:start` (novo `scripts/db-start.mjs` + lib compartilhada usada também pelo provisioning do worktree). Porque: ataca exatamente o caso perigoso (recriar container **de pé** com conexões vivas); quando o container não está de pé (missing/exited), compose up é seguro e continua o único caminho; dá mensagem clara ao operador; cobre os dois call sites do compose (`db:start` e `dockerComposeUp` do `worktree.mjs`, que tem o MESMO risco de replace durante `pnpm worktree next`). +**Rejeitadas:** + +- **B — "pinar o bind mount"** (ex.: path absoluto para o main repo): resolveria o hash, mas injeta path do host no compose commitado — quebra portabilidade (main repo pode morar em outro caminho/máquina) e o bind é read-only só usado no primeiro init; se o path sumir, o container sobe com init vazio em silêncio. Frágil demais para um problema de 1 linha no script. +- **C — só `--no-recreate` no compose up**: comportamento correto no skip, mas silencioso — quando o config muda de verdade (bump de imagem), o operador não descobre que o container está stale, e o caminho explícito fica menos óbvio; além disso não dá diagnóstico quando algo mais está errado (container unhealthy). O preflight de A é o `--no-recreate` com luzes. +- ~~Remover o bind mount e criar `teqo_test` via `docker exec createdb` pós-start~~: espalha o bootstrap, quebra a garantia "só no primeiro init do volume", e a mudança de compose toca todos os worktrees de uma vez. + +### Componentes / mudanças + +- **`scripts/lib/db-start.mjs`** (novo): núcleo compartilhado — + - `shouldSkipStart({ running, health })` → puro: `true` quando `running && health ∈ {healthy, starting}` (skip; `starting` evita o race de substituir um container que um worktree vizinho acabou de criar). + - `parsePostgresContainerHealth(jsonText)` → puro: `docker inspect --format '{{json .State}}'` → `{ running, health }` (`health` vazio = container antigo sem healthcheck → **não** skip: compose up migra de volta para a config atual). + - `waitForHealthyPostgres({ timeoutMs, intervalMs })` → espera um container `starting` ficar `healthy` (bounded 60s/2s; timeouts injetáveis para os testes); `false` se sumir/virar `unhealthy`/estourar o tempo → cai no compose up. + - `startSharedPostgres({ cwd, forceRecreate })` → async: inspeciona `teqo-postgres-1`; skip com mensagem quando `healthy` (ou espera o boot de um `starting`) e `!forceRecreate`; senão `docker compose -p teqo up -d [--force-recreate] --wait --wait-timeout 120 postgres` com `cwd` (a lib não assume o root — o provisioning passa o dir do worktree). Docker inacessível → lança (o CLI imprime remédio; o `worktree.mjs` já try/catch → fallback env). +- **`scripts/db-start.mjs`** (novo, CLI): `--force-recreate` (recriação explícita) e `--help`; args desconhecidos → usage + exit 2; cwd = `process.cwd()` (compose file do worktree atual); erro de docker → mensagem estilo `db-doctor` ("Docker looks unreachable — is Docker running?") + exit 1. Sem `--force-recreate`, container saudável → `[db] teqo-postgres-1 already up (healthy) — nothing to recreate.` exit 0. +- **`package.json`**: `"db:start": "node scripts/db-start.mjs"` (substitui o raw compose up). `db:stop`/`db:doctor` intactos. +- **`scripts/worktree.mjs`**: `dockerComposeUp` inlined — o `provision()` chama `await startSharedPostgres({ cwd: dir })` direto — mesmo preflight no provisioning (idempotência de `worktree next`); `SHARED_COMPOSE_PROJECT` órfão removido (agora const privada da lib). +- **`tests/unit/dbStart.unit.spec.ts`** (novo): `shouldSkipStart` (matrix running×health), `parsePostgresContainerHealth` (healthy/starting/unhealthy/sem healthcheck/Health null/malformado), e o wrapper `startSharedPostgres`/`waitForHealthyPostgres` com `vi.mock('node:child_process')` (skip não chama compose; container ausente → compose up com argv correto; `--force-recreate` bypassa; `starting` → espera → healthy; `starting` travado/unhealthy/sumiu → false) — seguindo o padrão de `worktree.unit.spec.ts` (importa direto de `scripts/lib/*.mjs`, `// @vitest-environment node`). +- **Docs:** `AGENTS.md` → bullet "Database & local development": `db:start` é idempotente (no-op com container saudável; `pnpm db:start --force-recreate` para recriação explícita). `docs/CHANGELOG-AGENTS.md` → uma entrada curta. +- **Migration:** nenhuma. **Access/Consent:** n/a. **UI:** n/a. + +### Dados → forma (se aplicável) + +n/a — sem dados novos; o contrato `StartedAt` inalterado é a métrica de aceite (verificação com `docker inspect`). + +## Fases verificáveis + +1. **Núcleo + wiring** — `scripts/lib/db-start.mjs` + `scripts/db-start.mjs` + `package.json` + delegação em `worktree.mjs`. Verificação: `pnpm db:start` com container saudável imprime o no-op e `docker inspect teqo-postgres-1 --format '{{.State.StartedAt}}'` fica **igual** antes/depois; `docker compose -p teqo config --hash postgres` do worktree == hash do container (estado atual já é esse). +2. **Unit tests** — `tests/unit/dbStart.unit.spec.ts` (17 casos: matrix pura + wrapper mockado); rodar via `pnpm test:unit` (ou o alvo unit). +3. **Gates** — `pnpm exec tsc --noEmit`, `pnpm lint`, `pnpm format:check`, `pnpm exec knip`, `pnpm check:cycles`, unit/int. `--force-recreate` é verificado pela flag passando ao compose (execução live opcional, avisando o humano — recriar derruba sessões de outros worktrees por ~2s). + +## Rabbit holes / Não escopo (engenharia) + +- Não mexer em `docker-compose.yml` (mantém compat: qualquer worktree continua podendo rodar compose up cru — agora inofensivo para os outros? **não**: compose up cru de outro worktree ainda recria. O escopo é o owner (`db:start`/provisioning); compose up manual é caminho do operador, e o `--force-recreate`/docs o tornam explícito). +- Não tocar em `db:stop` (parada explícita é intenção do operador). +- Não serializar/lockar agentes (anti-goal da intenção). +- Race raro: container ausente e dois worktrees rodam `db:start` simultâneo → ambos sobem compose; o segundo pode recriar o primeiro (sem conexões vivas ainda — inofensivo). Documentado, não corrigido. + +## Riscos e mitigação + +- **Container `starting` de um peer**: o `startSharedPostgres` espera o boot ficar `healthy` (60s, polling 2s) em vez de recriar; se travou (nunca healthy), cai no compose up com `--wait-timeout 120` — falha alta, sem hang infinito. `--force-recreate`/`db:doctor` seguem como escape. +- **Container antigo sem healthcheck**: `health` vazio → não skip → compose up migra para a config atual (uma recria única, sem conexões? pode ter — caso raro e legítimo; o plano da intenção aceita recriação quando a config mudou de verdade). +- **`worktree next` com docker inacessível**: comportamento preservado (throw → try/catch → fallback env). +- **Knip/águeiro**: `scripts/db-start.mjs` é entry point (não exporta nada); `scripts/lib/db-start.mjs` tem consumidores reais (`db-start.mjs` + `worktree.mjs` + o spec) — nada órfão. + +## Aceite de engenharia + +- [x] Aceite de produto da intenção ainda coberto (no-op com saudável; recreate explícito; sem CI/prod; sem locks) +- [x] Invariantes AGENTS/engineering-standards (idempotência; sem path absoluto no compose; owner do container) +- [x] Testes de domínio previstos: unit das decisões puras + wrapper mockado (17 casos) +- [x] Verificação live: `StartedAt` inalterado após `pnpm db:start` repetido (inclusive de outro worktree com hash diferente) diff --git a/package.json b/package.json index b6314074..660d0c83 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "type": "module", "scripts": { "build": "cross-env NODE_OPTIONS=\"--no-deprecation --conditions=react-server\" payload migrate && cross-env NODE_OPTIONS=\"--no-deprecation --max-old-space-size=8000\" next build", - "db:start": "docker compose -p teqo up -d --wait postgres", + "db:start": "node scripts/db-start.mjs", "db:stop": "docker compose -p teqo down", "db:doctor": "node scripts/db-doctor.mjs", "db:pull": "node scripts/db-pull.mjs", diff --git a/scripts/db-start.mjs b/scripts/db-start.mjs new file mode 100644 index 00000000..34d84dce --- /dev/null +++ b/scripts/db-start.mjs @@ -0,0 +1,55 @@ +/** + * `pnpm db:start` — starts the ONE shared local Postgres container + * (`compose -p teqo`), or does nothing when it is already up and healthy. + * + * The no-op guard matters because the container is shared by every worktree + * (see scripts/lib/db-start.mjs): a plain `compose up` from another worktree + * recreates it and kills every live dev/e2e connection. Recreate on purpose: + * `pnpm db:start --force-recreate` (image bump / config change). + */ + +import { pathToFileURL } from 'node:url' + +import { startSharedPostgres } from './lib/db-start.mjs' + +const USAGE = `Usage: pnpm db:start [--force-recreate] + +Starts the shared local Postgres container (compose project "teqo"). +No-op when the container is already up and healthy. + +Options: + --force-recreate Recreate the container even when it is healthy + (apply a real compose/image change on purpose). + -h, --help Show this help.` + +const main = async () => { + const args = process.argv.slice(2) + if (args.includes('--help') || args.includes('-h')) { + console.log(USAGE) + process.exit(0) + } + const unknown = args.filter((arg) => arg !== '--force-recreate') + if (unknown.length > 0) { + console.error(`[db] Unknown option: ${unknown.join(', ')}`) + console.error(USAGE) + process.exit(2) + } + const forceRecreate = args.includes('--force-recreate') + + try { + await startSharedPostgres({ cwd: process.cwd(), forceRecreate }) + } catch (error) { + if (error.code === 'ENOENT') { + console.error('[db] Docker looks unreachable — is Docker running?') + console.error('[db] Start Docker and retry: pnpm db:start') + } else { + const message = error.stderr?.toString()?.trim() || error.message + console.error(`[db] Failed to start the local Postgres: ${message.split('\n')[0]}`) + } + process.exit(1) + } +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) { + await main() +} diff --git a/scripts/lib/db-start.mjs b/scripts/lib/db-start.mjs new file mode 100644 index 00000000..68afc264 --- /dev/null +++ b/scripts/lib/db-start.mjs @@ -0,0 +1,136 @@ +/** + * Starts the ONE shared local Postgres container (`compose -p teqo`) from any + * worktree, WITHOUT recreating it when it is already up and serving. + * + * Why the guard exists (OPS37, 2026-08-10): docker-compose.yml binds the + * init scripts via a RELATIVE path (`./docker/postgres/init`), so compose + * resolves a different absolute mount source per worktree and computes a + * different `config_hash` for the same pinned project. A plain + * `docker compose -p teqo up` from any other worktree therefore decides the + * running container is stale and REPLACES it — killing every live connection + * (dev servers and e2e runs of ALL worktrees die with `terminating connection + * due to administrator command`). The bind mount only matters on the FIRST + * volume initialization; after that it is dead weight in the hash. + * + * The container must be immutable while healthy: `db:start` and worktree + * provisioning skip compose entirely when `teqo-postgres-1` is running and + * healthy — or, when a peer worktree is still booting it (`starting`), wait + * for that boot instead of replacing the container mid-healthcheck. + * Legitimate recreation (image bump, config change) stays possible via an + * explicit `--force-recreate`. + */ + +import { execFileSync } from 'node:child_process' + +/** Fixed compose project so `db:start`/provisioning always target ONE container. */ +const SHARED_COMPOSE_PROJECT = 'teqo' + +/** Compose names the project's service container `--1`. */ +const SHARED_POSTGRES_CONTAINER = 'teqo-postgres-1' + +/** How long to wait for a peer's `starting` container before falling back to compose. */ +const STARTING_WAIT_MS = 60_000 + +/** Poll interval while waiting for a `starting` container. */ +const STARTING_POLL_INTERVAL_MS = 2_000 + +/** + * Pure decision: skip compose entirely when the shared container is already + * up and serving (`healthy`), or being brought up by a peer worktree right + * now (`starting` — replacing it then would be a pointless race; we wait for + * that boot instead). Anything else (stopped, unhealthy, no healthcheck) + * falls through to compose up, which is safe: no live connections exist to + * kill, and a pre-healthcheck container gets migrated back to the current + * config. + * + * @returns {boolean} true when compose must NOT run. + */ +export const shouldSkipStart = ({ running, health }) => + running === true && (health === 'healthy' || health === 'starting') + +/** + * Parse `docker inspect --format '{{json .State}}'` output into + * `{ running, health }`. `health` is empty when the container predates the + * compose healthcheck (no `Health` object) — that never skips, so `db:start` + * brings the container back to the current config exactly once. + * + * @returns {{ running: boolean, health: string } | null} null on unparseable output. + */ +export const parsePostgresContainerHealth = (jsonText) => { + try { + const state = JSON.parse(jsonText) + return { + running: state.Running === true, + health: typeof state.Health?.Status === 'string' ? state.Health.Status : '', + } + } catch { + return null + } +} + +/** + * Inspect the shared container's state. Returns null when it does not exist + * (compose will create it). Throws when Docker itself is unreachable — the + * caller decides the remedy (CLI prints one; worktree provisioning falls back + * to the shared databases). + * + * @returns {{ running: boolean, health: string } | null} + */ +const inspectSharedPostgres = () => { + try { + const output = execFileSync( + 'docker', + ['inspect', '--format', '{{json .State}}', SHARED_POSTGRES_CONTAINER], + { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }, + ) + return parsePostgresContainerHealth(output) + } catch (error) { + const stderr = error.stderr?.toString() ?? '' + if (stderr.includes('No such object')) return null + throw error + } +} + +/** + * Wait for a `starting` container to become `healthy`, bounded. Returns true + * only when it is serving; false when it disappears, turns `unhealthy`, or + * the timeout elapses — callers then fall back to compose up (no live + * connections exist to kill on a container that never became healthy). + * Timeouts are injectable so unit tests run without real waits. + */ +export const waitForHealthyPostgres = async ({ + timeoutMs = STARTING_WAIT_MS, + intervalMs = STARTING_POLL_INTERVAL_MS, +} = {}) => { + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, intervalMs)) + const state = inspectSharedPostgres() + if (!state) return false + if (state.health === 'healthy') return true + if (state.health === 'unhealthy') return false + } + return false +} + +/** + * Start (or reuse) the shared Postgres container from `cwd` — a no-op when it + * is already up and serving, unless `forceRecreate` explicitly asks for a + * recreate (image bump / config change). Creates the container when missing, + * starts it when stopped, and waits for a peer's fresh container instead of + * replacing it mid-healthcheck — always with `--wait` for the healthcheck. + */ +export const startSharedPostgres = async ({ cwd, forceRecreate = false }) => { + const state = inspectSharedPostgres() + if (!forceRecreate && state && shouldSkipStart(state)) { + if (state.health === 'healthy' || (await waitForHealthyPostgres())) { + console.log(`[db] ${SHARED_POSTGRES_CONTAINER} already up (healthy) — nothing to recreate.`) + return + } + } + + const args = ['compose', '-p', SHARED_COMPOSE_PROJECT, 'up', '-d'] + if (forceRecreate) args.push('--force-recreate') + args.push('--wait', '--wait-timeout', '120', 'postgres') + execFileSync('docker', args, { cwd, stdio: 'inherit' }) +} diff --git a/scripts/worktree.mjs b/scripts/worktree.mjs index 59719960..4ac1a121 100644 --- a/scripts/worktree.mjs +++ b/scripts/worktree.mjs @@ -111,6 +111,7 @@ import { nextClaimableIssue, parseArgs, } from './lib/agent-github.mjs' +import { startSharedPostgres } from './lib/db-start.mjs' import { DEV_PORT_BASE, GENERATED_ENV_MARKER, @@ -143,8 +144,6 @@ const printLaunchDirective = ({ dir, purpose, issueNumber }) => { if (line) console.log(line) } -/** Fixed compose project so `db:start`/provisioning always target ONE container. */ -const SHARED_COMPOSE_PROJECT = 'teqo' /** Maintenance database on the shared local container — only ever localhost. */ const MAINTENANCE_DATABASE_URL = 'postgresql://teqo:teqo@localhost:5432/postgres' @@ -221,15 +220,6 @@ const envFileIsGenerated = (dir, name) => { } } -/** Start (or reuse) the ONE shared Postgres container from any worktree dir. */ -const dockerComposeUp = (dir) => { - execFileSync( - 'docker', - ['compose', '-p', SHARED_COMPOSE_PROJECT, 'up', '-d', '--wait', 'postgres'], - { cwd: dir, stdio: 'inherit' }, - ) -} - /** `CREATE DATABASE` for each missing generated name on the shared container. */ const ensureDatabases = async (names) => { const client = new pg.Client({ @@ -332,7 +322,8 @@ const provision = async ({ dir, branch, issue, env, skipMigrate, mainRoot, purpo const payloadSecret = mainEnv.PAYLOAD_SECRET ?? randomBytes(24).toString('hex') try { - dockerComposeUp(dir) + // Start (or reuse) the ONE shared Postgres container from any worktree dir. + await startSharedPostgres({ cwd: dir }) } catch (error) { console.warn('[worktree] Docker indisponível — usando o banco compartilhado (teqo/teqo_test).') console.warn(`[worktree] (${error.message.split('\n')[0]})`) diff --git a/tests/unit/dbStart.unit.spec.ts b/tests/unit/dbStart.unit.spec.ts new file mode 100644 index 00000000..ad84f101 --- /dev/null +++ b/tests/unit/dbStart.unit.spec.ts @@ -0,0 +1,180 @@ +// @vitest-environment node + +import { beforeEach, describe, expect, it, vi } from 'vitest' + +type ExecArgs = [command: string, args: string[], options: { cwd: string }] + +const execFileSyncMock = vi.fn<(...args: ExecArgs) => unknown>() + +vi.mock('node:child_process', () => ({ + execFileSync: (...args: ExecArgs) => execFileSyncMock(...args), +})) + +import { + parsePostgresContainerHealth, + shouldSkipStart, + startSharedPostgres, + waitForHealthyPostgres, +} from '../../scripts/lib/db-start.mjs' + +/** The `docker inspect --format '{{json .State}}'` shape, not a custom one. */ +const stateJson = ({ running = true, health = 'healthy' } = {}) => + JSON.stringify({ + Status: running ? 'running' : 'exited', + Running: running, + ...(health ? { Health: { Status: health } } : {}), + }) + +/** Throws exactly what docker does when the container does not exist. */ +const throwNoSuchObject = () => { + const error = new Error('No such object') as Error & { stderr: Buffer } + error.stderr = Buffer.from('No such object: teqo-postgres-1') + throw error +} + +describe('shouldSkipStart', () => { + it('skips when the container is running and healthy', () => { + expect(shouldSkipStart({ running: true, health: 'healthy' })).toBe(true) + }) + + it('skips when the container is running and still starting (peer just created it)', () => { + expect(shouldSkipStart({ running: true, health: 'starting' })).toBe(true) + }) + + it('does not skip when the container is unhealthy', () => { + expect(shouldSkipStart({ running: true, health: 'unhealthy' })).toBe(false) + }) + + it('does not skip when the container predates the healthcheck (no Health object)', () => { + expect(shouldSkipStart({ running: true, health: '' })).toBe(false) + }) + + it('does not skip when the container is stopped', () => { + expect(shouldSkipStart({ running: false, health: 'healthy' })).toBe(false) + }) +}) + +describe('parsePostgresContainerHealth', () => { + it('parses a running healthy container', () => { + expect(parsePostgresContainerHealth(stateJson({ health: 'healthy' }))).toEqual({ + running: true, + health: 'healthy', + }) + }) + + it('parses a stopped container without a Health object', () => { + expect(parsePostgresContainerHealth(stateJson({ running: false, health: '' }))).toEqual({ + running: false, + health: '', + }) + }) + + it('parses a running container without a Health object (legacy pre-healthcheck container)', () => { + expect(parsePostgresContainerHealth(stateJson({ health: '' }))).toEqual({ + running: true, + health: '', + }) + }) + + it('treats an explicit null Health as no healthcheck', () => { + expect( + parsePostgresContainerHealth('{"Status":"running","Running":true,"Health":null}'), + ).toEqual({ + running: true, + health: '', + }) + }) + + it('returns null on unparseable output', () => { + expect(parsePostgresContainerHealth('not json')).toBeNull() + expect(parsePostgresContainerHealth('')).toBeNull() + }) +}) + +describe('startSharedPostgres', () => { + beforeEach(() => { + execFileSyncMock.mockReset() + }) + + const composeArgs = () => + execFileSyncMock.mock.calls.find((call) => call[1][0] === 'compose')?.[1] + + it('is a no-op when the container is already healthy (never touches compose)', async () => { + execFileSyncMock.mockReturnValue(stateJson({ health: 'healthy' })) + await startSharedPostgres({ cwd: '/repo' }) + expect(execFileSyncMock.mock.calls).toHaveLength(1) + expect(execFileSyncMock.mock.calls[0][1]).toEqual([ + 'inspect', + '--format', + '{{json .State}}', + 'teqo-postgres-1', + ]) + }) + + it('runs compose up when the container does not exist', async () => { + execFileSyncMock.mockImplementationOnce(throwNoSuchObject).mockReturnValueOnce('') + await startSharedPostgres({ cwd: '/repo' }) + expect(composeArgs()).toEqual([ + 'compose', + '-p', + 'teqo', + 'up', + '-d', + '--wait', + '--wait-timeout', + '120', + 'postgres', + ]) + const composeCall = execFileSyncMock.mock.calls.find((call) => call[1][0] === 'compose') + expect(composeCall?.[2].cwd).toBe('/repo') + }) + + it('force-recreate bypasses the healthy skip', async () => { + execFileSyncMock.mockReturnValue(stateJson({ health: 'healthy' })).mockReturnValueOnce('') + await startSharedPostgres({ cwd: '/repo', forceRecreate: true }) + expect(composeArgs()).toEqual([ + 'compose', + '-p', + 'teqo', + 'up', + '-d', + '--force-recreate', + '--wait', + '--wait-timeout', + '120', + 'postgres', + ]) + }) +}) + +describe('waitForHealthyPostgres', () => { + beforeEach(() => { + execFileSyncMock.mockReset() + }) + + it('waits for a starting container and returns true when it becomes healthy', async () => { + execFileSyncMock + .mockReturnValueOnce(stateJson({ health: 'starting' })) + .mockReturnValue(stateJson({ health: 'healthy' })) + expect(await waitForHealthyPostgres({ timeoutMs: 50, intervalMs: 1 })).toBe(true) + }) + + it('falls back (false) when a starting container never becomes healthy', async () => { + execFileSyncMock.mockReturnValue(stateJson({ health: 'starting' })) + expect(await waitForHealthyPostgres({ timeoutMs: 10, intervalMs: 1 })).toBe(false) + }) + + it('falls back (false) when the container turns unhealthy', async () => { + execFileSyncMock + .mockReturnValueOnce(stateJson({ health: 'starting' })) + .mockReturnValue(stateJson({ health: 'unhealthy' })) + expect(await waitForHealthyPostgres({ timeoutMs: 50, intervalMs: 1 })).toBe(false) + }) + + it('falls back (false) when the container disappears', async () => { + execFileSyncMock + .mockReturnValueOnce(stateJson({ health: 'starting' })) + .mockImplementation(throwNoSuchObject) + expect(await waitForHealthyPostgres({ timeoutMs: 50, intervalMs: 1 })).toBe(false) + }) +})