mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-12 22:13:05 +00:00
fix(OPS37): db:start idempotente — não recria o container Postgres compartilhado (no-op quando saudável; --force-recreate explícito)
This commit is contained in:
parent
f44254cea5
commit
7c17d43723
8 changed files with 464 additions and 14 deletions
File diff suppressed because one or more lines are too long
|
|
@ -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:<base>` 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 <N>` 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 <N>` (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).
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
55
scripts/db-start.mjs
Normal file
55
scripts/db-start.mjs
Normal file
|
|
@ -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()
|
||||
}
|
||||
136
scripts/lib/db-start.mjs
Normal file
136
scripts/lib/db-start.mjs
Normal file
|
|
@ -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 `<project>-<service>-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' })
|
||||
}
|
||||
|
|
@ -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]})`)
|
||||
|
|
|
|||
180
tests/unit/dbStart.unit.spec.ts
Normal file
180
tests/unit/dbStart.unit.spec.ts
Normal file
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue