From 497e5029f121e5f55c47971476c379e146425512 Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Fri, 25 Sep 2026 11:55:48 -0300 Subject: [PATCH] =?UTF-8?q?fix(C225):=20fecha=20guard=20e=20runbook=20da?= =?UTF-8?q?=20ingest=C3=A3o=20web?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .agents/skills/catalogo-falas-web/SKILL.md | 5 + docs/changelog/2026-09-25-c225.md | 1 + docs/ops/teqo-1313-deploy.md | 375 +++++++++++++++++- .../acervo-falas-internet-producao-impl.md | 272 +++++++++++++ scripts/import-web-speeches.mjs | 20 +- scripts/lib/cli.mjs | 7 + tests/unit/cliEnvFlags.unit.spec.ts | 67 ++++ tests/unit/importWebSpeechesCli.unit.spec.ts | 38 ++ 8 files changed, 777 insertions(+), 8 deletions(-) create mode 100644 docs/changelog/2026-09-25-c225.md create mode 100644 docs/plans/acervo-falas-internet-producao-impl.md create mode 100644 tests/unit/importWebSpeechesCli.unit.spec.ts diff --git a/.agents/skills/catalogo-falas-web/SKILL.md b/.agents/skills/catalogo-falas-web/SKILL.md index 3ca6a399..c4f2e779 100644 --- a/.agents/skills/catalogo-falas-web/SKILL.md +++ b/.agents/skills/catalogo-falas-web/SKILL.md @@ -180,6 +180,11 @@ item inválido para o C215 não se resolve por `aprovar`): fora do escopo do C215); - metadados que o agente não conseguiu confirmar. +Para `youtube` e `instagram`, confirme sempre `externalId` no lote curado. A +identidade do C215 usa `web::`; sem esse +campo, a mesma URL pode abrir uma chave diferente em outra rodada. Confira o +`sourceKey` no relatório do `--dry-run` antes de ingerir. + O que está em `review` não é ingerido até confirmação (`aprovar`). Modes (numeração única da listagem de `revisar` — `pending` primeiro, depois diff --git a/docs/changelog/2026-09-25-c225.md b/docs/changelog/2026-09-25-c225.md new file mode 100644 index 00000000..fab99169 --- /dev/null +++ b/docs/changelog/2026-09-25-c225.md @@ -0,0 +1 @@ +**C225 — operação de ingestão das falas da internet no acervo de produção (2026-09-25):** a escrita não-local de `pnpm falas-web:import` passou a recusar, antes de abrir o Payload, a ausência de mídia espelhada: o guard reutiliza a decisão de alvo de `scripts/lib/cli.mjs` e exige as quatro variáveis `S3_*` quando o alvo exige confirmação; `--dry-run` e o caminho local seguem inalterados. O runbook `docs/ops/teqo-1313-deploy.md` agora documenta a rodada manual no homeserver: workspace separado, estado durável fora do clone, cookies revogáveis e wrapper de `yt-dlp`, reescritas de Postgres/Garage, preflight, smoke limitado, lote, idempotência, inventário, honestidade de falhas e rollback pelo Payload. A curadoria C218 passou a exigir `externalId` para YouTube/Instagram e conferência do `sourceKey` no dry-run. Esta entrega não executou escrita em produção, não adicionou migration/UI/agendamento e o resultado da primeira rodada permanece para o operador registrar. diff --git a/docs/ops/teqo-1313-deploy.md b/docs/ops/teqo-1313-deploy.md index 16bc49c6..e36ad321 100644 --- a/docs/ops/teqo-1313-deploy.md +++ b/docs/ops/teqo-1313-deploy.md @@ -107,11 +107,11 @@ aprovação, produção. ## Onde roda cada coisa -| Máquina | Papel | -| -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **GitHub hosted** (ubuntu) | CI de PR (`ci-pr.yml`, job `checks`) e job `verify` do `deploy.yml` (suíte full). Nunca toca o homeserver. | -| **homeserver** (8c/16GB) | todo o stack (`~/stack/docker-compose.yml`): postgres `teqo_1313`, forgejo, registry `localhost:5000`, cloudflared, `teqo-1313` na 127.0.0.1:1313 e `teqo-staging` na 127.0.0.1:1314 (OPS103; DB `teqo_staging`, env `~/stack/teqo-staging.env`, bucket `teqo-media-staging`); **runner self-hosted do GitHub** (labels `self-hosted`, `homeserver`; instalado 2026-08-19 — Issue #113 OPS71-INFRA; `~/actions-runner` v2.336.0, systemd user `actions.runner.fsolla-teqo.teqo-1313-runner.service` — `systemctl --user status/restart`); workspace `~/teqo-deploy` (clone de `github.com/fsolla/teqo`, público) | -| **workstation** | dev/agentes apenas — o runner do Forgejo foi **desligado** no cutover (o schedule do `ci.yml` antigo não tem mais razão; religar é reversível) | +| Máquina | Papel | +| -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **GitHub hosted** (ubuntu) | CI de PR (`ci-pr.yml`, job `checks`) e job `verify` do `deploy.yml` (suíte full). Nunca toca o homeserver. | +| **homeserver** (8c/16GB) | todo o stack (`~/stack/docker-compose.yml`): postgres `teqo_1313`, forgejo, registry `localhost:5000`, cloudflared, `teqo-1313` na 127.0.0.1:1313 e `teqo-staging` na 127.0.0.1:1314 (OPS103; DB `teqo_staging`, env `~/stack/teqo-staging.env`, bucket `teqo-media-staging`); **runner self-hosted do GitHub** (labels `self-hosted`, `homeserver`; instalado 2026-08-19 — Issue #113 OPS71-INFRA; `~/actions-runner` v2.336.0, systemd user `actions.runner.fsolla-teqo.teqo-1313-runner.service` — `systemctl --user status/restart`); workspace `~/teqo-deploy` (clone de `github.com/fsolla/teqo`, público); o clone operacional da C225 `~/teqo-falas-web` é separado e guarda somente código/dependências, com estado durável em `~/falas-web` | +| **workstation** | dev/agentes apenas — o runner do Forgejo foi **desligado** no cutover (o schedule do `ci.yml` antigo não tem mais razão; religar é reversível) | Segredos **não** ficam no GitHub: o script sourceia o env file do ambiente (`~/stack/teqo-1313.env` em produção, `~/stack/teqo-staging.env` em staging) e @@ -767,6 +767,371 @@ DELETE FROM "speech" WHERE "legislature" = '55'; Para desfazer o acervo inteiro: `DELETE FROM "speech_segment"; DELETE FROM "speech";` (a cobertura `--coverage` volta a zero). Reexecutar `--all` reconstrói. +## C225 — rodada de falas da internet no acervo de produção + +A fonte **Falas na internet** do acervo (C216) já tem busca, player, transcrição e +origem, mas a produção só recebe fala web quando alguém executa esta operação. +A ingestão é a esteira do C215 (`pnpm falas-web:import`); a descoberta e a +curadoria são do C218. Esta seção é o procedimento manual, repetível e +reversível para rodar um lote contra `teqo_1313` e o bucket privado +`teqo-media`. O guard `FALAS_WEB_IMPORT_CONFIRM=1` continua sendo um ato +humano: nenhum agente, workflow ou skill o exporta. + +**Estado desta entrega (2026-09-25):** o guard e este runbook foram entregues; +esta entrega não executou a rodada de produção. A data, o lote, os criados, +pulados, falhas, custos e o caminho dos relatórios são preenchidos por quem +operar depois do merge. + +### Responsabilidades e limites + +- A descoberta/curadoria pode ser feita na workstation, mas a cópia do lote e o + estado operacional devem terminar no diretório durável do homeserver. O + `last-run.json` do host é a fonte de verdade da próxima janela. +- O CLI é a única etapa que baixa, transcreve, classifica, espelha e deduplica. + Não crie cron, fila, collection de estado, migration ou uma segunda esteira. +- O acervo da Câmara (`origin = camara`, 997 discursos no baseline registrado em + 2026-09-13/14) não é reprocessado por esta rodada. +- O download temporário pode existir durante o processamento em `/tmp`; o + destino da mídia espelhada é o bucket privado. O temp é removido pelo `finally` + do pipeline. Não use um diretório local como fila de upload. +- `internetSpeechMedia` continua privada e servida pelo acervo autenticado. Não + copie a URL do bucket para um canal público. + +### Pré-requisitos + +1. O commit que contém C215, C216, C218, C223, C224 e C225 está em produção. +2. No homeserver há Node 24 (`source ~/.nvm/nvm.sh`), `pnpm`, acesso ao GitHub, + `flock`, `curl`, `psql` e espaço para o clone e os temporários do download. +3. `~/stack/.env` contém `DEEPINFRA_API_KEY` e `DEEPSEEK_API_KEY`; a primeira + chave faz a transcrição e a segunda a classificação. `~/stack/teqo-1313.env` + contém `DATABASE_URL`, `PAYLOAD_SECRET`, `NODE_ENV=production` e as quatro + variáveis `S3_*` do bucket. Não copie esses arquivos para o repositório. +4. O `yt-dlp` instalado funciona no host e tem `yt-dlp-ejs` disponível. A + resolução do CLI usa `YTDLP_PATH → PATH`; o CLI também valida `ffmpeg` e usa + o binário empacotado do projeto como fallback. +5. A pessoa responsável tem um arquivo de cookies Netscape revogável para as + plataformas que exigirem sessão. O arquivo nunca é commitado, impresso nem + enviado ao Issue. + +### Workspace e estado durável + +Use dois diretórios diferentes: + +- `~/teqo-falas-web`: clone descartável, pinado no SHA que será executado; não é + o workspace do runner (`~/teqo-deploy`) e não guarda o watermark. +- `~/falas-web`: estado, lotes, descobertas, relatórios e logs duráveis da + operação, fora do clone e do diretório gitignored da workstation. + +```bash +set -euo pipefail +export WORKTREE="$HOME/teqo-falas-web" +export FALAS_WEB_DIR="$HOME/falas-web" +export EXPECTED_SHA="" +install -d -m 700 "$FALAS_WEB_DIR" "$FALAS_WEB_DIR/reports" "$FALAS_WEB_DIR/runs" +if [ ! -d "$WORKTREE/.git" ]; then + git clone https://github.com/fsolla/teqo.git "$WORKTREE" +fi +cd "$WORKTREE" +git fetch origin +git checkout --detach "$EXPECTED_SHA" +test "$(git rev-parse HEAD)" = "$EXPECTED_SHA" +pnpm install --frozen-lockfile +``` + +Se o C218 for executado dentro desse clone, aponte o diretório gitignored para o +estado durável uma única vez, sem apagar um diretório existente sem inspeção: + +```bash +set -euo pipefail +if [ -L "$WORKTREE/data/falas-web" ]; then + test "$(readlink -f "$WORKTREE/data/falas-web")" = "$(readlink -f "$FALAS_WEB_DIR")" +elif [ -e "$WORKTREE/data/falas-web" ]; then + printf '%s\n' 'data/falas-web existe e não é o symlink durável esperado.' >&2 + exit 1 +else + mkdir -p "$WORKTREE/data" + ln -s "$FALAS_WEB_DIR" "$WORKTREE/data/falas-web" +fi +``` + +Se a descoberta ocorrer na workstation, copie para `~/falas-web` apenas o +artefato `discovery-*.json` e o lote `batch-*.json`. O `last-run.json` do host é +o dono do estado: não sobrescreva o watermark dele com uma cópia mais antiga da +workstation. Se o host ainda não tiver estado, a primeira rodada é a varredura +inicial; caso já exista, o operador incorpora somente o lote pendente ao estado +durável. Não use `data/falas-web` local da workstation como estado de produção. +O contrato do estado é o do C218: `lastRunAt` só avança depois de uma importação +com exit 0; falhas ficam em `pending` com `attempts`; itens duvidosos ficam em +`review`. A gravação é atômica (arquivo temporário + `mv`) e deve ocorrer por +último, depois de ler o relatório JSON do C215. + +### Cookies e yt-dlp + +O C215 não recebe cookies por argumento. Crie um wrapper fora do repo que +aponta para o binário real e habilita somente o runtime JavaScript disponível +na máquina. `--no-js-runtimes` evita que um Deno instalado, mas não preparado, +vença o Node; `--cookies` aplica a sessão somente à rodada. O arquivo deve ter +permissão `600` e ser removível sem tocar no clone. + +```bash +set -euo pipefail +install -d -m 700 "$FALAS_WEB_DIR/bin" +YTDLP_REAL="$(command -v yt-dlp)" +test -n "$YTDLP_REAL" +test -r "$FALAS_WEB_DIR/cookies.txt" +cat > "$FALAS_WEB_DIR/bin/yt-dlp" <' +``` + +A saída deve ser JSON e o log não deve indicar runtime JavaScript ausente, +`yt-dlp-ejs` ausente ou cookie inválido. Para revogar a sessão, apague apenas +`~/falas-web/cookies.txt` e `~/falas-web/bin/yt-dlp`; não apague o lote nem o +histórico de relatórios. A skill C218 continua sem receber `YTDLP_PATH`, +cookies ou a flag de produção. + +### Sessão de produção e preflight + +O env file preserva os hosts da rede do compose; a sessão no host precisa dos +endpoints loopback. Não edite `~/stack/teqo-1313.env`: reescreva as variáveis +somente depois de `source`. + +```bash +source ~/.nvm/nvm.sh +cd "$WORKTREE" +set -a +source "$HOME/stack/.env" +source "$HOME/stack/teqo-1313.env" +set +a +export DATABASE_URL="${DATABASE_URL/@postgres:5432/@127.0.0.1:5433}" +export S3_ENDPOINT="${S3_ENDPOINT/host.docker.internal/127.0.0.1}" +export FALAS_WEB_DIR +export YTDLP_PATH="$FALAS_WEB_DIR/bin/yt-dlp" +``` + +Antes de qualquer escrita, confira o alvo exato, as dependências e o acesso ao bucket: + +```bash +set -euo pipefail +test "$NODE_ENV" = production +case "$DATABASE_URL" in + *@127.0.0.1:5433/teqo_1313) ;; + *) printf '%s\n' 'DATABASE_URL não é o alvo de produção esperado.' >&2; exit 1 ;; +esac +test "${S3_ENDPOINT%/}" = 'http://127.0.0.1:3900' +test "$S3_BUCKET" = 'teqo-media' +for name in PAYLOAD_SECRET DEEPINFRA_API_KEY DEEPSEEK_API_KEY S3_BUCKET S3_ENDPOINT S3_ACCESS_KEY_ID S3_SECRET_ACCESS_KEY; do + test -n "${!name:-}" || { printf 'falta %s\n' "$name" >&2; exit 1; } +done +test "$(psql "$DATABASE_URL" -Atqc 'select current_database()')" = 'teqo_1313' +node --input-type=module <<'NODE' +import { HeadBucketCommand, S3Client } from '@aws-sdk/client-s3' + +const client = new S3Client({ + region: process.env.S3_REGION || 'garage', + endpoint: process.env.S3_ENDPOINT, + forcePathStyle: true, + credentials: { + accessKeyId: process.env.S3_ACCESS_KEY_ID, + secretAccessKey: process.env.S3_SECRET_ACCESS_KEY, + }, +}) +await client.send(new HeadBucketCommand({ Bucket: process.env.S3_BUCKET })) +NODE +``` + +O `HeadBucket` precisa responder com sucesso; não imprima a resposta nem as +credenciais. O proxy de build `teqo-1313-build-proxy` em `127.0.0.1:5433` deve +existir. Se o preflight de download, o Postgres ou o Garage falhar, pare: não +troque o alvo para um banco remoto da workstation e não contorne as guardas. + +### Rodada manual + +Escolha um lote curado em `~/falas-web/batch-.json`. A primeira execução +é um smoke; não rode a varredura inteira de uma vez. Mantenha o lock pelo +processo inteiro, inclusive leitura do relatório e gravação de `last-run.json`: +uma segunda invocação do C218 ou do import não pode começar enquanto este +shell estiver aberto. + +```bash +set -euo pipefail +export RUN_ID="$(date -u +%Y%m%dT%H%M%SZ)" +exec 9>"$FALAS_WEB_DIR/.lock" +flock -n 9 || { printf '%s\n' 'já existe uma rodada web' >&2; exit 1; } +``` + +1. **Planejar, sem escrita e sem flag:** + + ```bash + export FINDINGS="$FALAS_WEB_DIR/batch-.json" + pnpm falas-web:import --findings "$FINDINGS" --out "$FALAS_WEB_DIR" --dry-run + ``` + + Leia o relatório em `~/falas-web/reports/`. `invalid` e `duplicates` devem + ser zero. Confira cada `plannedEntries[].sourceKey` e `action`; um `create` + para uma URL que já entrou exige corrigir a curadoria antes de ingerir. + +2. **Smoke, dentro do lock:** + + ```bash + if FALAS_WEB_IMPORT_CONFIRM=1 pnpm falas-web:import \ + --findings "$FINDINGS" --limit 1 --out "$FALAS_WEB_DIR" 2>&1 | + tee -a "$FALAS_WEB_DIR/runs/$RUN_ID.log"; then + : + else + exit 1 + fi + ``` + + O relatório deve mostrar a entrada processada, o estágio e o resultado. A + mídia espelhada deve aparecer no bucket `teqo-media`; o player e a rota de + mídia do acervo são a prova de leitura, não uma URL pública. A cauda do lote + permanece no próprio `batch-.json`. + +3. **Lote curado completo:** rode o mesmo comando sem `--limit` e no mesmo lock: + + ```bash + if FALAS_WEB_IMPORT_CONFIRM=1 pnpm falas-web:import \ + --findings "$FINDINGS" --out "$FALAS_WEB_DIR" 2>&1 | + tee -a "$FALAS_WEB_DIR/runs/$RUN_ID.log"; then + : + else + exit 1 + fi + ``` + + Exit 0 pode representar sucesso total ou parcial; leia `created`, `updated`, + `skipped`, `failures`, `asrSeconds`, `asrCostUsd`, `llmCostUsd` e + `durationMs` no JSON. Exit 1 significa que nenhum achado entrou e exige + retentativa, não um recibo verde. + +4. **Prova de idempotência:** repita o lote completo sem `--reprocess`, ainda + com o lock: + + ```bash + if FALAS_WEB_IMPORT_CONFIRM=1 pnpm falas-web:import --findings "$FINDINGS" --out "$FALAS_WEB_DIR" 2>&1 | + tee -a "$FALAS_WEB_DIR/runs/$RUN_ID.log"; then + : + else + exit 1 + fi + ``` + + O relatório deve mostrar `skipped` para itens completos, nenhum download/ASR + novo e nenhum objeto adicional no bucket. A segunda rodada deve usar a + mesma identidade `web::`; não use uma + URL alternativa para "forçar" o resultado. + +5. **Inventário e fonte da rodada:** registre o relatório, o lote e o estado em + `~/falas-web`; atualize `last-run.json` por último. Uma falha vira `pending` + com o estágio e a tentativa, e só uma importação com exit 0 avança + `lastRunAt`. Não marque a janela como coberta por memória da sessão. Mantenha + o FD 9 aberto até essa gravação; só depois execute `exec 9>&-`. + +6. **Ensaio de rollback:** antes de encerrar a primeira rodada, escolha uma + fala de teste, registre o `id`/`sourceKey`, peça confirmação humana para a + exclusão e remova pelo admin. Confirme no inventário e no Garage que a fala, + os segmentos e o objeto espelhado sumiram. Remova o `sourceKey` de qualquer + `pending`/`review` que ainda o referencie. Se essa prova não for feita, a + rodada fica bloqueada para aprovação operacional. + +### Inventário read-only + +As consultas abaixo não escrevem no banco: + +```bash +psql "$DATABASE_URL" -v ON_ERROR_STOP=1 <<'SQL' +SELECT origin, platform, count(*) AS total +FROM speech +GROUP BY origin, platform +ORDER BY origin, platform; + +SELECT id, "sourceKey", "speechAt", platform, origin +FROM speech +WHERE origin = 'web' +ORDER BY "speechAt" DESC; + +SELECT count(*) AS web_total, + count(*) FILTER (WHERE "mirroredMedia" IS NOT NULL) AS web_with_media +FROM speech +WHERE origin = 'web'; +SQL +``` + +Compare a contagem de `origin = 'camara'` com o baseline antes da rodada e +confirme que ela não diminuiu. Para o bucket, use o cliente S3/Garage aprovado +pela operação e confira o objeto pelo identificador da mídia, sem imprimir a +chave de acesso ou o segredo. Depois abra +`/campanha/comunicacao/acervo` → **Falas na internet** com uma sessão de +campanha e confirme player, transcrição, data e origem de uma fala da rodada. + +### Rollback + +Não copie o `DELETE` SQL do C155 para esta entrega. A remoção de uma fala web +deve passar pelo Payload, porque o hook `deleteSpeechAssets` remove segmentos e +registros de `internetSpeechMedia`, e o plugin de storage remove o objeto do +bucket: + +1. Faça o inventário read-only e anote o `id`/`sourceKey` exato. +2. No admin autenticado, exclua uma fala `origin = web` da rodada de teste. +3. Confirme no inventário que a linha e os segmentos sumiram e, no Garage, + que o objeto associado sumiu. +4. Repita para outras falas somente com identificação e confirmação humanas. + +Não apague diretamente a tabela `speech`, `speech_segment` ou +`internet_speech_media`; isso pode deixar relações e objetos órfãos. Remoção em +massa é uma operação destrutiva nova e precisa de item, flag, teste e runbook +próprios. + +### Falhas e decisões + +- **Runtime JS/YouTube:** o wrapper usa Node; se o binário não tiver + `yt-dlp-ejs`, a aquisição falha no estágio `acquisition`. Atualize o yt-dlp e + reinstale o componente pela via oficial; não coloque arquivos remotos no repo. +- **Cookies expirados:** a falha fica no relatório e em `pending`; atualize o + arquivo revogável e repita o lote. Nunca registre o conteúdo do cookie. +- **Egress do homeserver:** a rota para APIs/CDNs pode ser intermitente. O + preflight é um gate: se `--dump-json` ou o Garage falhar, a rodada não começa. + Não silencie a falha nem troque o banco de produção por um alvo remoto. +- **S3 ausente ou endpoint errado:** o novo guard recusa escrita não-local sem + as quatro `S3_*`; um erro de conectividade aparece no relatório. O env file do + container não deve ser editado para acomodar o host. +- **Espaço ou lock:** o `flock` serializa rodadas; um lock ocupado encerra o + comando. O diretório do clone e o diretório durável precisam de espaço para + dependências, lotes, relatórios e o temporário de cada download. +- **Fim da rodada:** registrar o resultado medido no changelog e o + `last-run.json` durável é parte da operação, não uma tarefa opcional. + +### Resultado registrado + +Preencher após uma execução real, sem apagar o histórico anterior: + +```text +data/hora: +lote: +sourceKeys/resultados: +criados/atualizados/pulados/falhas: +custo/tempo: +bucket e estado durável: +idempotência: +falhas/pendências: +``` + +O resultado da primeira rodada e a prova da segunda são a evidência de que a +fonte web foi realmente carregada em produção; o merge deste runbook, sozinho, +não representa uma rodada concluída. + ## C160 — reparo do link oficial do acervo (PDF direto do Diário) Operação de dados do acervo de falas (C153/C154/C155): troca o diff --git a/docs/plans/acervo-falas-internet-producao-impl.md b/docs/plans/acervo-falas-internet-producao-impl.md new file mode 100644 index 00000000..f589b474 --- /dev/null +++ b/docs/plans/acervo-falas-internet-producao-impl.md @@ -0,0 +1,272 @@ +# Impl: Falas na internet no acervo de produção (operação de ingestão) + +Status: aprovado +Atualizado em: 2026-09-25 +Issue: #1339 +Intenção: docs/plans/acervo-falas-internet-producao.md +Appetite restante: herdado (~1 dia de operação + providências no homeserver). A engenharia desta entrega é ~1/3 do dia: **um guard fail-closed pequeno + o runbook + o changelog**. O resto do dia é operação humana no homeserver (F0/F4), que não é trabalho de agente. + +> **Modo `--auto`.** Este plano nasce `aprovado` (Passo 3c vira apresentação). As quatro recomendações A da intenção (§Questões em aberto) são **assumidas** e estão registradas abaixo com a avaliação de divergência material. **Nenhuma fase deste plano escreve em produção a partir do agente** — o agente não recebe `DATABASE_URL`/credencial do `teqo_1313`, e a operação (F0/F4) é ato humano, com a flag de intenção exportada por mão. + +## Leitura da intenção + +**Outcome.** Em produção, `/campanha/comunicacao/acervo` → "Falas na internet" mostra as falas da rodada com player, transcrição, data e origem, com a mídia no bucket privado de produção; a segunda rodada não duplica nem reprocessa o que já está completo; e `docs/ops/teqo-1313-deploy.md` traz o suficiente (onde roda, pré-requisitos, guardas, como provar idempotência, como reverter) para outra pessoa repetir sem memória de sessão. + +**O que NÃO negociar.** + +- **Fronteira fail-closed do C218:** a skill `catalogo-falas-web` continua sem poder de produção — nunca exporta `FALAS_WEB_IMPORT_CONFIRM`, nunca contorna `assertLocalDatabase`, nunca escreve no `teqo_1313`. A promoção da skill é item próprio, fora daqui. +- **Bandeira de intenção humana:** `FALAS_WEB_IMPORT_CONFIRM=1` só existe na linha de comando de quem opera. Nenhum arquivo do repo, script, `.env`, workflow ou skill define essa flag. +- **`sourceKey` é a identidade:** `web::`; o acervo da Câmara (`origin = camara`, chaves da API) fica intocado; dedupe semântico cross-URL continua fora de escopo. +- **Mídia só no bucket privado** (`internetSpeechMedia` → Garage `teqo-media`), servida pelo acervo autenticado; nada de disco local e nada de URL pública. +- **Estado durável fora do banco** e fora do diretório gitignored da workstation; relatório honesto (sucesso parcial ≠ sucesso), falhas em `pending` retentável. +- **Sem automação:** nada de cron, fila, watcher, agendador, collection de estado, migration, UI nova nem tela de rodada. +- **Agente não executa escrita em produção** (política de `AGENTS.md` + §Modo autônomo do `work-issue`). + +**O que reavaliar (da "Direção no codebase" da intenção).** + +- _"Rodar no homeserver"_ — confirmado como **recomendação da intenção (assumida)**, mas a hipótese "lá dá para baixar do YouTube/Instagram" **não é verificável daqui** e há evidência contrária parcial: a rota do homeserver é intermitente para a API de IA (medido 2026-09-25, 1/3 de connect), o CDN do Instagram em IPv4 é instável nessa rede (post-mortem `2026-09-25-instagram-cdn-ipv4-instavel.md`) e `objects.githubusercontent.com` expira (nota do `pnpm-workspace.yaml`). Isso vira **gate de preflight da operação** (F0.4), não feature. +- _"Prefiro `data/falas-web/` (gitignored) no clone"_ — **errado para produção**: o clone é descartável por definição e o watermark morre com ele. O estado durável é um diretório dedicado fora do repo (D5). +- _"`~/stack/teqo-1313.env` é o ponto de configuração"_ — os hosts do env file (`postgres`, `host.docker.internal`) **só resolvem na rede do compose**; do host eles precisam de reescrita para loopback, e essa reescrita é **de sessão**, não persistida (D5/F2). +- _"Rollback no mesmo formato do C155 (SQL direto)"_ — **inseguro aqui**: o C155 não tinha mídia no S3. Fala da web tem `internet_speech_media` e o cascade `Speech → speechSegment → internetSpeechMedia` só roda pelo Payload (D4). +- _"Cookies: opção do yt-dlp já existente"_ — o C215 **não passa `--cookies`** em nenhum dos dois comandos (`readYtDlpMetadata`, `downloadWithYtDlp`). Não existe caminho hoje sem wrapper ou sem código (D2). + +## Recomendações A da intenção: como ficam + +| Questão (intenção) | Recomendação assumida | Como esta entrega implementa | Divergência material? | +| ------------------------------ | -------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | +| Onde a rodada roda | A — no homeserver, `yt-dlp` provisionado + um arquivo de cookies revogável | F0 cria um clone pinado dedicado no host; F2 documenta o procedimento com as reescritas de DB/S3 e o preflight de aquisição; a rodada para se o egress falhar | **Não.** É a própria recomendação da intenção, registrada como assumida. O _ato_ continua humano (F4) | +| Onde vive o estado incremental | A — diretório durável no homeserver, dono único | F0.5 cria `~/falas-web/` (lote, descoberta, relatórios, `last-run.json`); `--out` aponta para lá; lock por `flock` | **Não.** Mover o estado do clone gitignored para o host é o que o aceite exige ("o acervo de produção não depende de estado na workstation") | +| Quem conduz a rodada | A — procedimento manual documentado, skill C218 fail-closed | F2 escreve o procedimento; nenhuma linha de código toca a skill além de **um bullet de curadoria** (D3) que reforça a fronteira; a flag continua humana | **Não.** O bullet na skill é documentação de curadoria, não capacidade nova | +| O que entra na primeira rodada | A — lote curado com `--limit`, depois o volume | F4.3 smoke `--limit 1` → F4.4 lote curado → F4.5 repetição idempotente | **Não.** É a redução de blast radius que a própria intenção pede | + +**Conclusão de divergência:** nenhuma material. O plano pode seguir em `--auto`. As duas incertezas técnicas reais (egress do host e runtime JS do yt-dlp) são **gates de preflight com parada documentada**, não decisões de produto — se o F0.4 falhar, a operação para para correção do host, nunca o agente. + +## Abordagem recomendada + +**A menor entrega que fecha o aceite:** (1) **um guard fail-closed** no dono dos guards de escrita, para que "escrita em produção espelha no bucket" deixe de depender de o operador ter as 4 envs `S3_*` na sessão; (2) **o runbook** `## C225` no `docs/ops/teqo-1313-deploy.md`, com pré-requisitos, provisionamento de `yt-dlp`/cookies/`ffmpeg`, reescritas de host, preflight, dry-run, smoke, idempotência, estado, inventário e rollback; (3) **um bullet** na curadoria do C218 sobre a identidade do lote; (4) o changelog. Sem migration, sem collection, sem UI, sem automação, sem segunda esteira — a fase de escrita continua sendo exatamente `pnpm falas-web:import` do C215. + +```mermaid +flowchart TB + subgraph COD["PR (agente) — sem tocar produção"] + G["guard: escrita não-local exige S3
scripts/lib/cli.mjs + import-web-speeches.mjs"] + T["unit cliEnvFlags + int webSpeechIngest"] + R["runbook docs/ops §C225"] + K["bullet de curadoria (identidade do lote)
SKILL.md C218"] + L["changelog 2026-09-25-c225.md"] + end + subgraph HOST["Homeserver — ato humano (F0/F4)"] + W["clone pinado ~/teqo-falas-web + pnpm install"] + B["wrapper YTDLP_PATH + cookies revogável"] + E["env do ambiente + DB 127.0.0.1:5433 + S3 127.0.0.1:3900"] + P["preflight: aquisição (--dump-json) + Garage + proxy"] + S1["dry-run (read-only, sem flag)"] + S2["smoke --limit 1 (flag) → relatório"] + S3["lote curado (flag) → relatório honesto"] + S4["repetição = skipped / ASR 0 (idempotência)"] + ST[("~/falas-web/
lote · relatórios · last-run.json")] + end + PROD[("produção: teqo_1313 + bucket teqo-media")] + UI["/campanha/comunicacao/acervo
→ Falas na internet (C216)"] + G --> T --> R + R --> L + K --> L + W --> B --> E --> P + P --> S1 --> S2 --> S3 --> S4 + S2 -.->|flock + estado| ST + S3 -->|upsert transacional| PROD + S4 -->|skip idempotente| PROD + S4 -.->|inventário read-only| ST + PROD --> UI +``` + +**Opções consideradas:** A) guard de mídia + runbook + changelog, com provisionamento no host | B) só runbook, sem código | C) comando novo de rollback em massa + collection de estado da rodada. + +**Recomendação: A** — porque o aceite tem duas partes que documentação sozinha não entrega: (i) _"a mídia está no bucket e nunca em disco local"_ é hoje uma promessa silenciosa: sem as 4 envs `S3_*`, `resolveS3StorageEnv` devolve `{ enabled: false }` e o Payload grava em disco local **com exit 0 e relatório verde** — exatamente a falha que o anti-goal "não deixa mídia em disco local" proíbe; (ii) _"é repetível e segura"_ precisa da pré-condição de ambiente verificada antes da escrita, não de memória do operador. Um guard de ~6 linhas no dono (`scripts/lib/cli.mjs`) fecha (i); o runbook fecha (ii). + +**Rejeitadas:** + +- **B (só runbook):** deixa o descumprimento do anti-goal de mídia depender de disciplina; o relatório de uma rodada com S3 ausente é indistinguível de uma rodada bem-sucedida — o oposto de "falha não é mascarada" e de "honesta". Custo: um bug de ambiente vira lixo silencioso no disco efêmero do clone. +- **C (rollback em massa + collection de estado):** fora de escopo explícito da intenção ("schema/migration/collection nova: o estado operacional não vai para o banco"; "não invente uma segunda esteira"). O rollback documentado pelo caminho do Payload (D4) cobre o caso real; um comando destrutivo novo exigiria guard, teste e runbook próprios — item de Issue própria, com gatilho declarado em §Gatilhos de revisitação. + +### Decisões de engenharia + +**D1 — Onde a rodada executa: clone pinado no host do homeserver.** + +Opções: A) host do homeserver, clone dedicado `~/teqo-falas-web` no SHA, `pnpm install`, env do ambiente na sessão | B) workstation com alvo explícito (`ALLOW_REMOTE_DB=true` + flag) | C) container de manutenção do compose (`docker compose --profile maintenance run … teqo-1313-migrate`), como o C160. + +Recomendação: **A** — porque o dado de produção não sai de onde vive, o único material sensível transportado é um arquivo de cookies revogável, e o precedente é literal: o C155 e o C160 rodaram exatamente assim (`~/teqo-backfill` + `source ~/stack/teqo-1313.env` + `DATABASE_URL` reescrita para `127.0.0.1:5433` + flag). Também mantém o estado durável ao lado do dado. + +Rejeitadas: **B** porque a fronteira que o C215 impõe (`assertLocalDatabase` recusa host não-local) afrouxa no momento em que `ALLOW_REMOTE_DB=true` é aceito, e o precedente mostra o resultado: rodadas de ingestão que não deixaram registro em produção. **C** porque a imagem do migrator é `node:24-alpine` sem Python/yt-dlp e sem `ffmpeg` (`apk add ffmpeg` só existe no estágio `runner`), e o estado durável + cookies exigiriam volume no compose: trocaria uma dependência externa (`yt-dlp` do host, revisável e revogável) por uma mudança no build da imagem de produção. + +**D2 — Como o yt-dlp recebe cookies e runtime JS sem tocar a esteira.** + +Opções: A) wrapper em `~/falas-web/bin/yt-dlp` com `exec` do binário real mais `--cookies` e `--js-runtimes node`, apontado por `YTDLP_PATH` só na rodada | B) config global do yt-dlp (`~/.config/yt-dlp/config`) | C) código da aplicação: flag de cookies/ambiente no dono `src/utilities/media/ytdlp.ts`. + +Recomendação: **A** — porque `ytDlpBinary()` já resolve `YTDLP_PATH → PATH` e `defaultRunner` usa `execFile(bin, args)`: um wrapper executável funciona hoje, sem diff no C215, e é _escopado à rodada_, _reversível em um `rm`_ e _não versiona credencial_. E porque duas opções do yt-dlp são requisito de ambiente, não de domínio: `--cookies FILE` (formato Netscape) porque a sessão do operador (YouTube/Instagram) é o que o C215 pressupõe e nunca teve, e `--js-runtimes node` porque a documentação do yt-dlp diz que **só `deno` vem habilitado por padrão** (`deno, node, quickjs, bun` por prioridade) — sem essa flag o host depende de um runtime que não está instalado, e o `yt-dlp-ejs` (necessário para suporte pleno ao YouTube) precisa vir empacotado na instalação, nunca buscado remotamente (`--remote-components` é negado por default, e buscar do GitHub é rota que expira nesse host). + +Rejeitadas: **B** porque a config global vaza a sessão do operador para _todo_ yt-dlp daquele usuário no host (inclusive o pipeline de reels/C219), e revogação/auditoria ficam implícitas. **C** porque adiciona ao owner da esteira uma variável de ambiente de operador sem dono no domínio, e porque o C215 já fixa o contrato de aquisição (720p/mp4, sem cookies) — mexer nisso é decisão de domínio, não providência de ambiente. + +**D3 — A divergência de identidade (`externalId` presente × ausente muda a `sourceKey`).** + +Opções: A) regra procedural no runbook + conferência obrigatória no dry-run + um bullet na curadoria do C218 | B) normalizar a identidade no código (`webSpeechSourceKey` passa a extrair sempre o id da URL em `youtube`/`instagram`) | C) `findSpeechImportState` passando a aceitar uma segunda chave candidata (lookup por URL canônica). + +Recomendação: **A** — porque o contrato do C215 é explícito (`web::`) e a C223/C224 não o mexeram; a correção é de _uso_ (a curadoria sempre leva `externalId` em `youtube`/`instagram`, que é trivial de extrair da própria URL) e o dry-run já expõe a `sourceKey` planejada de cada entrada — o operador vê `create` para uma URL que reconhece de outra rodada. Mudar identidade de uma chave já persistida é "caro de reverter" e merece item próprio. + +Rejeitadas: **B** porque troca a semântica de identidade do dono (contrato citado por skills e testes pinados em `tests/unit/webSpeech.unit.spec.ts`) para tapar um desvio de curadoria, e porque `externalId` também é campo gravado na fala — a mudança mexe em identidade _e_ em metadado. **C** porque é pass-through que deixa o problema no momento errado (o `sourceKey` já foi gravado em `pending`/relatório) e nasce de um índice novo. + +**D4 — Rollback: remoção pelo Payload, nunca SQL cru.** + +Opções: A) apagar o discurso no admin (ou por um `payload.delete` pontual): o hook `deleteSpeechAssets` remove `speechSegment` e `internet_speech_media`, e o plugin S3 apaga o objeto do bucket | B) `DELETE FROM speech_segment; DELETE FROM speech;` no formato do C155 | C) novo comando `--rollback` scoped por `sourceKey`, com flag própria. + +Recomendação: **A** — porque o cascade é do Payload por construção ("Payload relationships do not cascade" → hook), e o B deixa exatamente o que o runbook proíbe: linhas de mídia órfãs no bucket e rels inconsistentes. Documento também um **inventário read-only** (`SELECT` das falas `web:%`) para saber exatamente o que existe antes de decidir. + +Rejeitadas: **B** por copiar o rollback do C155 sem adaptar a diferença real (lá não havia mídia no S3; o próprio runbook do C155 diz "nenhum objeto de mídia vai para o S3"). **C** por ser um caminho destrutivo novo: guard + teste + runbook próprios, e não exigido pelo aceite (a operação é aditiva e idempotente; remoção é caso raro e manual, pelo admin, com confirmação humana). + +**D5 — Workspace, estado durável e isolamento.** + +Opções: A) `~/teqo-falas-web` (clone pinado, descartável) + `~/falas-web/` (diretório durável, dono único, `chmod 700`), com `--out "$FALAS_WEB_DIR"` e `flock` no diretório | B) `data/falas-web/` do próprio clone (gitignored) | C) `~/teqo-deploy` (workspace do runner). + +Recomendação: **A** — porque o accept exige que o acervo de produção não dependa de estado na workstation, e o inverso também vale: o estado operacional não pode morrer com um `git checkout`/`git clean` de um clone descartável; `~/teqo-deploy` é reescrito pelo deploy (imagem, tag, revisão), e nenhum dado operacional pode morar ali. `--out` aceita caminho absoluto (a validação recusa `..`), e o `flock` é o single-flight que o C218 declara não existir ("uma rodada por vez: o estado não tem lock"), com o precedente do próprio repo (`/tmp/teqo-deploy.lock`, `/tmp/teqo-unblock.lock`). + +Rejeitadas: **B** porque o `data/falas-web/` é gitignored _e_ destruído junto com o clone — watermark perdido significa varredura inicial de novo, que é exatamente o que a rodada incremental evita. **C** porque mistura dado operacional com workspace de CI, o que já produziu desvios de revisão no passado ("prod regride para um SHA antigo" na §Falhas conhecidas). + +**Armadilha registrada (barata, mas custa a rodada se o operador errar):** `--out ~/falas-web` **não** expande o til no Node — o relatório cairia em `./~/falas-web/reports/…` dentro do clone. O runbook usa sempre `"$FALAS_WEB_DIR"` expandido pela shell. + +**D6 — O único guard de código: escrita não-local exige mídia espelhada.** + +Opções: A) predicado puro no dono dos guards (`scripts/lib/cli.mjs`) + `die` no CLI, reusando `requiresWriteConfirm()` e `resolveS3StorageEnv()` | B) checagem ad-hoc dentro do script | C) novo script de preflight à parte. + +Recomendação: **A** — porque `requiresWriteConfirm()` já define "escrita não provadamente local" (é a mesma condição que exige a flag, incluindo o `NODE_ENV=production` do host, que é o "sinal honesto" do C155) e `resolveS3StorageEnv` já é o dono da decisão S3. O predicado fica testável como unidade, o `die` fica no CLI (a semântica de erro pertence ao CLI, como o resto), e o guard é **fail-closed**: sem `S3_*`, escrita não-local não roda. O alvo exato de produção (`teqo_1313`/`teqo-media`) é uma precondição do procedimento C225, não uma política global do CLI, para não bloquear usos legítimos de staging. Alvo local (dev/test/worktree, e portanto toda a skill C218) continua com disco local — a fronteira do C218 fica idêntica. + +Rejeitadas: **B** porque lógica de decisão sem dono fica órfã e não há como testá-la sem subir o CLI inteiro. **C** porque um "preflight" separado é mais um comando para lembrar — fail-closed no caminho da escrita não depende de o operador lembrar de rodar o passo anterior. + +### Componentes / mudanças + +- **`scripts/lib/cli.mjs`** (dono dos guards de escrita): +1 predicado puro `mirroredMediaRequired({ nodeEnv, databaseUrl, allowRemoteDb, s3Enabled })` delegando a `requiresWriteConfirm` (sem reescrever a semântica de "não-local") + testes em `tests/unit/cliEnvFlags.unit.spec.ts` e `tests/unit/importWebSpeechesCli.unit.spec.ts` (incluindo valor S3 não booleano e recusa antes da leitura do lote). +- **`scripts/import-web-speeches.mjs`** (`src` do comando): no ramo de **escrita** (o ramo `--dry-run` é read-only e fica intacto), após `assertWriteConfirm` + `assertLocalDatabase`, um `die` quando o predicado for verdadeiro — mensagem nomeando as 4 envs e o motivo ("sem elas o Payload gravaria em disco local"). `pnpm falas-web:import` (o `cross-env` de `package.json:92`) **não** é tocado: não passa a exportar `NODE_ENV=production` e a fronteira da skill C218 fica igual. +- **`docs/ops/teqo-1313-deploy.md`**: nova seção `## C225 — rodada de falas da internet no acervo de produção` (§Fases/F2 especifica o conteúdo exigido) + uma linha na tabela "Onde roda cada coisa" para o clone operacional (ao lado de `~/teqo-deploy`, explicitando que **não** é o workspace do runner). +- **`.agents/skills/catalogo-falas-web/SKILL.md`**: **um** bullet em §Curadoria — identidade do lote: `youtube`/`instagram` sempre com `externalId`; sem ele a chave vira a URL canônica e a mesma fala pode abrir duas linhas; e a conferência no dry-run antes de ingerir. Nenhuma capacidade nova, nenhuma flag, nenhum caminho de produção. +- **`docs/changelog/2026-09-25-c225.md`**: entrada curta no padrão do repo. **Declara o guard, o runbook e o status da operação** — se a rodada ainda não rodou, o changelog diz isso; o resultado medido é acrescentado depois pela pessoa, sem reescrever o que o agente afirmou. +- **Migration:** sem migration. **Collection/global/campo:** nenhum. **Access/Consent:** intocado — `internetSpeechMedia` segue `canReadSpeech`/`payloadAdminOnly`, o acervo da Câmara segue com `origin = camara` e suas chaves. **UI:** Impeccable A — N/A (sem UI; nenhuma superfície visual muda). +- **Fora do repo (ato humano, não no PR):** `~/falas-web/`, `~/teqo-falas-web`, `~/falas-web/bin/yt-dlp`, `~/falas-web/cookies.txt`, `yt-dlp` instalado no host, e as reescritas de sessão de `DATABASE_URL`/`S3_ENDPOINT` — **`~/stack/teqo-1313.env` não é editado** (o container precisa dos hosts da rede do compose; o host precisa do loopback; as duas coisas convivem por reescrita de sessão). + +## Fases verificáveis + +**F0 — Providências de ambiente no homeserver (ato humano, ~2h; bloqueia a operação, não o PR).** + +- F0.1 `~/teqo-falas-web`: `git clone`/`fetch` + `checkout ` + `pnpm install` (molde do `~/teqo-backfill` do C155). Verificação: `git -C ~/teqo-falas-web rev-parse HEAD` = SHA do merge; `pnpm falas-web:import --help` roda. +- F0.2 `yt-dlp` com `yt-dlp-ejs` empacotado e um runtime JS no PATH (`source ~/.nvm/nvm.sh` já entrega o node 24 que o C155 usa). Verificação: `yt-dlp --version` e `yt-dlp --verbose --simulate 2>&1 | grep -i runtime` sem tentativa de download remoto. +- F0.3 `~/falas-web/` (`chmod 700`) + wrapper `~/falas-web/bin/yt-dlp` (chmod 700) que faz `exec` do binário real com `--cookies "$FALAS_WEB_DIR/cookies.txt" --no-js-runtimes --js-runtimes node`; `cookies.txt` (Netscape, `chmod 600`, fora do repo, **nunca versionado**). Verificação: `"$YTDLP_PATH" --dump-json --no-playlist --no-warnings --skip-download ` responde JSON. Revogação: apagar `~/falas-web/bin` e `~/falas-web/cookies.txt`. +- F0.4 Preflight de rede/acquisição: o mesmo `--dump-json` de F0.3; `psql` confirma `current_database() = teqo_1313`; `HeadBucket` confirma `S3_BUCKET = teqo-media` no endpoint loopback; o proxy socat de produção responde em `127.0.0.1:5433`. Qualquer falha interrompe a rodada; não há troca automática de alvo. +- F0.5 `flock` disponível e `~/falas-web/.lock` criado na primeira rodada. + +**F1 — Guard de mídia espelhada (código, ~1h; quota máxima do plano).** + +1. Predicado em `scripts/lib/cli.mjs` delegando a `requiresWriteConfirm`, sem reimplementar "não-local". +2. `die` no ramo de escrita de `scripts/import-web-speeches.mjs`; `--dry-run` intocado. +3. Unit em `tests/unit/cliEnvFlags.unit.spec.ts` (5 casos, incluindo `NODE_ENV=production` com host local e entrada S3 não booleana) + teste de processo em `tests/unit/importWebSpeechesCli.unit.spec.ts` que prova a recusa antes da leitura do lote. RED antes do fix. +4. `pnpm vitest run tests/unit/cliEnvFlags.unit.spec.ts tests/unit/importWebSpeechesCli.unit.spec.ts` verde + `tests/int/webSpeechIngest.int.spec.ts` e `tests/int/webSpeechImport.int.spec.ts` verdes (o caminho de ingestão não muda; a cobertura existente continua sendo a dele). + +**F2 — Runbook (docs, ~2h).** Escrever `## C225` com o conteúdo exigido em §Runbook exigido abaixo, mais a linha em "Onde roda cada coisa". Verificação: seguir o runbook de ponta a ponta **a seco** numa sessão limpa (sem escrever) — todo comando listado existe, toda variável está definida antes do uso, nenhum passo depende de memória de sessão. + +**F3 — Gates e fechamento (agente).** `pnpm gate:fast`; `pnpm format:check`; `pnpm knip`; `pnpm check:cycles`; `pnpm push` (changelog + branch `C225-…`); PR com `Closes #1339`. Sem e2e novo: a mudança é docs + um predicado testado, e nenhuma superfície renderizada muda. + +**F4 — A operação (ato humano no homeserver, depois do merge; ~2–4h de máquina na primeira rodada).** + +- F4.1 Sessão: `ssh homeserver` → `source ~/.nvm/nvm.sh` → `cd ~/teqo-falas-web` → `set -a; source ~/stack/.env; set +a` (`DEEPINFRA_API_KEY` **e** `DEEPSEEK_API_KEY` — a ausência do segundo é a falha que o C224 registrou na workstation) → `set -a; source ~/stack/teqo-1313.env; set +a` (`DATABASE_URL`, `NODE_ENV=production`, `PAYLOAD_SECRET`, `S3_*`) → reescritas de host (`DATABASE_URL` → `127.0.0.1:5433`; `S3_ENDPOINT` → `http://127.0.0.1:3900`) → `export YTDLP_PATH="$HOME/falas-web/bin/yt-dlp"`. +- F4.2 Planejar (read-only, **sem** flag): `pnpm falas-web:import --findings "$FALAS_WEB_DIR/batch-.json" --out "$FALAS_WEB_DIR" --dry-run`. Conferir: `invalid = 0`, `duplicates = 0`, e **nenhum `create` para uma URL já ingerida** (D3). +- F4.3 Smoke: adquirir uma vez o lock externo `~/falas-web/.lock` e mantê-lo até F4.8; `FALAS_WEB_IMPORT_CONFIRM=1 pnpm falas-web:import --findings … --limit 1 --out "$FALAS_WEB_DIR" 2>&1 | tee -a ~/falas-web/runs/.log`, em `tmux`. Conferir no relatório JSON: `created = 1`, estágio por campo, e o objeto no bucket. +- F4.4 Lote curado: repetir o comando com a flag explícita e **sem** `--limit`, ainda sob o mesmo lock; a cauda do `--limit` do F4.3 não é `pending` — ela fica no `batch-.json` e entra nesta passada. Registrar o código de saída: exit 0 = sucesso total/parcial (falhas no relatório), exit 1 = nenhum achado entrou. +- F4.5 Idempotência: repetir F4.4 com o mesmo lote e a mesma flag explícita, ainda sob o lock → relatório com `skipped`, `asrSeconds = 0` e nenhum objeto novo no bucket. É a prova de aceite do "não duplica/não reprocessa". +- F4.6 Inventário e verificação (read-only): `SELECT` das falas `web:%` (contagem, `sourceKey`, `speechAt`, plataforma) e conferência do bucket; abrir `/campanha/comunicacao/acervo` → "Falas na internet" como confirmação (usuária de campanha) e confirmar que a fonte da Câmara segue com os **997** discursos. +- F4.7 Estado: ainda com o lock, gravar `~/falas-web/last-run.json` no contrato do C218 (escrita atômica `tmp` + `mv`), com `lastRunAt` advanced **só** com exit 0, falhas → `pending` com `attempts`, revisão → `review`. Acrescentar o resultado medido ao changelog. +- F4.8 Rollback ensaiado uma vez antes de liberar o lock (a prova de que o caminho é reversível): apagar **um** discurso da rodada no admin e conferir que segmento, linha de mídia e objeto no bucket foram junto; remover o `sourceKey` de qualquer `pending`/`review` que ainda o referencie. + +### Runbook exigido (F2 — o conteúdo, não o texto) + +`## C225 — rodada de falas da internet no acervo de produção`, na voz do documento (comando, motivo, medido). Seções: + +1. **O que é** — a fonte C216 abre vazia porque nenhuma rodada web rodou contra o `teqo_1313`; esta seção é o procedimento para encher, repetir e reverter. Operação, não feature; a esteira é o C215 e a descoberta é o C218. +2. **Pré-requisitos** — deploy com o C216/C217/C223/C224 em produção; `~/teqo-falas-web` pinado no SHA + `pnpm install`; `~/stack/.env` com as duas chaves; `~/stack/teqo-1313.env`; `~/falas-web/` (durable, `chmod 700`); `yt-dlp` com `yt-dlp-ejs` + runtime JS; `ffmpeg` (o CLI resolve `FFMPEG_PATH` → `PATH` → binário empacotado do `pnpm install`, e imprime a origem). +3. **Estado durável e isolamento** — `~/falas-web/{batch,discovery,reports}` + `last-run.json`; contrato do C218 copiado com o link; `--out "$FALAS_WEB_DIR"` (nunca `~/…` literal); lock externo mantido do início da rodada até a gravação de `last-run.json`; por que **não** `~/teqo-deploy` nem `data/falas-web/` do clone. +4. **Cookies** — um arquivo Netscape revogável, o wrapper, `YTDLP_PATH` só na rodada, refresh documentado, revogação = apagar os dois arquivos. Fronteira: a skill C218 continua sem produção e sem flag. +5. **Hosts na sessão** — as duas reescritas e **por que** (`postgres`/`host.docker.internal` só resolvem na rede do compose); o env file não é editado; o proxy socat de produção (`teqo-1313-build-proxy`, `127.0.0.1:5433`) e o endpoint do Garage no host. +6. **Guardas** — `FALAS_WEB_IMPORT_CONFIRM=1` exigida sempre que a escrita não é provadamente local (`NODE_ENV=production`, host remoto ou `ALLOW_REMOTE_DB`); `--dry-run` é read-only; e o guard novo: escrita em produção/override/alvo não-local **recusa** sem as 4 `S3_*` (mídia em disco local é proibida). O runbook também exige banco `teqo_1313` e bucket `teqo-media` antes da operação. `ALLOW_REMOTE_DB` não faz parte do caminho normal. +7. **Rodada** — o bloco de comandos numerado (F4.1→F4.5) com `tmux` + `tee`, exit codes, `--limit` e a cauda, e onde está o relatório JSON. +8. **Provar idempotência** — repetir o lote e esperar `skipped`/`asrSeconds 0`/nenhum objeto novo. +9. **Inventário** — os `SELECT` read-only (falas `web:%`, contagem por plataforma) para responder "o que já entrou". +10. **Rollback** — apagar no admin (cascade `Speech → speechSegment → internetSpeechMedia` + objeto no bucket) com a **ressalva explícita** de que o SQL direto do C155 é inseguro aqui; remoção em massa é item próprio (gatilho declarado). +11. **Falhas conhecidas da operação** — egress intermitente do host (IA 1/3 em 2026-09-25; CDN do Instagram IPv4 instável, com IPv6 do compose como histórico), `yt-dlp` sem runtime JS, cookies expirados, `S3_ENDPOINT` errado, `~/falas-web` em disco cheio (o temp é por achado e sai no `finally`, mas o clone precisa de espaço para `node_modules`), e a decisão de segurança: se o preflight de aquisição falhar no host, corrigir o host tem precedência; não existe troca automática para um banco remoto da workstation. +12. **Decisão de segurança** — se o preflight falhar, a rodada para para correção do host; não abrir uma segunda esteira nem trocar o alvo de produção. +13. **Resultado medido** — a ser preenchido por quem rodar (data, lote, criados/pulados/falhas, ASR/LLM, onde ficaram os artefatos), com o mesmo espírito do "Resultado registrado" do C155. + +## Rabbit holes / Não escopo (engenharia) + +- **Não** criar collection/tabela de estado, migration, campo de rodada, ou "última execução" no banco. O estado é o arquivo, como o C218 define. +- **Não** dar à skill C218 modo de produção, flag, lock próprio ou escrita fora do CLI. +- **Não** mexer em `sourceKey`, no contrato do lote, no relatório, nos estágios ou nos exit codes do C215. +- **Não** tocar a ingestão da Câmara (`origin = camara`, 997 discursos), as URLs do acervo, o C216/C217 nem o C219. +- **Não** agendar, pôr cron, bot, watcher ou monitoring; a rodada é manual. +- **Não** versionar cookies, config de navegador ou qualquer credencial; **não** replicar o perfil do navegador. +- **Não** deixar mídia em disco local como "destino intermediário para subir depois" (o mirror é direto no bucket; o temp é por achado e sai no `finally`). +- **Não** espelhar a sessão do navegador inteiro; no máximo o arquivo de cookies. +- **Não** fazer uma "sweep" inicial em bloco na primeira rodada. +- **Não** tocar Dockerfile/compose/env files para instalar `yt-dlp` na imagem (D1C). +- **Não** "limpar" o runbook do C155 ao reescrever o rollback — a diferença (mídia no S3) fica explícita nas duas seções. + +## Riscos e mitigação + +| Risco | Evidência | Mitigação | +| ---------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| O host não baixa do YouTube/Instagram (egress intermitente; CDN do Instagram instável em IPv4) | runbook §Egress (1/3 de connect em 2026-09-25); post-mortem `2026-09-25-instagram-cdn-ipv4-instavel.md` | F0.4 é **gate**: se o `--dump-json` falha, a operação não começa; o relatório honesto transforma a falha em `pending` e o contorno está declarado no runbook. Instagram entra depois do YouTube na primeira rodada | +| `yt-dlp` sem runtime JS habilitada (só `deno` vem por default) e sem `yt-dlp-ejs` empacotado | docs do yt-dlp (`--js-runtimes`, `--remote-components`) | Wrapper com `--js-runtimes node` + instalação com o ejs empacotado; verificado no F0.2 antes de qualquer escrita | +| Sessão de cookies expirada/revogada | C215 pressupõe sessão que ele nunca teve | Falha vira `pending` com estágio `acquisition`; refresh documentado; revogação = apagar `bin/` + `cookies.txt` | +| Mídia caindo em disco local sem ninguém perceber (S3 ausente) | `resolveS3StorageEnv` degrada para `{ enabled: false }` em silêncio | **Guard D6** (fail-closed na escrita não-local) + preflight F0.4 + mensagem de erro nomeando as 4 envs | +| Estado durável perdido (clone limpo, `~/teqo-deploy` reescrito) | `data/falas-web/` é gitignored; deploy reescreve o workspace do runner | Diretório dedicado fora do repo (D5) + `flock`; o runbook diz onde está a fonte da verdade | +| Duplicata silenciosa por `externalId` presente × ausente | `webSpeechSourceKey` (`src/lib/webSpeech.ts:163-174`) não tem lookup alternativo; sem teste para o caso cruzado | Regra de curadoria + conferência no dry-run (D3) + `sourceKey` unique como rede de segurança; **gatilho**: se a F4.2 apontar `create` para URL já ingerida, item próprio de normalização | +| Rollback por SQL deixa mídia órfã no bucket | cascade só pelo Payload (`src/collections/Speech.ts:65-98`) | Rollback documentado pelo admin (D4) + inventário read-only antes de decidir | +| Custo de ASR/LLM maior do que o esperado em fala longa | C223 liberou transcrição completa; C155 mediu ~US$1,23/997 discursos (~45h) | Rodada com `--limit` primeiro, relatório com `asrCostUsd`/`llmCostUsd`/`durationMs` lido **antes** de ampliar; nada de varredura inicial em bloco | +| `S3_ENDPOINT` reescrito errado grava em outro bucket/nada | hosts do env file só resolvem no compose | Preflight F0.4 (Garage responde) + conferência do objeto no bucket em F4.3 antes de seguir | + +## Gatilhos de revisitação + +- **Normalizar a identidade (`sourceKey`)** — quando um dry-run apontar `create` para uma URL já ingerida, ou quando a curadoria virar rotina automática. Item próprio (mudança de contrato do C215). +- **Comando de rollback em massa** — quando uma rodada precisar ser removida em bloco. Exige flag, teste e runbook próprios. +- **Promover a skill C218 a modo de produção** — quando a operação manual se provar repetível e o custo de coordenação pesar. Item de produto, com a flag ainda sob ato humano. +- **Rodar a ingestão dentro do container de manutenção** — se o host deixar de ter `yt-dlp` viável (o que muda o D1 para a opção C e toca o build da imagem). +- **`--out` com state dir relativo/expansão de `~`** — hoje só `..` é recusado; se alguém pedir `--out` com validação mais estrita, é item pequeno e isolado. + +## Aceite de engenharia + +- [x] Aceite de produto da intenção ainda coberto — a fonte abre com falas, player, transcrição, data e origem; a segunda rodada não duplica nem reprocessa; a mídia está no bucket privado e servida só ao acervo; o runbook permite repetir sem memória de sessão; as falas da Câmara ficam intactas. +- [x] Invariantes `AGENTS.md`/engineering-standards — nada de migration/collection nova; nenhuma escrita em produção pelo agente; o estado operacional fora do banco; a fronteira fail-closed do C218 intacta (a skill não ganha flag, lock nem caminho de escrita); `sourceKey` preservado; identificadores em inglês, textos de operação em pt-BR; o rollback documentado passa pelo dono (Payload), não por SQL cru. +- [x] `FALAS_WEB_IMPORT_CONFIRM` permanece exportada só por mão, na linha de comando de quem opera; nenhum arquivo do repo a define. +- [x] Testes de domínio previstos — unit novo para o predicado do guard (5 casos, RED antes do fix) em `tests/unit/cliEnvFlags.unit.spec.ts`; teste de processo do CLI em `tests/unit/importWebSpeechesCli.unit.spec.ts` prova a recusa antes da leitura do lote; `tests/int/webSpeechIngest.int.spec.ts` e `tests/int/webSpeechImport.int.spec.ts` continuam cobrindo a ingestão; unit de `webSpeech`/`webSpeeches`/`ytdlp`/`catalogoFalasWebSkill` intocados (nenhum contrato mudou). Sem teste de CLI contra Garage/cookies/proxy/rollback — limitação declarada, verificada pelo preflight e pelo smoke humano. +- [x] Gates — `pnpm gate:fast`, `pnpm format:check`, `pnpm knip`, `pnpm check:cycles`; changelog curto e honesto (declara o que é guard e runbook, e o status real da operação). +- [x] Verificável sem a memória da sessão — o runbook lista workspace, envs, reescritas, cookies, guardas, comandos, prova de idempotência, inventário e rollback; cada fase tem um comando de conferência. + +## Triage da revisão + +### Já resolvido no simplify/critique (não reabrir) + +- O guard de mídia espelhada, o teste do predicado, o teste de processo do CLI e o smoke de recusa antes da leitura do lote estão neste diff. +- O runbook agora é fail-closed no alvo, nas chaves, no bucket, no clone, no symlink, no lock e no estado; o branch foi rebased em `origin/main`. +- A curadoria C218 agora fixa `externalId` para YouTube/Instagram; a identidade `sourceKey` não foi alterada. + +### Explicitamente fora + +- Não registrar um hardcode global de `teqo_1313`/`teqo-media` no CLI: o guard de alvo exato pertence ao procedimento C225 para preservar usos legítimos de staging; gatilho: se a CLI ganhar um modo de produção próprio. +- Não criar segunda esteira, cron, collection de estado, migration, modo de produção para C218 ou dedupe cross-URL; esses itens permanecem nos gatilhos de revisitação acima. +- Não registrar a query de duplicatas: `sourceKey` já é único; o inventário agora conta mídia e lista `id` para rollback. + +## Self-score (decision-quality, gate ≥4) + +1. **Decisões caras têm rejeitadas?** — **5/5.** D1 (onde executa), D2 (cookies/runtime sem código), D3 (identidade), D4 (rollback), D5 (workspace/estado) e D6 (guard) trazem Opções/Recomendação/Rejeitadas com o motivo de cada rejeição. +2. **Cabe no appetite?** — **5/5.** Um predicado + um `die` + um bullet de skill + um runbook + changelog; sem migration, sem collection, sem UI, sem cron. Fases F0/F4 são operação, como a intenção compte (~1 dia). +3. **Rabbit holes nomeados?** — **5/5.** Doze bullets de não escopo (collection de estado, skill em produção, mexer em `sourceKey`, varrer em bloco, cookies versionados, replicar perfil, mirror em disco, tocar Dockerfile, "limpar" o C155) + cinco gatilhos de revisitação. +4. **Depth check (reuso, sem módulo raso)?** — **5/5.** Reusa `requiresWriteConfirm`/`assertWriteConfirm`/`dieWithLabel` (o próprio dono declara que reescrever essas formas quebra o build), `resolveS3StorageEnv`, `resolveFfmpeg`, `assertLocalDatabase`, o `flock` já usado pelo repo, o clone pinado do C155 e o formato de seção do runbook existente. Nenhum arquivo/utility/componente novo — o único código novo é um predicado no módulo que já é o dono do conhecimento. +5. **Intenção permanece satisfeita?** — **5/5.** O outcome de produto é o mesmo da intenção; a engenharia só torna explícitos o binário de aquisição, o bucket e o rollback. As quatro recomendações A ficam registradas como assumidas, e nenhuma foi convertida em feature ou automação. + +**Total: 25/25.** + +**Status: aprovado** (auto-aprovado no modo `--auto`: abordagem técnica, corte de escopo previsto na skill e literais de dados que o próprio plano recomenda, todos registrados como assumidos). **Continua parando, sem exceção:** qualquer execução de escrita em produção, aprovação humana, migration, `DEGRADED` visual, e divergência material de produto → o orquestrador para, comenta na Issue e flipa para `blocked`. diff --git a/scripts/import-web-speeches.mjs b/scripts/import-web-speeches.mjs index 081a90db..d5693bdb 100644 --- a/scripts/import-web-speeches.mjs +++ b/scripts/import-web-speeches.mjs @@ -26,7 +26,13 @@ import { dirname, join } from 'node:path' import { getPayload } from 'payload' import { assertLocalDatabase } from './assert-local-database.mjs' -import { assertWriteConfirm, databaseTarget, dieWithLabel, loadCliEnv } from './lib/cli.mjs' +import { + assertWriteConfirm, + databaseTarget, + dieWithLabel, + loadCliEnv, + mirroredMediaRequired, +} from './lib/cli.mjs' import { resolveFfmpeg } from './lib/mediaBinaries.mjs' import { formatWebSpeechReport, @@ -42,6 +48,7 @@ loadCliEnv() const die = dieWithLabel('falas-web:import') const config = (await import('../src/payload.config.ts')).default +const { resolveS3StorageEnv } = await import('../src/utilities/mediaStorage.ts') const { parseWebSpeechBatch } = await import('../src/lib/webSpeech.ts') const { findSpeechImportState } = await import('../src/utilities/speech/speechImport.ts') const { ingestWebSpeech } = await import('../src/utilities/speech/webSpeechIngest.ts') @@ -105,8 +112,9 @@ Opções: Sucesso parcial sai com código 0 (as falhas ficam no relatório); código 1 quando nenhum achado entrou no catálogo. -Escrita em alvo não-local ou com NODE_ENV=production exige -${WRITE_CONFIRM_FLAG}=1. O binário do yt-dlp vem de YTDLP_PATH ou do PATH; +Escrita em alvo não-local, com NODE_ENV=production ou com ALLOW_REMOTE_DB exige +${WRITE_CONFIRM_FLAG}=1 e as quatro variáveis S3_* para que a mídia seja +espelhada no bucket. O binário do yt-dlp vem de YTDLP_PATH ou do PATH; instale com \`pipx install yt-dlp\` (o ffmpeg empacotado é usado se não houver no PATH). ` @@ -153,6 +161,12 @@ async function main() { 'falas-web:import', `A ingestão escreve no acervo; produção exige ${WRITE_CONFIRM_FLAG}=1.`, ) + const storage = resolveS3StorageEnv(process.env) + if (mirroredMediaRequired({ s3Enabled: storage.enabled })) { + die( + `escrita em produção, override ou alvo não-local (${databaseTarget()}) exige mídia espelhada: configure S3_BUCKET, S3_ENDPOINT, S3_ACCESS_KEY_ID e S3_SECRET_ACCESS_KEY; sem elas o Payload usaria disco local.`, + ) + } } console.log( `[falas-web:import] alvo: ${databaseTarget()} | modo: ${options.dryRun ? 'dry-run (read-only)' : options.reprocess ? 'import (--reprocess)' : 'import'}`, diff --git a/scripts/lib/cli.mjs b/scripts/lib/cli.mjs index 939629ba..0ada9560 100644 --- a/scripts/lib/cli.mjs +++ b/scripts/lib/cli.mjs @@ -194,6 +194,13 @@ export const requiresWriteConfirm = ({ allowRemoteDb = isRemoteDbOverrideSet(), } = {}) => nodeEnv === 'production' || allowRemoteDb || !isLocalDatabaseUrl(databaseUrl) +export const mirroredMediaRequired = ({ + nodeEnv = process.env.NODE_ENV, + databaseUrl = process.env.DATABASE_URL, + allowRemoteDb = isRemoteDbOverrideSet(), + s3Enabled = false, +} = {}) => requiresWriteConfirm({ nodeEnv, databaseUrl, allowRemoteDb }) && s3Enabled !== true + /** Human label of the write target for the guard messages (`host/name`). */ export const databaseTarget = () => { const url = process.env.DATABASE_URL diff --git a/tests/unit/cliEnvFlags.unit.spec.ts b/tests/unit/cliEnvFlags.unit.spec.ts index 04f732f5..7ada6803 100644 --- a/tests/unit/cliEnvFlags.unit.spec.ts +++ b/tests/unit/cliEnvFlags.unit.spec.ts @@ -9,6 +9,7 @@ import { isLocalDatabaseUrl, isRemoteDbOverrideSet, isTruthyEnv, + mirroredMediaRequired, requiresWriteConfirm, } from '../../scripts/lib/cli.mjs' @@ -80,6 +81,72 @@ describe('databaseHostname / isLocalDatabaseUrl (C155)', () => { }) }) +describe('mirroredMediaRequired (C225 production media guard)', () => { + it('requires mirrored media for a production target without S3', () => { + expect( + mirroredMediaRequired({ + nodeEnv: 'production', + databaseUrl: 'postgresql://teqo:teqo@127.0.0.1:5433/teqo_1313', + allowRemoteDb: false, + s3Enabled: false, + }), + ).toBe(true) + }) + + it('allows a production target when S3 is enabled', () => { + expect( + mirroredMediaRequired({ + nodeEnv: 'production', + databaseUrl: 'postgresql://teqo:teqo@127.0.0.1:5433/teqo_1313', + allowRemoteDb: false, + s3Enabled: true, + }), + ).toBe(false) + }) + + it('does not require mirrored media for a local development target', () => { + expect( + mirroredMediaRequired({ + nodeEnv: 'development', + databaseUrl: 'postgresql://teqo:teqo@localhost:5432/teqo_wt155', + allowRemoteDb: false, + s3Enabled: false, + }), + ).toBe(false) + }) + + it('does not trust a non-boolean S3 capability value', () => { + const options = { + nodeEnv: 'production' as const, + databaseUrl: 'postgresql://teqo:teqo@127.0.0.1:5433/teqo_1313', + allowRemoteDb: false, + s3Enabled: false, + } + Object.assign(options, { s3Enabled: 'false' }) + + expect(mirroredMediaRequired(options)).toBe(true) + }) + + it('requires mirrored media for remote and override targets', () => { + expect( + mirroredMediaRequired({ + nodeEnv: 'development', + databaseUrl: 'postgresql://teqo:teqo@db.example.com:5432/teqo_1313', + allowRemoteDb: false, + s3Enabled: false, + }), + ).toBe(true) + expect( + mirroredMediaRequired({ + nodeEnv: 'development', + databaseUrl: 'postgresql://teqo:teqo@localhost:5432/teqo_wt155', + allowRemoteDb: true, + s3Enabled: false, + }), + ).toBe(true) + }) +}) + describe('requiresWriteConfirm (C155 write guard)', () => { it('stays quiet for a local target in dev/test', () => { expect( diff --git a/tests/unit/importWebSpeechesCli.unit.spec.ts b/tests/unit/importWebSpeechesCli.unit.spec.ts new file mode 100644 index 00000000..71e49ab1 --- /dev/null +++ b/tests/unit/importWebSpeechesCli.unit.spec.ts @@ -0,0 +1,38 @@ +import { spawnSync } from 'node:child_process' +import { join } from 'node:path' + +import { describe, expect, it } from 'vitest' + +const repoRoot = process.cwd() +const scriptPath = join(repoRoot, 'scripts', 'import-web-speeches.mjs') +const missingFindingsPath = join(repoRoot, 'data', 'falas-web', 'missing-test-batch.json') + +const runWithoutS3 = () => + spawnSync(process.execPath, [scriptPath, '--findings', missingFindingsPath], { + cwd: repoRoot, + encoding: 'utf8', + timeout: 30_000, + env: { + ...process.env, + NODE_OPTIONS: '--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs', + NODE_ENV: 'production', + DATABASE_URL: 'postgresql://teqo:teqo@127.0.0.1:5433/teqo_1313', + FALAS_WEB_IMPORT_CONFIRM: '1', + PAYLOAD_SECRET: 'test-secret', + S3_BUCKET: '', + S3_ENDPOINT: '', + S3_ACCESS_KEY_ID: '', + S3_SECRET_ACCESS_KEY: '', + }, + }) + +describe('import-web-speeches production media guard', () => { + it('refuses a non-local write without S3 before reading the findings file', () => { + const result = runWithoutS3() + const output = `${result.stdout}${result.stderr}` + + expect(result.status).toBe(1) + expect(output).toContain('S3_BUCKET') + expect(output).not.toContain('ENOENT') + }) +})