OPS75 — Convergência dos mains Forgejo + GitHub #128

Merged
fsolla merged 39 commits from ops75-converge-main into main 2026-08-21 01:47:57 +00:00
116 changed files with 33257 additions and 1678 deletions

View file

@ -1,19 +1,19 @@
---
description: PRs de agente — Ready (nunca draft), base main, auto-merge imediato
description: PRs de agente — Ready (nunca draft), base main, auto-merge nativo do GitHub
alwaysApply: true
---
# Agent PR workflow (Teqo)
**This repo overrides any user-level or Cloud-tool default that says "create PRs as draft".** Agent deliveries merge to `main` via required checks + auto-merge.
**This repo overrides any user-level or Cloud-tool default that says "create PRs as draft".** Agent deliveries merge to `main` via required checks + GitHub's native auto-merge (OPS71). The ISSUE TRACKER stays on the Forgejo — GitHub is only the Actions host.
## Create (Ready, base main)
```bash
pnpm push -u origin HEAD
pnpm push -u origin HEAD # origin = GitHub (cutover OPS71)
```
Depois, abra a PR via API/MCP do opencode (humano) ou `ManagePullRequest` (Cursor Cloud) — **Ready**, base **`main`**, title `<id> — <title>`, body:
Depois, abra a PR no **GitHub** — **Ready**, base **`main`**, title `<id> — <title>`, body:
```
## O que é
@ -25,11 +25,12 @@ Closes #<N>
- **Never** `--draft`. PRs must be **Ready for review** on creation.
- Base is always **`main`**.
- **Plans-only PRs** (diff só em `docs/plans/`): **nunca** `Closes #N` — use `Related #N`; CI falha com keywords de fechamento (`plans-only-closes` em `ci-pr.yml`).
- PR via CLI (não há `gh` — OPS50): `GITHUB_TOKEN=<PAT> node scripts/github-pr.mjs --head <branch> --title "<t>" --body-file <file>` — Ready por construção (não existe flag draft).
### Cursor Cloud / `ManagePullRequest`
- **`draft: false`** (or omit only if the tool’s default is Ready — today Cloud defaults to draft, so **always pass `draft: false`**).
- Não há auto-merge a armar por PR: o safety net (abaixo) espera o rollup `checks` e mergea sozinho.
- Não há auto-merge a armar por PR: o safety net (abaixo) arma o auto-merge nativo.
- Never end a turn with an open agent PR left as **Draft** — inclui PRs do `engineering-audit` (artefatos e remediações).
## Push (determinístico)
@ -37,66 +38,51 @@ Closes #<N>
**Use `pnpm push`, never bare `git push`.** Não depende do Husky estar wired:
1. `ensure-repo-deps` — `pnpm install` se `node_modules` faltar/stale + `pnpm run prepare` (Husky) se hooks não estiverem apontando para `.husky`
2. `pnpm gate:push` — espelho do `ci-pr.yml` sem e2e (e2e roda uma vez, no CI, após o push; local opcional: `pnpm test:e2e:affected`), com os `docs-guards` locais (OPS63: changelog append-only, agregado sync e marcadores de conflito — o escape `changelog-rewrite:` fica só no CI, body do PR)
2. `pnpm gate:push` — espelho do `ci-pr.yml` sem e2e (OPS72: o e2e é passo **explícito da skill** — antes do push rode localmente os e2e que criou + os da mesma superfície, discricionário via `pnpm test:e2e:affected`; o CI de PR roda só o blast radius `selected`, e o full fica no `verify` do deploy manual), com os `docs-guards` locais (OPS63: changelog append-only, agregado sync e marcadores de conflito — o escape `changelog-rewrite:` fica só no CI, body do PR)
3. `git push --no-verify` — só porque o gate já rodou no passo 2 (não é escape hatch)
```bash
pnpm push -u origin HEAD # canonical
pnpm push -u origin HEAD # canonical — origin é o GitHub (OPS71)
git push # só depois de `pnpm i` — hook repete ensure-deps + gate:push
git push --no-verify # WIP / cutover escape — CI repete tudo; ver AGENT-OPS
```
## Auto-merge immediately
The safety net (`agent-pr-ready-automerge.yml`, abaixo) merges the PR when the required checks pass — there is nothing to arm per PR. Do not wait for human approval on normal deliveries.
The safety net (`agent-pr-ready-automerge.yml`, abaixo) arms GitHub's **native auto-merge** (rebase) — the server merges the PR when the required check passes. There is nothing to arm per PR. Do not wait for human approval on normal deliveries.
## Required checks
Branch protection on `main` (applied 2026-08-18, OPS61) requires only:
Branch protection on `main` (GitHub, applied at the OPS71 cutover via `pnpm configure:branch-protection`) requires only:
- `CI (PR) / checks` — the ci-pr cascade rollup (the only context that covers
every ci-pr job; on the server the rule matches it as a glob,
`CI (PR) / checks*`)
- `CI (PR) / checks` — the ci-pr single-job check-run (OPS62: one sequential job posts one honest verdict; no matrix, no shards, no rollup to lie). **Literal de match na proteção: `checks`** (o GitHub casa pelo nome do check-run/job — a UI exibe `CI (PR) / checks`; pin ao vivo PR #742).
OPS64: the rollup is structurally honest since OPS62 — ci-pr.yml is a SINGLE
sequential job (`checks`), so its own status IS the cascade verdict (no
matrix, no shards, no parent aggregation to lie). The historical bug (Forgejo
9.0.3 reporting the LAST matrix shard's result as the parent's in `needs`,
posting `checks` SUCCESS with an e2e shard red — runs 604/607/610) is gone by
construction; the `needs.<job>.results` array of GitHub does not exist in this
runner, so any future matrix must NOT aggregate via `needs`. The safety-net
CLI pins the defense: it never trusts the rollup alone (scans individual
statuses; run-607 scenario unit-tested in `forgejoApi.unit.spec.ts`).
OPS64 history: the Forgejo runner could report the LAST matrix shard's result as the parent's (runs 604/607/610) — that bug is gone by construction: ci-pr.yml is a SINGLE sequential job, and GitHub's native auto-merge only merges with every required check green (server-side guarantee). The safety-net CLI has no poll path to port.
(`migration-lock` removed 2026-08-12 — the CI validates migration chains via migrate + int on every PR/main.)
(`migration-lock` removed 2026-08-12 — the CI validates migration chains via migrate + int on every PR.)
**There is no deploy from PR CI.** The `ci.yml` verifier on `main` runs the full suite in its fixed 30-minute window and, on a production change, the `deploy` job publishes to the homeserver (`jorgesolla1313.com.br`, self-hosted) — migrations applied before the rollout. Do not add a Vercel/Git-integration deploy gate.
**There is no deploy from PR CI.** Publishing is a **manual `workflow_dispatch`** (`.github/workflows/deploy.yml`): `verify` runs the full suite (hosted) and only then the `deploy` job (self-hosted runner on the homeserver) publishes `jorgesolla1313.com.br` — migrations applied before the rollout. Do not add a Vercel/Git-integration deploy gate.
When watching CI, use the PR page (or the API) — the safety net is what waits on checks; `gh` does not exist in this repo (OPS50).
When watching CI, use the PR page (or the API) — the safety net arms the server-side auto-merge; `gh` does not exist in this repo (OPS50).
Use the rollup context `CI (PR) / checks` — without it, you are not covering the cascade.
Use the required check `CI (PR) / checks` — without it, you are not covering the cascade.
## Promote
There is no stage→main promote. Merging to `main` is the delivery; `ci.yml` verifies the full suite and, on a production change, deploys to the homeserver (OPS53/OPS65; runbook: `docs/ops/teqo-1313-deploy.md`).
There is no stage→main promote. Merging to `main` is the delivery; publishing is a deliberate manual dispatch with full verification (runbook: `docs/ops/teqo-1313-deploy.md`).
## Safety net (workflow)
`agent-pr-ready-automerge.yml` — PRs same-repo → `main` (todas as branches).
O workflow roda `scripts/forgejo-pr-automerge.mjs` (plain Node, zero-dep): se
draft `cursor/*` → marca Ready; espera os checks e mergea por rebase (o
equivalente Forgejo do auto-merge — sem `gh`). O wait exige o rollup
`CI (PR) / checks` POSTADO e verde (OPS61) — o único contexto que só existe
quando a cascata inteira do ci-pr assentou (jobs ainda não agendados não
postam status; o próprio status do job do safety net é `pending` durante o run
e **nunca** entra no veredito). O CLI nunca confia no rollup sozinho: varre
todos os statuses individuais e falha em qualquer `failure`/`error` — um shard
de e2e vermelho bloqueia o merge mesmo com o rollup verde (OPS64; run 607).
A regra de branch protection no servidor
bloqueia o merge POST (405) enquanto o contexto exigido não estiver satisfeito
— inclusive merges manuais via API: foi um merge manual que colocou a PR #52
em main com o CI vermelho (o CLI nunca mergeou sozinho antes do OPS61; todo
merge Forgejo era manual).
O workflow roda `scripts/github-pr-automerge.mjs` (plain Node, zero-dep,
REST+GraphQL): se draft `cursor/*` → marca Ready (PATCH `draft: false`); arm
`enablePullRequestAutoMerge` (GraphQL, mergeMethod REBASE) no `node_id` do PR
— o **servidor** espera o required check `CI (PR) / checks` POSTADO e verde
antes de mergear; o próprio check-run do safety net fica `pending` durante o
run e não interfere. A regra de branch protection no servidor
(`enforce_admins: true`) bloqueia qualquer caminho de merge — inclusive API
manual — enquanto o required check não estiver satisfeito (foi um merge
manual que colocou a PR #52 em main com o CI vermelho na era Forgejo).
Cobre o caso em que o
agente (ou o default draft do Cloud) esqueceu e PRs humanas/worktree com merge
falho silencioso (incidente OPS52). **Draft de branch não-`cursor/*` nunca é

View file

@ -11,7 +11,7 @@ Established 2026-07-23 by the engineering-standards hardening (tracker: `docs/IM
1. **Dead code dies immediately.** After your edits, check what your change orphaned and delete it in the same delivery: run `pnpm exec knip` (files/dependencies are CI-blocking errors; unused exports are warnings being driven to zero). Never leave commented-out code, unused exports, superseded components, or one-shot scripts behind — git history is the archive.
2. **Zero warnings.** `pnpm lint` runs with `--max-warnings=0`. Deliberate placeholders use a `_` prefix. Never silence a rule without an inline justification comment.
3. **Gate em duas velocidades (desde 2026-07-30, paradigma de agentes paralelos — `docs/AGENT-OPS.md`).** **Fast gate local do agente** (iteração durante a sessão): `pnpm gate:fast` (lint + typecheck + test:unit). **Push canônico:** `pnpm push` — `ensure-repo-deps` (install + Husky em worktrees) + `git push`; o hook pre-push roda `gate:push` → `gate:ci` (espelho serial do `ci-pr.yml` **sem e2e** — OPS59: o e2e roda **uma vez, no CI**, em PR e na main; local opcional para feedback antecipado: `pnpm test:e2e:affected`; **com os `docs-guards` locais** — OPS63: changelog append-only, agregado sync e marcadores de conflito, mesmos scripts do CI; o escape `changelog-rewrite:` fica só no CI, body do PR). Não use `git push` nu. Fonte única: scripts `gate:*` e `scripts/git-push.mjs` em `package.json`; escape para WIP: `git push --no-verify`. Migrations NÃO são serializadas entre PRs: a cadeia é validada por `pnpm migrate` + `test:int` (incl. `campaignMigrationReconciliation`) em todo PR e na main; rebase antes de `migrate:create` continua obrigatório (o antigo `migration-lock` foi removido em 2026-08-12 — deadlock de CI). Entrega = merge em `main`; deploy gated pelo verificador `ci.yml`. **CI vermelho no teu PR é teu, mesmo pré-existente ou de infra** — "fora do escopo da feature" não é critério de parada (política "Dono do PR, dono do CI" em `docs/AGENT-OPS.md`).
3. **Gate em duas velocidades (desde 2026-07-30, paradigma de agentes paralelos — `docs/AGENT-OPS.md`).** **Fast gate local do agente** (iteração durante a sessão): `pnpm gate:fast` (lint + typecheck + test:unit). **Push canônico:** `pnpm push` — `ensure-repo-deps` (install + Husky em worktrees) + `git push`; o hook pre-push roda `gate:push` → `gate:ci` (espelho serial do `ci-pr.yml` **sem e2e** — OPS72: o e2e é passo **explícito da skill** (`work-issue`/`agent-work-issue`): antes do push rode localmente os e2e que criou + os da mesma superfície, **discricionário** via `pnpm test:e2e:affected`; o CI de PR roda só o blast radius `selected` — nunca `full` — e o full fica no `verify` do deploy manual; **com os `docs-guards` locais** — OPS63: changelog append-only, agregado sync e marcadores de conflito, mesmos scripts do CI; o escape `changelog-rewrite:` fica só no CI, body do PR). Não use `git push` nu. Fonte única: scripts `gate:*` e `scripts/git-push.mjs` em `package.json`; escape para WIP: `git push --no-verify`. Migrations NÃO são serializadas entre PRs: a cadeia é validada por `pnpm migrate` + `test:int` (incl. `campaignMigrationReconciliation`) em todo PR; rebase antes de `migrate:create` continua obrigatório (o antigo `migration-lock` foi removido em 2026-08-12 — deadlock de CI). Entrega = merge em `main`; publish gated pelo `verify` do `deploy.yml` (dispatch manual — OPS71; não há `ci.yml` verificador de main). **CI vermelho no teu PR é teu, mesmo pré-existente ou de infra** — "fora do escopo da feature" não é critério de parada (política "Dono do PR, dono do CI" em `docs/AGENT-OPS.md`).
4. **Run gate commands bare, never piped** (`pnpm test | tail` swallows the exit code). Never run `knip --fix` blind — verify each removal with `git grep -w <symbol>` first (knip still cannot load `payload.config.ts`, so its graph is incomplete; ledgered as P3).
## Type honesty

View file

@ -33,9 +33,9 @@ Conduz **uma** Issue já claimada do plano de implementação ao merge em `main`
- [ ] 1. Sessão: rename_chat + open_resource na intenção
- [ ] 2. Modelo: spawn do pool já fixou (`model:` da Issue) — sem comparação
- [ ] 3. Plan mode → plano de implementação (`*-impl.md`)
- [ ] 4. Executar o impl plan (schema/server → UI → gates)
- [ ] 4. Executar o impl plan (schema/server → UI → e2e local afetado → gates)
- [ ] 5. /simplify → fixes; capture-review-debts (modo autônomo)
- [ ] 6. PR Ready `--base main` + auto-merge + checks --required
- [ ] 6. PR Ready `--base main` (GitHub) + auto-merge nativo — `pnpm push -u origin HEAD` → `node scripts/github-pr.mjs` (ou `ManagePullRequest` `draft: false` no Cloud)
```
## Passo 0 — Prep
@ -79,13 +79,20 @@ O spawn do pool já fixou o modelo (`model:` da Issue — ver `model-selection`)
(executar → simplify → fechar em main), com os deltas do ator pool:
- **Branch:** `agent/<id>-<slug>` (worktrees Cursor podem já ter criado).
- **E2E local afetado (OPS72):** mesmo passo discricionário do ator humano
(rodar os e2e criados + da mesma superfície — ver `execution-pipeline.md`);
**worktree com browsers Playwright** → rode; **Cursor Cloud sem browsers** →
registre a justificativa no fechamento e siga — o CI de PR roda o blast
radius quando o diff classifica `selected`; em diff high-risk (`full`) o PR
não roda e2e e a rede final é o `verify` do deploy.
- **UI:** shape → craft → critique → polish (harden/optimize só sob gatilho).
- **`capture-review-debts`:** **autônomo** — só `expensive_lock` com score ≥4
(Issues novas com `depends: [<id-pai>]` se necessário); score ≤3 /
cheap_polish / defer_trigger → defer com gatilho no `*-impl.md` ou descarte —
**não** polua a fila.
- **Cloud:** PR via `ManagePullRequest` com `draft: false`; Prep Cloud no
Passo 0.
- **Cloud:** PR via `ManagePullRequest` com `draft: false` no **GitHub**
(repo/PR agora no GitHub; o tracker de Issues segue no Forgejo — OPS71);
Prep Cloud no Passo 0.
## Resumo final

View file

@ -144,7 +144,7 @@ Para cada lote com destino **registrar** ou **absorver**:
Não implemente as fases aqui. Execução é via `work-issue` / `agent-work-issue`, só se o usuário (ou o pipeline autônomo) seguir.
**Próximo no fluxo de entrega:** após a triage (confirmada ou modo autônomo), o fechamento segue o Passo 6 de `agent-work-issue` / `work-issue` (`pnpm push` → PR `--base main` com `Closes #N` → auto-merge → CI).
**Próximo no fluxo de entrega:** após a triage (confirmada ou modo autônomo), o fechamento segue o Passo 6 de `agent-work-issue` / `work-issue` (`pnpm push` → PR no GitHub `--base main` com `Closes #N` → auto-merge nativo → CI).
## Modo autônomo (`work-issue` pós-plano / `agent-work-issue` / pool)

View file

@ -36,7 +36,7 @@ pular a pausa do impl plan; Draft / sem auto-merge.
- [ ] 1. Contexto: Issue do prompt/`$ARGUMENTS` da sessão (ausente → UMA pergunta com validação; nunca claim)
- [ ] 2. Abrir o plano de intenção do body da Issue (`Plano: docs/plans/...`; sem link → body é spec)
- [ ] 3. Plan mode → escrever `docs/plans/<slug>-impl.md` → **PARAR e confirmar com o humano**
- [ ] 4. Após “ok”: Passo 4 (execução → /simplify → débitos autônomos → PR, via `execution-pipeline.md`)
- [ ] 4. Após “ok”: Passo 4 (execução → e2e local afetado → /simplify → débitos autônomos → PR, via `execution-pipeline.md`)
```
## Passo 1 — Contexto da sessão
@ -96,6 +96,12 @@ nunca invente produto novo.
fechar em main), com os deltas do ator humano:
- **Branch:** `<Code>-<slug>` do worktree — nunca crie branch nova na sessão.
- **E2E local afetado (OPS72, discricionário):** antes do push, rode
localmente os e2e que você **criou** + os da **mesma superfície afetada** —
você decide quais (`pnpm test:e2e:affected` ou specs diretas). Não está no
`gate:push`; ver a seção "E2E local afetado" do `execution-pipeline.md`
(incl. a limitação da #72: `--no-deps` + projetos paralelos → `--workers=1`
ou a cadeia padrão de projetos).
- **UI:** shape → craft → critique → polish.
- **`capture-review-debts`:** **autônomo** — colha, dedupe, pontue e **decida
você mesmo o destino** (registrar/absorver/deferir/descartar) pela triage

View file

@ -12,12 +12,42 @@ Ordem:
testes de domínio. Invariantes do engineering-brief.
2. **UI** — se Impeccable B/C/D: shape → craft → critique → polish. Tokens
`data-theme='campaign'`; shells existentes.
3. **Gates** — `pnpm gate:fast` na iteração; entrega com `pnpm push` (não
3. **E2E local afetado (OPS72, discricionário)** — antes do push, rode
localmente os e2e que você **criou** + os da **mesma superfície afetada**
(você decide quais). Ver §E2E local afetado.
4. **Gates** — `pnpm gate:fast` na iteração; entrega com `pnpm push` (não
`git push` nu).
Tracer bullet cedo se o item for grande. Inclua o `*-impl.md` no commit da
entrega.
## E2E local afetado (OPS72)
Política de e2e: **local = discricionário, CI/PR = blast radius, main = full
antes do deploy**.
- **O que rodar:** os specs e2e que o diff **criou** (novos `.e2e.spec.ts`) +
os da mesma superfície trabalhada (o `E2E_AFFECTED_MANIFEST` mapeia
`src/` → specs; a mecânica do CI é a mesma). **É discricionário: você
decide quais rodar** — não é um gate mecânico e não está no `gate:push`.
- **Ferramenta:** `pnpm test:e2e:affected` (espelho local do classifier:
modo `none`/`selected`/`full` conforme o diff; roda `migrate` +
`db:seed:minimal` antes). Alternativa para specs diretas:
`pnpm test:e2e --no-deps -- tests/e2e/<spec>.e2e.spec.ts` (ou
`--no-deps --project=<família>`).
- **CI/PR:** roda só o **blast radius** detectado (`ci-scope.mjs`, modo
`selected`) — nunca `full`. Diff high-risk (schema/lockfile/harness)
classifica `full` → o PR não roda e2e: o full fica para o `verify` do
deploy manual (antes de publicar) e, nesse mesmo diff, o espelho local
roda full.
- **Limitação da #72 (S3-FOLLOWUP):** e2e local com `--no-deps` + projetos
paralelos (`--project=a --project=b`) colide no `seedTestUser` (delete +
create concorrentes nos `beforeAll` → `ValidationError: email` e falhas
fantasma). Use `--workers=1` ou a cadeia padrão de projetos. Só afeta o
local: no CI/prod mode a cadeia de deps dos projects é descartada (as
famílias rodam em paralelo) e os runs selecionados filtram por arquivo —
quase sempre uma família — então o seed não colide.
## /simplify + débitos
1. Rode o comando `/simplify` completo (3 reviewers paralelos via Task,
@ -34,10 +64,15 @@ entrega.
e rode `pnpm changelog:build` para regenerar `docs/CHANGELOG-AGENTS.md`
(insert-only: entradas históricas nunca mudam; o diff será só a entrada
nova + o agregado). Rode `pnpm changelog:check` para confirmar.
3. **`pnpm push -u origin HEAD`**
4. PR via API/MCP (ou `ManagePullRequest` no Cloud) — **Ready** (nunca draft), base `main`, com `Closes #<N>`
5. O safety net `agent-pr-ready-automerge.yml` espera o rollup `CI (PR) / checks` e mergea por rebase — nada a armar
6. CI flipa `done`/`in-prod` no merge. Comente na Issue o desfecho em uma linha.
3. **`pnpm push -u origin HEAD`** — origin é o **GitHub** (OPS71; o tracker de
Issues continua no Forgejo por API).
4. PR no **GitHub** via `GITHUB_TOKEN=<PAT> node scripts/github-pr.mjs --head <branch> --title "<id> — <título>" --body-file <arquivo>` — **Ready** (nunca draft; o script não tem flag draft), base `main`, body com `Closes #<N>` (ou `Related #N` em plans-only). Em Cursor Cloud: `ManagePullRequest` com `draft: false`.
5. O safety net `agent-pr-ready-automerge.yml` arma o **auto-merge nativo do
GitHub** (`enablePullRequestAutoMerge`, rebase) — o servidor mergea quando
o required check `CI (PR) / checks` fica verde; nada a armar.
6. No merge, o workflow `issue-done-on-main-merge.yml` flipa `done`/`in-prod`
**no Forgejo** (lê o body do PR via API do GitHub, escreve no tracker por
`FORGEJO_API_TOKEN`). Comente na Issue o desfecho em uma linha.
## Deltas por ator

View file

@ -1,57 +0,0 @@
# Safety net: every same-repo PR to main must be Ready + auto-merge.
# Covers Cloud ManagePullRequest defaulting to draft, agents that forget to
# arm the merge, and human/worktree PRs (the OPS52 incident was a human PR
# whose merge failed silently). Draft PRs from non-`cursor/*` branches are
# never touched — the draft flag is the actor's veto (the CLI skips them).
# Fork PRs are excluded (the repo is public; a merged fork PR would deploy).
#
# Forgejo port (OPS50): Forgejo's API has no auto-merge scheduling, so the
# workflow runs scripts/forgejo-pr-automerge.mjs (plain Node): marks draft →
# ready, waits for the commit statuses (the branch-protection `checks` gate),
# then merges by rebase. Same semantics as `gh pr merge --auto --rebase`,
# implemented as poll + merge. GitHub's cancel-workflow-on-failure action was
# dropped (no GITHUB_RUN_ID plumbing on Forgejo).
#
# OPS61: the job injects FORGEJO_API_TOKEN. The merge POST performs a git push
# of the rebased branch to main; Forgejo's pre-receive hook rejects that push
# when the caller's user has no repo write — and the native GITHUB_TOKEN acts
# as the Actions system user (no write, and the same 403 that broke issue
# label flips). Every pre-OPS61 "auto-merge" that ended success had actually
# found the PR already merged manually; the CLI's own merge attempts died on
# 409 "User permission denied for writing".
name: PR Ready + auto-merge
on:
pull_request:
types: [opened, reopened, synchronize, ready_for_review, converted_to_draft]
branches: [main]
jobs:
ready-automerge:
# Same-repo only. This MUST stay in the `if:` (pre-checkout): for a fork
# PR, actions/checkout checks out the fork's head, so a same-repo guard
# inside the CLI would already be running the fork's own script — RCE with
# the Actions token. Fork PRs are excluded here because the repo is public
# and a merged fork PR would deploy attacker code to production. The draft
# policy lives in the CLI (skips non-`cursor/*` drafts) because there the
# PR state is re-read from the API, not from webhook payload fields.
if: >-
github.event.pull_request.state == 'open' &&
github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
# OPS61: the CLI now waits for the full CI (PR) cascade (the `checks`
# rollup posts only after every ci-pr job, incl. e2e shards) — 55 min keeps
# 10 min of slack over the CLI's 45 min timeout.
timeout-minutes: 55
env:
FORGEJO_API_TOKEN: ${{ secrets.FORGEJO_API_TOKEN }}
steps:
- uses: actions/checkout@v5
# Script is plain Node (stdlib) — no pnpm. Disable package-manager-cache:
# setup-node@v5 defaults to true and looks for pnpm before pnpm/action-setup.
- uses: actions/setup-node@v5
with:
node-version: 24
package-manager-cache: false
- name: Mark Ready and merge when checks pass
run: node scripts/forgejo-pr-automerge.mjs --pr "${{ github.event.pull_request.number }}"

View file

@ -1,229 +0,0 @@
# Post-merge verifier on `main`, in a fixed 30-minute window (OPS65): the
# workflow no longer fires on every push. The `schedule` trigger polls every
# 30 min and a cheap gate (`gate`, runs-on host, no pnpm) classifies whether
# anything that would enter the docker build context changed since the
# revision running on the homeserver (the `org.opencontainers.image.revision`
# label of the running container, read over ssh).
# - Deployed == HEAD → suite + deploy skip; the run stays green.
# - Anything else (production change, unknown deployed state, dispatch) →
# full suite; deploy in the same window.
# - `workflow_dispatch` always runs the full pipeline (explicit operator
# intent); the deploy is idempotent via the "already deployed" guard in
# scripts/deploy-homeserver.sh.
#
# Fail-open: an unreadable/unknown deployed revision makes the gate report
# production=true, so the suite ALWAYS verifies main and a quiet day only
# skips when we KNOW deployed == HEAD. A gate that cannot classify at all
# (git/.dockerignore error) exits non-zero and turns the run RED — never a
# silent green blackout. The deploy job only runs when the gate succeeded
# AND production changed AND checks are green — never deploy blind.
#
# When the suite is green, the `deploy` job publishes teqo-1313 on the
# homeserver (OPS53): it streams scripts/deploy-homeserver.sh to the homeserver
# over SSH; the remote script builds the standalone image at the pushed SHA,
# pushes it to the local registry (localhost:5000), applies pending migrations
# through the compose maintenance service and rolls the container out.
#
# SINGLE sequential job (OPS62, X1): one container, one checkout, one pnpm
# install, one runner slot. Fail-fast is STRUCTURAL — a failed step skips
# every step after it, the job fails, the run fails and the slot is released
# immediately. No cancel-run API (Forgejo 9.0.3 has none), no watchdog job,
# no upgrades. `deploy` (needs: [checks]) never starts on a red run.
#
# Forgejo port (OPS50): Postgres services live on the job network and are
# reached by NAME (postgres-int / postgres-build on 5432) — no host publish,
# so concurrent runs never collide on host ports (OPS62 X1). No
# `permissions:` (Forgejo issues one native GITHUB_TOKEN) and no
# `concurrency:` (unsupported). `gh workflow run` requeues became
# `node scripts/forgejo-dispatch.mjs`.
name: CI
on:
schedule:
- cron: '*/30 * * * *'
workflow_dispatch:
env:
PAYLOAD_SECRET: test-only-secret-not-used-in-production
NEXT_PUBLIC_SITE_URL: http://localhost:3000
jobs:
gate:
name: gate production change
# Host runner: needs `ssh homeserver` (same environment as the deploy
# job — the key lives on the workstation host) and a full clone to diff
# <deployed-rev>..HEAD. Main + dispatch only: a branch dispatch must not
# run that branch's code on the host — the skipped gate makes the suite
# run fail-open on ubuntu containers instead (test-only secrets).
if: github.ref == 'refs/heads/main'
runs-on: host
timeout-minutes: 10
outputs:
production: ${{ steps.classify.outputs.production }}
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0
- uses: actions/setup-node@v5
with:
node-version: 24
package-manager-cache: false
- name: Read deployed revision and classify production change
id: classify
run: |
set -euo pipefail
# Truth = the RUNNING container's revision label (the compose file
# can lie after a failed rollback — it is swapped before the
# rollout). Empty/malformed = unknown deployed state → the
# classifier reports production=true (full run, green).
deployed_sha="$(ssh homeserver "docker inspect -f '{{index .Config.Labels \"org.opencontainers.image.revision\"}}' teqo-1313 2>/dev/null" || true)"
if ! printf '%s' "$deployed_sha" | grep -Eq '^[0-9a-f]{40}$'; then
deployed_sha=""
fi
echo "deployed_sha: ${deployed_sha:-<none>}"
out="$(node scripts/ci-classify-production.mjs --deployed "$deployed_sha")"
printf '%s\n' "$out"
echo "production=$(printf '%s' "$out" | node -pe 'JSON.parse(require("fs").readFileSync(0,"utf8")).production')" >> "$GITHUB_OUTPUT"
checks:
runs-on: ubuntu-latest
timeout-minutes: 50
needs: [gate]
# Fail-open: an errored gate never skips the verifier; only a gate that
# succeeded AND found no production change skips the suite.
if: needs.gate.result != 'success' || needs.gate.outputs.production == 'true'
services:
# Services are reached by NAME on the job network (no host publish —
# OPS62 X1): concurrent runs never collide on host ports, which the
# single long-lived job would hold for the whole run. Two isolated
# services — int and build+e2e — keep e2e from inheriting int fixture
# residue. Forgejo 9 does not expand expressions in `services.ports`,
# so a per-run port scheme was dropped in favour of the job-network DNS.
postgres-int:
image: postgres:17-alpine
env:
POSTGRES_USER: teqo
POSTGRES_PASSWORD: teqo
POSTGRES_DB: teqo_test
options: >-
--health-cmd "pg_isready -U teqo"
--health-interval 5s
--health-timeout 5s
--health-retries 12
postgres-build:
image: postgres:17-alpine
env:
POSTGRES_USER: teqo
POSTGRES_PASSWORD: teqo
POSTGRES_DB: teqo_test
options: >-
--health-cmd "pg_isready -U teqo"
--health-interval 5s
--health-timeout 5s
--health-retries 12
steps:
- uses: actions/checkout@v5
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v5
with:
node-version: 24
cache: pnpm
- run: pnpm install --frozen-lockfile
- name: Check test file locations
run: node scripts/check-test-locations.mjs
- name: Lint
run: pnpm lint
- name: Format check
run: pnpm format:check
- name: Typecheck
run: pnpm typecheck
- name: Knip
run: pnpm knip
- name: Check cycles
run: pnpm check:cycles
- name: Unit tests (full suite)
run: pnpm test:unit
- name: Prepare database URL (int service)
run: |
set -euo pipefail
echo "DATABASE_URL=postgresql://teqo:teqo@postgres-int:5432/teqo_test" >> "$GITHUB_ENV"
# vitest re-loads .env.test over the process env, so the service URL
# must win through the .env.test.local layer (same mechanism
# `pnpm worktree next` uses). OPS50.
echo "DATABASE_URL=postgresql://teqo:teqo@postgres-int:5432/teqo_test" > .env.test.local
- name: Migrate (int database)
run: pnpm migrate
- name: Seed minimal (int database)
run: pnpm db:seed:minimal
- name: Integration tests (full suite)
env:
# Self-hosted runner has 8 CPUs; vitest forks ≈ CPUs and each fork
# holds a Payload pool (max 10) → overflows the service Postgres' 100
# clients ("too many clients already"). 4 forks stay well under. The
# unit step above stays CPU-relative (no DB).
VITEST_MAX_WORKERS: 4
run: pnpm test:int
- name: Prepare database URL (build/e2e service)
run: |
set -euo pipefail
echo "DATABASE_URL=postgresql://teqo:teqo@postgres-build:5432/teqo_test" >> "$GITHUB_ENV"
echo "DATABASE_URL=postgresql://teqo:teqo@postgres-build:5432/teqo_test" > .env.test.local
- name: Migrate (build/e2e database)
run: pnpm migrate
- name: Seed minimal (build/e2e database)
run: pnpm db:seed:minimal
- name: Build (against the service database)
run: pnpm build
- name: Cache Playwright browsers
uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
playwright-${{ runner.os }}-
- name: Install Playwright chromium
run: pnpm exec playwright install --with-deps chromium
- name: Build for e2e
# Same dir the Playwright webServer serves (playwright.config.ts
# falls back to '.next-e2e'; gate-ci.mjs builds there too).
run: pnpm build
env:
NEXT_DIST_DIR: .next-e2e
- name: E2E tests (full suite, single job, 4 workers)
# Explicit CI workers; the local default stays 2 (playwright.config.ts).
# 4 workers in one process replace the former 2 shards × 2 workers.
# No `--` separator: pnpm would forward it verbatim to playwright and
# the flag would be parsed as a file pattern ("No tests found").
env:
PLAYWRIGHT_WORKERS: 4
run: pnpm test:e2e
deploy:
name: deploy homeserver (teqo-1313)
# OPS53. Runs directly on the runner HOST (not a job container): the
# deploy needs the workstation's SSH key to reach the homeserver
# (`ssh homeserver`) and streams the whole remote build/migrate/rollout
# to this job's logs. Deliberately wired ONLY here — main window +
# workflow_dispatch — never in ci-pr.yml: a host job is code execution as
# `fsolla` on the workstation, and main only ever moves through a merged
# PR whose full suite was already green.
# OPS65: double-gated — the verifier must be green AND the gate must have
# found a production change. The explicit `if` prevents both "deploy
# blind on a failed gate" and "deploy with a red suite" (a bare output
# condition would run despite failed needs).
needs: [checks, gate]
if: needs.checks.result == 'success' && needs.gate.outputs.production == 'true'
runs-on: host
timeout-minutes: 60
steps:
- uses: actions/checkout@v5
- name: Deploy teqo-1313 on the homeserver
run: |
set -euo pipefail
ssh homeserver "bash -s -- $GITHUB_SHA" < scripts/deploy-homeserver.sh

View file

@ -1,30 +0,0 @@
# Main merge flips claimed issues to done + in-prod.
# Forgejo port (OPS50): the gh loop became scripts/forgejo-issue-transition.mjs
# (plain Node, reads the PR body from the API itself).
# OPS61: the job injects FORGEJO_API_TOKEN — the native GITHUB_TOKEN 403s on
# issue label writes, and the flip was silently skipped (script logged the 403
# and the job still ended success). The fork guard is the same class as
# agent-pr-ready-automerge.yml: on a merged fork PR the checkout pulls the
# fork's own script, which would run with the repo PAT (RCE).
name: Main merge flips claimed issues to done + in-prod
on:
pull_request:
types: [closed]
jobs:
issue-done:
if: github.event.pull_request.base.ref == 'main' && github.event.pull_request.merged == true && github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
env:
FORGEJO_API_TOKEN: ${{ secrets.FORGEJO_API_TOKEN }}
steps:
- uses: actions/checkout@v5
# Script is plain Node (stdlib) — no pnpm. Disable package-manager-cache:
# setup-node@v5 defaults to true and looks for pnpm before pnpm/action-setup.
- uses: actions/setup-node@v5
with:
node-version: 24
package-manager-cache: false
- name: Flip Closes/Fixes #N issues to done and in-prod
run: node scripts/forgejo-issue-transition.mjs --pr "${{ github.event.pull_request.number }}"

View file

@ -1,44 +0,0 @@
# Safety net OPS18: when a PR merges to main with `Related #N`, promote Issues
# that are still awaiting their intention plan (`blocked` + docs/plans/ link)
# to `ready`. Idempotent with `pnpm agent:ready` (agent happy path).
#
# Does NOT run on open PRs — only after merge — so the claim race stays closed.
# Job is gated on Related # in the body so non-plan merges skip checkout/Node.
# Forgejo port (OPS50): script reads the PR via the Forgejo API (plain Node).
# OPS61: the job injects FORGEJO_API_TOKEN — the native GITHUB_TOKEN 403s on
# issue label writes (same silent-skip failure as issue-done-on-main-merge).
# Fork guard: on a merged fork PR the checkout pulls the fork's own script,
# which would run with the repo PAT (RCE — same class as
# agent-pr-ready-automerge.yml).
name: Promote plan-issue Related on main merge
on:
pull_request:
types: [closed]
branches: [main]
jobs:
plan-issue-ready:
if: >-
github.event.pull_request.merged == true &&
github.event.pull_request.head.repo.full_name == github.repository &&
(
contains(github.event.pull_request.body, 'Related #') ||
contains(github.event.pull_request.body, 'related #') ||
contains(github.event.pull_request.body, 'RELATED #')
)
runs-on: ubuntu-latest
timeout-minutes: 5
env:
FORGEJO_API_TOKEN: ${{ secrets.FORGEJO_API_TOKEN }}
steps:
- uses: actions/checkout@v5
# Script is plain Node (stdlib) — no pnpm. Disable
# package-manager-cache: setup-node@v5 defaults to true and looks for
# pnpm (packageManager in package.json) before pnpm/action-setup.
- uses: actions/setup-node@v5
with:
node-version: 24
package-manager-cache: false
- name: Promote Related #N awaiting plan → ready
run: node scripts/agent-promote-related-on-merge.mjs --pr "${{ github.event.pull_request.number }}"

View file

@ -0,0 +1,64 @@
# Safety net (OPS71 — GitHub): every same-repo PR to main must be Ready +
# auto-merge armed. Covers Cloud ManagePullRequest defaulting to draft,
# agents that forget to arm the merge, and human/worktree PRs (the OPS52
# incident was a human PR whose merge failed silently). Draft PRs from
# non-`cursor/*` branches are never touched — the draft flag is the actor's
# veto (the CLI skips them). Fork PRs are excluded (the repo is public; a
# merged fork PR would deploy) — same RCE-guard class as the other workflows.
#
# GitHub's NATIVE auto-merge replaces the Forgejo-era poll CLI (removed in
# OPS71 Fase 2): `enablePullRequestAutoMerge` (GraphQL, REBASE —
# the repo's canonical merge style) is server-side — GitHub only merges when
# every required check (the `CI (PR) / checks` rollup of ci-pr.yml, OPS62) is
# green and the PR is mergeable. The OPS64 "rollup can lie" defense is
# structural here: a single sequential job posts one honest check-run and the
# server enforces it — nothing to poll, nothing to trust.
#
# Permissions: contents+pull-requests write — enough for PATCH draft:false
# and the GraphQL auto-merge mutation with the built-in GITHUB_TOKEN.
name: PR Ready + auto-merge
on:
pull_request:
types: [opened, reopened, synchronize, ready_for_review, converted_to_draft]
branches: [main]
permissions:
contents: write
pull-requests: write
jobs:
ready-automerge:
# Same-repo only. This MUST stay in the `if:` (pre-checkout): for a fork
# PR, actions/checkout checks out the fork's head, so a same-repo guard
# inside the CLI would already be running the fork's own script — RCE
# with the Actions token. The draft policy lives in the CLI (skips
# non-`cursor/*` drafts) because there the PR state is re-read from the
# API, not from webhook payload fields.
if: >-
github.event.pull_request.state == 'open' &&
github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v5
# Script is plain Node (stdlib) — no pnpm. Disable package-manager-cache:
# setup-node@v5 defaults to true and looks for pnpm before pnpm/action-setup.
- uses: actions/setup-node@v5
with:
node-version: 24
package-manager-cache: false
- name: Mark Ready and arm auto-merge
env:
# Explicit `github.token` — the built-in GITHUB_TOKEN env is not
# guaranteed to be injected on every event path; passing it via the
# documented context is the robust form.
GITHUB_TOKEN: ${{ github.token }}
# OPS71-FLIP: arming with the built-in GITHUB_TOKEN makes the merge
# happen as github-actions[bot], and GitHub's anti-recursion never
# creates workflow runs for events triggered by GITHUB_TOKEN actions
# — the post-merge `closed` flips would silently never run (PR #746).
# AUTOMERGE_PAT (real user PAT) attributes the merge to a user, so
# the `closed` event creates runs again. Fail-closed in the CLI.
AUTOMERGE_PAT: ${{ secrets.AUTOMERGE_PAT }}
run: node scripts/github-pr-automerge.mjs --pr "${{ github.event.pull_request.number }}"

View file

@ -11,6 +11,8 @@ on:
description: Cursor run id (run-…) optional cancel first
required: false
type: string
permissions:
contents: read
jobs:
archive:
runs-on: ubuntu-latest

View file

@ -1,53 +1,71 @@
# PR gate: feature → main. SINGLE sequential job (OPS62, X1): one container,
# one checkout, one pnpm install, one runner slot. Fail-fast is STRUCTURAL —
# a failed step skips every step after it, the job fails, the run fails and
# the slot is released immediately. No cancel-run API (Forgejo 9.0.3 has
# none), no watchdog job, no upgrades: with one job there are no siblings to
# cancel. Steps keep their own logs, so diagnosis is intact after a failure.
# The job id `checks` preserves the `CI (PR) / checks` status context the
# automerger waitForChecks and branch protection rely on (OPS61).
# PR gate: feature → main (OPS71 — GitHub Actions). SINGLE sequential job
# (OPS62, X1): one container, one checkout, one pnpm install, one runner slot.
# Fail-fast is STRUCTURAL — a failed step skips every step after it, the job
# fails and the run fails. GitHub-native `concurrency` cancels the in-flight
# run when the PR gets a new push (the Forgejo era had no cancel API; here it
# is free and matches the OPS62 "one slot per PR" spirit).
#
# The job id `checks` preserves the `CI (PR) / checks` check-run that the
# branch protection rule and the native auto-merge rely on (OPS71): GitHub
# names it <workflow name> / <job name> — keep both literals frozen. GitHub's
# auto-merge is server-side: it only merges with every required check green,
# so the OPS64 "rollup can lie" defense is structural here (single job posts
# one honest check-run; no matrix, no shards).
#
# Job-level skips come from the `scope` classifier (scripts/ci-scope.mjs) run
# as a step; its outputs drive `if:` on the heavy steps (same modes as the
# former per-job cascade: none/changed/full).
# as a step; its outputs drive `if:` on the heavy steps (code/build/test:
# none/changed/full; e2e: none/selected/full). e2e policy (OPS72): the PR
# runs ONLY the blast radius — the specs the classifier detected as touched
# (mode `selected`), never `full`. A high-risk diff (schema/lockfile/test
# harness) classifies as `full` → the PR simply runs no e2e: the local agent
# mirror (`pnpm test:e2e:affected`) runs full in that same case, and the
# deploy `verify` job runs the full suite before publishing.
#
# Schema migrations are NOT serialized across PRs (migration-lock removed
# 2026-08-12): a stale chain is caught by `pnpm migrate` + `test:int` on every
# PR and on main (ci.yml), incl. campaignMigrationReconciliation — the check's
# hard fail deadlocked every open schema PR the moment two coexisted.
# PR, incl. campaignMigrationReconciliation.
#
# Forgejo port (OPS50): Postgres services live on the job network and are
# reached by NAME (postgres-int / postgres-build on 5432) — no host publish,
# so concurrent runs never collide on host ports (OPS62 X1). Two isolated
# services keep e2e from inheriting int fixture residue.
# Fork-PRs are excluded at job level (`if:` same-repo): the repo is public and
# a fork PR would burn hosted minutes running arbitrary code. Same guard class
# as the RCE guard on the post-merge workflows.
#
# Postgres services (OPS71 — GitHub hosted): jobs run directly on the runner
# VM (NOT inside the job-container network of the Forgejo era), so services
# are reached at localhost on PUBLISHED host ports. Each hosted run is its own
# VM, so fixed ports never collide across runs. Two isolated services — int
# on 5432 and build+e2e on 5433 — keep e2e from inheriting int fixture
# residue.
name: CI (PR)
on:
pull_request:
branches: [main]
permissions:
contents: read
concurrency:
group: ci-pr-${{ github.head_ref }}
cancel-in-progress: true
env:
PAYLOAD_SECRET: test-only-secret-not-used-in-production
NEXT_PUBLIC_SITE_URL: http://localhost:3000
jobs:
checks:
if: github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
timeout-minutes: 50
services:
# Services are reached by NAME on the job network (no host publish —
# OPS62 X1): concurrent runs never collide on host ports, which the
# single long-lived job would hold for the whole run. Two isolated
# services — int and build+e2e — keep e2e from inheriting int fixture
# residue (same isolation the former jobs had). Forgejo 9 does not
# expand expressions in `services.ports`, so a per-run port scheme was
# dropped in favour of the job-network DNS.
postgres-int:
image: postgres:17-alpine
env:
POSTGRES_USER: teqo
POSTGRES_PASSWORD: teqo
POSTGRES_DB: teqo_test
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U teqo"
--health-interval 5s
@ -59,6 +77,8 @@ jobs:
POSTGRES_USER: teqo
POSTGRES_PASSWORD: teqo
POSTGRES_DB: teqo_test
ports:
- 5433:5432
options: >-
--health-cmd "pg_isready -U teqo"
--health-interval 5s
@ -133,11 +153,11 @@ jobs:
- name: Prepare database URL (int service)
run: |
set -euo pipefail
echo "DATABASE_URL=postgresql://teqo:teqo@postgres-int:5432/teqo_test" >> "$GITHUB_ENV"
echo "DATABASE_URL=postgresql://teqo:teqo@localhost:5432/teqo_test" >> "$GITHUB_ENV"
# vitest re-loads .env.test over the process env, so the service URL
# must win through the .env.test.local layer (same mechanism
# `pnpm worktree next` uses). OPS50.
echo "DATABASE_URL=postgresql://teqo:teqo@postgres-int:5432/teqo_test" > .env.test.local
# `pnpm worktree next` uses).
echo "DATABASE_URL=postgresql://teqo:teqo@localhost:5432/teqo_test" > .env.test.local
- name: Migrate (int database)
run: pnpm migrate
- name: Seed minimal (int database)
@ -146,10 +166,10 @@ jobs:
- name: Integration tests (full suite)
if: steps.scope.outputs.test_mode == 'full'
env:
# Self-hosted runner has 8 CPUs; vitest forks ≈ CPUs and each fork
# holds a Payload pool (max 10) → overflows the service Postgres' 100
# clients ("too many clients already", CI run 180). 4 forks stay
# well under. The unit step above stays CPU-relative (no DB).
# vitest forks ≈ CPUs and each fork holds a Payload pool (max 10) →
# overflows the service Postgres' 100 clients ("too many clients
# already"). 4 forks stay well under. The unit step above stays
# CPU-relative (no DB).
VITEST_MAX_WORKERS: 4
run: pnpm test:int
- name: Integration tests (changed since PR base)
@ -159,16 +179,16 @@ jobs:
run: pnpm test:int -- --changed origin/${{ github.base_ref }} --passWithNoTests
- name: Prepare database URL (build/e2e service)
if: steps.scope.outputs.build_mode != 'none' || steps.scope.outputs.e2e_mode != 'none'
if: steps.scope.outputs.build_mode != 'none' || steps.scope.outputs.e2e_mode == 'selected'
run: |
set -euo pipefail
echo "DATABASE_URL=postgresql://teqo:teqo@postgres-build:5432/teqo_test" >> "$GITHUB_ENV"
echo "DATABASE_URL=postgresql://teqo:teqo@postgres-build:5432/teqo_test" > .env.test.local
echo "DATABASE_URL=postgresql://teqo:teqo@localhost:5433/teqo_test" >> "$GITHUB_ENV"
echo "DATABASE_URL=postgresql://teqo:teqo@localhost:5433/teqo_test" > .env.test.local
- name: Migrate (build/e2e database)
if: steps.scope.outputs.build_mode != 'none' || steps.scope.outputs.e2e_mode != 'none'
if: steps.scope.outputs.build_mode != 'none' || steps.scope.outputs.e2e_mode == 'selected'
run: pnpm migrate
- name: Seed minimal (build/e2e database)
if: steps.scope.outputs.build_mode != 'none' || steps.scope.outputs.e2e_mode != 'none'
if: steps.scope.outputs.build_mode != 'none' || steps.scope.outputs.e2e_mode == 'selected'
run: pnpm db:seed:minimal
- name: Build (against the local service database)
@ -176,7 +196,7 @@ jobs:
run: pnpm build
- name: Cache Playwright browsers
if: steps.scope.outputs.e2e_mode != 'none'
if: steps.scope.outputs.e2e_mode == 'selected'
uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
@ -184,26 +204,23 @@ jobs:
restore-keys: |
playwright-${{ runner.os }}-
- name: Install Playwright chromium
if: steps.scope.outputs.e2e_mode != 'none'
if: steps.scope.outputs.e2e_mode == 'selected'
run: pnpm exec playwright install --with-deps chromium
- name: Build for e2e
# Selected mode needs the build too (the Playwright webServer serves
# .next-e2e; the fallback `.next-e2e` dir is what gate-ci.mjs builds).
if: steps.scope.outputs.e2e_mode != 'none'
# the `.next-e2e` production build).
if: steps.scope.outputs.e2e_mode == 'selected'
run: pnpm build
env:
NEXT_DIST_DIR: .next-e2e
- name: E2E tests (full suite, single job, 4 workers)
if: steps.scope.outputs.e2e_mode == 'full'
- name: E2E tests (blast radius — manifest-selected specs)
if: steps.scope.outputs.e2e_mode == 'selected'
# OPS72: the PR never runs e2e full — mode `full` (high-risk diff)
# skips this step by design (local mirror + deploy verify cover it).
# Explicit CI workers; the local default stays 2 (playwright.config.ts).
# 4 workers in one process replace the former 2 shards × 2 workers.
# No `--` separator: pnpm would forward it verbatim to playwright and
# the flag would be parsed as a file pattern ("No tests found").
env:
PLAYWRIGHT_WORKERS: 4
run: pnpm test:e2e
- name: E2E tests (manifest-selected specs)
if: steps.scope.outputs.e2e_mode == 'selected'
env:
PLAYWRIGHT_WORKERS: 4
# The `--` separates pnpm from the scope paths: pnpm consumes it and
# playwright receives the bare spec paths (no flag parsing drift).
run: pnpm test:e2e -- ${{ steps.scope.outputs.e2e_specs }}

170
.github/workflows/deploy.yml vendored Normal file
View file

@ -0,0 +1,170 @@
# Manual production deploy (OPS71 — GitHub Actions). `workflow_dispatch`
# only: there is NO automatic main verifier anymore (ci.yml eliminated in the
# gate 2026-08-19) — publishing is a deliberate operator act, and this
# workflow runs the FULL suite (no scope skips, e2e included) before the
# deploy job touches the homeserver.
#
# TWO jobs, same workflow:
# verify — ubuntu-latest (hosted): full cascade (guards for PR contracts are
# left out — they gate PRs, not production).
# deploy — needs: [verify], runs-on [self-hosted, homeserver]: the GitHub
# self-hosted runner ON the homeserver executes
# scripts/deploy-homeserver.sh LOCALLY (no SSH; outbound-only
# runner, works behind the Cloudflare tunnel). The hosted job
# never reaches the homeserver; the self-hosted job never counts
# hosted minutes.
#
# The production build CANNOT leave the homeserver (migrate pre-build on the
# prod DB — OPS66 — and the compose network / local registry localhost:5000),
# which is exactly why the hosted job only verifies and the self-hosted job
# only builds + deploys. Deploy is idempotent ("already deployed" guard in
# deploy-homeserver.sh) and serialized by flock on the homeserver.
#
# The `deploy` job fetches the repo from github.com (public) — TEQO_REPO_URL
# in deploy-homeserver.sh now defaults there; the script's HEAD guard only
# deploys when the dispatched SHA is main's HEAD (a stale dispatch skips).
name: Deploy (manual)
on:
workflow_dispatch:
permissions:
contents: read
env:
PAYLOAD_SECRET: test-only-secret-not-used-in-production
NEXT_PUBLIC_SITE_URL: http://localhost:3000
jobs:
verify:
runs-on: ubuntu-latest
timeout-minutes: 50
services:
# OPS71 — GitHub hosted: jobs run on the runner VM, so services are
# reached at localhost on PUBLISHED host ports (fixed — each run is its
# own VM). Two isolated services — int on 5432, build+e2e on 5433 —
# keep e2e from inheriting int fixture residue.
postgres-int:
image: postgres:17-alpine
env:
POSTGRES_USER: teqo
POSTGRES_PASSWORD: teqo
POSTGRES_DB: teqo_test
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U teqo"
--health-interval 5s
--health-timeout 5s
--health-retries 12
postgres-build:
image: postgres:17-alpine
env:
POSTGRES_USER: teqo
POSTGRES_PASSWORD: teqo
POSTGRES_DB: teqo_test
ports:
- 5433:5432
options: >-
--health-cmd "pg_isready -U teqo"
--health-interval 5s
--health-timeout 5s
--health-retries 12
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v5
with:
node-version: 24
cache: pnpm
- run: pnpm install --frozen-lockfile
- name: Check test file locations
run: node scripts/check-test-locations.mjs
- name: Lint
run: pnpm lint
- name: Format check
run: pnpm format:check
- name: Typecheck
run: pnpm typecheck
- name: Knip
run: pnpm knip
- name: Check cycles
run: pnpm check:cycles
- name: Unit tests (full suite)
run: pnpm test:unit
- name: Prepare database URL (int service)
run: |
set -euo pipefail
echo "DATABASE_URL=postgresql://teqo:teqo@localhost:5432/teqo_test" >> "$GITHUB_ENV"
# vitest re-loads .env.test over the process env, so the service URL
# must win through the .env.test.local layer (same mechanism
# `pnpm worktree next` uses).
echo "DATABASE_URL=postgresql://teqo:teqo@localhost:5432/teqo_test" > .env.test.local
- name: Migrate (int database)
run: pnpm migrate
- name: Seed minimal (int database)
run: pnpm db:seed:minimal
- name: Integration tests (full suite)
env:
# vitest forks ≈ CPUs and each fork holds a Payload pool (max 10) →
# overflows the service Postgres' 100 clients. 4 forks stay well
# under. The unit step above stays CPU-relative (no DB).
VITEST_MAX_WORKERS: 4
run: pnpm test:int
- name: Prepare database URL (build/e2e service)
run: |
set -euo pipefail
echo "DATABASE_URL=postgresql://teqo:teqo@localhost:5433/teqo_test" >> "$GITHUB_ENV"
echo "DATABASE_URL=postgresql://teqo:teqo@localhost:5433/teqo_test" > .env.test.local
- name: Migrate (build/e2e database)
run: pnpm migrate
- name: Seed minimal (build/e2e database)
run: pnpm db:seed:minimal
- name: Build (against the service database)
run: pnpm build
- name: Cache Playwright browsers
uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
playwright-${{ runner.os }}-
- name: Install Playwright chromium
run: pnpm exec playwright install --with-deps chromium
- name: Build for e2e
# Same dir the Playwright webServer serves (playwright.config.ts
# falls back to '.next-e2e'; gate-ci.mjs builds there too).
run: pnpm build
env:
NEXT_DIST_DIR: .next-e2e
- name: E2E tests (full suite, single job, 4 workers)
# Explicit CI workers; the local default stays 2 (playwright.config.ts).
# No `--` separator: pnpm would forward it verbatim to playwright and
# the flag would be parsed as a file pattern ("No tests found").
env:
PLAYWRIGHT_WORKERS: 4
run: pnpm test:e2e
deploy:
name: deploy homeserver (teqo-1313)
# OPS53 + OPS71. Runs on the GitHub self-hosted runner ON the homeserver
# (labels self-hosted + homeserver): the script executes locally — no SSH
# hop, no workstation involvement. Deliberately wired ONLY here (manual
# dispatch), never in ci-pr.yml: a self-hosted job is code execution on
# the production machine, and only an explicit dispatch with the full
# verify suite green can reach it.
needs: [verify]
if: needs.verify.result == 'success'
runs-on: [self-hosted, homeserver]
timeout-minutes: 60
steps:
- uses: actions/checkout@v5
- name: Deploy teqo-1313 on the homeserver
run: bash scripts/deploy-homeserver.sh "$GITHUB_SHA"

View file

@ -0,0 +1,80 @@
# Main merge flips claimed issues to done + in-prod.
# OPS71: the PR lives on GitHub; the ISSUES stay on the Forgejo tracker. The
# job passes the PR body via PR_BODY (GitHub event payload) and
# scripts/forgejo-issue-transition.mjs flips labels/comments and closes the
# issue on the Forgejo API with the FORGEJO_API_TOKEN PAT (the built-in
# GITHUB_TOKEN cannot touch Forgejo issues — OPS61). The fork guard is the
# same class as agent-pr-ready-automerge.yml: on a merged fork PR the checkout
# pulls the fork's own script, which would run with the repo PAT (RCE).
#
# OPS71-FLIP: `workflow_dispatch` is the MANUAL recovery path. The `closed`
# event does NOT create a run when the merge was performed by
# github-actions[bot] (auto-merge armed with the built-in GITHUB_TOKEN —
# GitHub's anti-recursion; live finding PR #746). Dispatch with the PR number:
# the job fetches the PR via `gh` (same contract: merged + base main +
# same-repo) and runs the same flip.
name: Main merge flips claimed issues to done + in-prod
on:
pull_request:
types: [closed]
workflow_dispatch:
inputs:
pr:
description: 'PR number (manual recovery when the closed event did not create a run)'
required: true
type: number
permissions:
contents: read
pull-requests: read
jobs:
issue-done:
if: >-
(github.event_name == 'pull_request' && github.event.pull_request.base.ref == 'main' && github.event.pull_request.merged == true && github.event.pull_request.head.repo.full_name == github.repository) ||
(github.event_name == 'workflow_dispatch' && inputs.pr != '')
runs-on: ubuntu-latest
env:
FORGEJO_API_TOKEN: ${{ secrets.FORGEJO_API_TOKEN }}
# Explicit base: in the GitHub Actions env, GITHUB_SERVER_URL points at
# github.com and forgejo-api.mjs would resolve the wrong base (410).
FORGEJO_API_URL: https://git.solla.dev/api/v1
GH_TOKEN: ${{ github.token }}
steps:
- uses: actions/checkout@v5
# Script is plain Node (stdlib) — no pnpm. Disable package-manager-cache:
# setup-node@v5 defaults to true and looks for pnpm before pnpm/action-setup.
- uses: actions/setup-node@v5
with:
node-version: 24
package-manager-cache: false
- name: Fetch PR metadata (manual recovery — bot auto-merges skip the closed event)
if: github.event_name == 'workflow_dispatch'
id: pr
run: |
PR="${{ inputs.pr }}"
STATE="$(gh pr view "$PR" --json state -q .state)"
if [ "$STATE" != "MERGED" ]; then
echo "PR #$PR não mergeada (state=$STATE) — skip"
exit 0
fi
BASE="$(gh pr view "$PR" --json baseRefName -q .baseRefName)"
REPO="$(gh pr view "$PR" --json headRepository -q .headRepository.nameWithOwner)"
if [ "$BASE" != "main" ] || [ "$REPO" != "${{ github.repository }}" ]; then
echo "PR #$PR fora do contrato (base=$BASE repo=$REPO — precisa main + same-repo) — skip"
exit 0
fi
BODY="$(gh pr view "$PR" --json body -q .body)"
{
echo "valid=true"
echo "pr_number=$PR"
echo "pr_body<<EOF"
echo "$BODY"
echo "EOF"
} >> "$GITHUB_OUTPUT"
- name: Flip Closes/Fixes #N issues to done and in-prod
if: steps.pr.outputs.valid == 'true' || github.event_name == 'pull_request'
env:
PR_BODY: ${{ steps.pr.outputs.pr_body || github.event.pull_request.body }}
run: node scripts/forgejo-issue-transition.mjs --pr "${{ steps.pr.outputs.pr_number || github.event.pull_request.number }}"

View file

@ -0,0 +1,88 @@
# Safety net OPS18: when a PR merges to main with `Related #N`, promote Issues
# that are still awaiting their intention plan (`blocked` + docs/plans/ link)
# to `ready`. Idempotent with `pnpm agent:ready` (agent happy path). OPS71:
# the PR lives on GitHub, the issues on the Forgejo tracker — the job passes
# the PR body via PR_BODY and scripts/agent-promote-related-on-merge.mjs flips
# labels on the Forgejo API (FORGEJO_API_TOKEN; the built-in GITHUB_TOKEN
# cannot touch Forgejo issues — OPS61).
#
# Does NOT run on open PRs — only after merge — so the claim race stays closed.
# Job is gated on Related # in the body so non-plan merges skip checkout/Node.
# Fork guard: on a merged fork PR the checkout pulls the fork's own script,
# which would run with the repo PAT (RCE — same class as
# agent-pr-ready-automerge.yml).
#
# OPS71-FLIP: `workflow_dispatch` is the MANUAL recovery path — the `closed`
# event does NOT create a run when the merge was performed by
# github-actions[bot] (auto-merge armed with the built-in GITHUB_TOKEN;
# GitHub's anti-recursion, live finding PR #746). Dispatch with the PR number:
# the job fetches the PR via `gh` (same contract: merged + base main +
# same-repo) and runs the same promotion.
name: Promote plan-issue Related on main merge
on:
pull_request:
types: [closed]
branches: [main]
workflow_dispatch:
inputs:
pr:
description: 'PR number (manual recovery when the closed event did not create a run)'
required: true
type: number
permissions:
contents: read
pull-requests: read
jobs:
plan-issue-ready:
if: >-
(github.event_name == 'pull_request' && github.event.pull_request.merged == true && github.event.pull_request.head.repo.full_name == github.repository && (contains(github.event.pull_request.body, 'Related #') || contains(github.event.pull_request.body, 'related #') || contains(github.event.pull_request.body, 'RELATED #'))) ||
(github.event_name == 'workflow_dispatch' && inputs.pr != '')
runs-on: ubuntu-latest
timeout-minutes: 5
env:
FORGEJO_API_TOKEN: ${{ secrets.FORGEJO_API_TOKEN }}
# Explicit base: in the GitHub Actions env, GITHUB_SERVER_URL points at
# github.com and forgejo-api.mjs would resolve the wrong base (410).
FORGEJO_API_URL: https://git.solla.dev/api/v1
GH_TOKEN: ${{ github.token }}
steps:
- uses: actions/checkout@v5
# Script is plain Node (stdlib) — no pnpm. Disable
# package-manager-cache: setup-node@v5 defaults to true and looks for
# pnpm (packageManager in package.json) before pnpm/action-setup.
- uses: actions/setup-node@v5
with:
node-version: 24
package-manager-cache: false
- name: Fetch PR metadata (manual recovery — bot auto-merges skip the closed event)
if: github.event_name == 'workflow_dispatch'
id: pr
run: |
PR="${{ inputs.pr }}"
STATE="$(gh pr view "$PR" --json state -q .state)"
if [ "$STATE" != "MERGED" ]; then
echo "PR #$PR não mergeada (state=$STATE) — skip"
exit 0
fi
BASE="$(gh pr view "$PR" --json baseRefName -q .baseRefName)"
REPO="$(gh pr view "$PR" --json headRepository -q .headRepository.nameWithOwner)"
if [ "$BASE" != "main" ] || [ "$REPO" != "${{ github.repository }}" ]; then
echo "PR #$PR fora do contrato (base=$BASE repo=$REPO — precisa main + same-repo) — skip"
exit 0
fi
BODY="$(gh pr view "$PR" --json body -q .body)"
{
echo "valid=true"
echo "pr_number=$PR"
echo "pr_body<<EOF"
echo "$BODY"
echo "EOF"
} >> "$GITHUB_OUTPUT"
- name: Promote Related #N awaiting plan → ready
if: steps.pr.outputs.valid == 'true' || github.event_name == 'pull_request'
env:
PR_BODY: ${{ steps.pr.outputs.pr_body || github.event.pull_request.body }}
run: node scripts/agent-promote-related-on-merge.mjs --pr "${{ steps.pr.outputs.pr_number || github.event.pull_request.number }}"

File diff suppressed because one or more lines are too long

View file

@ -6,16 +6,16 @@
> **Cheatsheet de operação agentic (humanos)** — o fluxo em 5 linhas:
>
> 1. O agente roda `pnpm agent:claim` (ou `pnpm worktree next --issue N`) e pega a próxima Issue `ready` por prioridade (`prio:P0..P3`).
> 1. O agente roda `pnpm agent:claim` (ou `pnpm worktree next --issue N`) e pega a próxima Issue `ready` por prioridade (`prio:P0..P3`). O **tracker de Issues vive no Forgejo** (`git.solla.dev/fsolla/teqo`); o código/PR/CI vive no **GitHub**.
> 2. Ele implementa, roda o fast gate (`lint + typecheck + unit`) e `pnpm push -u origin HEAD` — abre a PR para `main` (Ready, `Closes #N`).
> 3. CI verde → o safety net `agent-pr-ready-automerge.yml` mergea em `main` por rebase; o verificador `ci.yml` do main roda a suíte completa na janela fixa de 30 min.
> 4. Com mudança de produção → o job `deploy` publica no homeserver (`jorgesolla1313.com.br`, self-hosted) — migrações aplicadas antes do rollout.
> 5. Secrets humanos (uma vez): `FORGEJO_API_TOKEN` no Forgejo; envs de prod em `~/stack/teqo-1313.env` no homeserver. Branch protection de `main` já aplicada (reaplicar: `pnpm configure:branch-protection`).
> 3. CI verde (`CI (PR) / checks`) → o safety net `agent-pr-ready-automerge.yml` arma o **auto-merge nativo** (rebase) — o servidor só mergea com o required check verde.
> 4. Publicar é **manual**: dispatch de `deploy.yml` roda a suíte full (`verify`) e o job `deploy` publica no homeserver (`jorgesolla1313.com.br`, runner self-hosted) — migrações aplicadas antes do rollout. Nada é automático pós-merge.
> 5. Secrets humanos (uma vez): `FORGEJO_API_TOKEN` no GitHub (flips pós-merge no Forgejo); envs de prod em `~/stack/teqo-1313.env` no homeserver. Branch protection de `main` já aplicada (reaplicar: `pnpm configure:branch-protection`).
>
> Comandos: `pnpm agent:claim | agent:register | agent:prioritize | agent:file-miss | worktree next` e `pnpm db:seed:minimal`.
> Labels: estado `ready|in-progress|blocked|done|in-prod`, `prio:P0..P3`, `kind:*`, `needs:migration|consent`, `requirements-changed`.
> **Agente faz sozinho:** claim → implementar → PR → main. **Só humano:** envs do homeserver, branch protection, runbook de rollback.
> Tudo em detalhe: [`docs/AGENT-OPS.md`](docs/AGENT-OPS.md) · CI: `.forgejo/workflows/ci-pr.yml` + `ci.yml`.
> **Agente faz sozinho:** claim → implementar → PR → main. **Só humano:** deploy (dispatch), envs do homeserver, branch protection, runbook de rollback.
> Tudo em detalhe: [`docs/AGENT-OPS.md`](docs/AGENT-OPS.md) · CI: `.github/workflows/ci-pr.yml` · Deploy: `.github/workflows/deploy.yml`.
Teqo starts as the official digital platform for **deputado Jorge Solla** and evolves into a **white-label civic engagement platform** for politicians in Brazil.

View file

@ -1,27 +1,27 @@
# AGENT-OPS — operação do paradigma de agentes paralelos
Uma página. Norte: simplicidade (só Forgejo + Cursor), agente entrega em `main`, deploy de produção só após o verificador full-suite verde.
Uma página. Norte: simplicidade (Forgejo = tracker, GitHub = Actions), agente entrega em `main`, deploy de produção é **manual** com verificação full (OPS71).
## Ambientes de dados (test ladder — nunca misturar)
| Ambiente | Conteúdo | Quem usa |
| ------------------------------------------- | ------------------------------------------------------------ | -------------------------------- |
| **Mínimo** (`teqo_test` local / service CI) | schema migrado + `pnpm db:seed:minimal` (sintético, sem PII) | agentes, Cursor Cloud, CI |
| **Prod** (`teqo_1313`, homeserver) | real | job `deploy` do `ci.yml` (OPS53) |
| Ambiente | Conteúdo | Quem usa |
| ------------------------------------------- | ------------------------------------------------------------ | -------------------------------------------- |
| **Mínimo** (`teqo_test` local / service CI) | schema migrado + `pnpm db:seed:minimal` (sintético, sem PII) | agentes, Cursor Cloud, CI |
| **Prod** (`teqo_1313`, homeserver) | real | job `deploy` do `deploy.yml` (OPS53 + OPS71) |
Agentes **nunca** recebem `DATABASE_URL` de prod e nunca setam `ALLOW_REMOTE_DB`. Não há mais smoke `stage` / `ALLOW_STAGE_TEST_DB` / `ci-stage.yml`.
## Fluxo
```text
claim → feature branch → PR --base main → CI PR green (cascade + skips) → auto-merge em main
main → ci.yml (janela de 30 min, OPS65) full suite verde → job `deploy` (homeserver, OPS53 — merge com mudança de produção == site atualizado em ~30 min + suite/deploy)
claim → feature branch → PR --base main (GitHub) → CI PR green (cascade + skips) → auto-merge nativo em main (rebase)
main → (opcional, deliberado) workflow_dispatch deploy.yml → verify full suite verde → job deploy no homeserver (OPS53)
```
**Skills:** `plan-issue` (intenção + Issues) → `work-issue` (humano: Issue já claimada → impl plan → confirmação → execução) ou `agent-work-issue` (pool: já claimada → impl plan → execução sem pausa) → `/simplify` → `capture-review-debts` → PR `--base main` → `project-status`. `docs/roadmap.md` = legado congelado; fonte canônica = Issues do Forgejo (`git.solla.dev/fsolla/teqo`).
**Skills:** `plan-issue` (intenção + Issues) → `work-issue` (humano: Issue já claimada → impl plan → confirmação → execução) ou `agent-work-issue` (pool: já claimada → impl plan → execução sem pausa) → `/simplify` → `capture-review-debts` → PR `--base main` (GitHub) → `project-status`. `docs/roadmap.md` = legado congelado; fonte canônica = Issues do Forgejo (`git.solla.dev/fsolla/teqo`).
- **Agente faz sozinho:** claim (pool-supervisor no pool; humano via `pnpm agent:claim` fora da sessão — OPS33: `worktree next` claima) → implementa → **`pnpm push`** → PR via API/MCP **Ready** (nunca draft) com `Closes #N` → o safety net `agent-pr-ready-automerge.yml` espera o rollup `CI (PR) / checks` e mergea por rebase. Regra always-on: `.agents/rules/agent-pr-workflow.mdc`. Em Cursor Cloud: `ManagePullRequest` com `draft: false` (o default draft da tool **não** vale neste repo) — o merge continua pelo safety net.
- **Só humano:** envs de produção no homeserver (`~/stack/teqo-1313.env`, fora do repo — o deploy as lê via BuildKit secrets), `pnpm configure:branch-protection` (idempotente; reaplicar se o drift voltar), runbook manual/rollback (`docs/ops/teqo-1313-deploy.md`).
- **Agente faz sozinho:** claim (pool-supervisor no pool; humano via `pnpm agent:claim` fora da sessão — OPS33: `worktree next` claima) → implementa → **e2e local afetado** (OPS72, discricionário: roda os e2e que criou + os da mesma superfície — `pnpm test:e2e:affected`; ver skill) → **`pnpm push`** (origin = GitHub) → PR via `node scripts/github-pr.mjs` **Ready** (nunca draft) com `Closes #N` → o safety net `agent-pr-ready-automerge.yml` arma o **auto-merge nativo do GitHub** (`enablePullRequestAutoMerge`, rebase) — o servidor mergea quando o required check `CI (PR) / checks` fica verde. Regra always-on: `.agents/rules/agent-pr-workflow.mdc`. Em Cursor Cloud: `ManagePullRequest` com `draft: false` (o default draft da tool **não** vale neste repo) — o merge continua pelo safety net.
- **Só humano:** envs de produção no homeserver (`~/stack/teqo-1313.env`, fora do repo — o deploy as lê via BuildKit secrets), `GITHUB_TOKEN` local (PAT) para os scripts GitHub, secrets no repo GitHub, `pnpm configure:branch-protection` (idempotente; reaplicar se o drift voltar), runbook manual/rollback (`docs/ops/teqo-1313-deploy.md`).
### Dono do PR, dono do CI
@ -36,28 +36,29 @@ main → ci.yml (janela de 30 min, OPS65) full suite verde → job `deploy` (hom
| Comando | Faz |
| ------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| `pnpm agent:claim [-- --dry-run]` | Fila ready+unblocked por prio → `in-progress` + brief |
| `pnpm agent:claim [-- --dry-run]` | Fila ready+unblocked por prio → `in-progress` + brief (Forgejo) |
| `pnpm worktree next [--issue N] [--stay]` | Claim determinístico (mesma fila/lock do claim) → worktree + provisionamento |
| `pnpm agent:register -- --id X --title T [--prio P1] [--depends A,B] [--plan docs/plans/x.md] [--model slug]` | Cria Issue (`--plan` ⇒ `blocked` até `agent:ready`; sem plano ⇒ `ready`) |
| `pnpm agent:register -- --id X --title T [--prio P1] [--depends A,B] [--plan docs/plans/x.md] [--model slug]` | Cria Issue no Forgejo (`--plan` ⇒ `blocked` até `agent:ready`; sem plano ⇒ `ready`) |
| `pnpm agent:ready -- --issue N[,N…]` | Pós-merge do plano: `blocked`→`ready` (só Issues com link `docs/plans/`) |
| `pnpm agent:status` | Overview / fila / mermaid |
| `pnpm agent:prioritize -- <issue> <P0..P3>` | Troca `prio:*` |
| `pnpm agent:file-miss -- --title ...` | Issue `kind:agent-miss` |
| `pnpm agent:pool -- status\|tick --dry-run\|doctor` | Pool Cloud **dormente (OPS65)** — o workflow `agent-pool.yml` foi removido; dispatch falha 404; scripts/skill ficam para histórico |
| `pnpm push [-- -u origin HEAD …]` | Canônico: ensure-deps + gate:push + push |
| `pnpm configure:branch-protection [-- --dry-run]` | Proteção de `main`: required status check `CI (PR) / checks` (rollup do ci-pr; idempotente — cria/atualiza/no-op) |
| `pnpm push [-- -u origin HEAD …]` | Canônico: ensure-deps + gate:push + push para o **GitHub** (origin) |
| `GITHUB_TOKEN=… node scripts/github-pr.mjs --head <b> --title <t> --body-file <f>` | Abre PR no **GitHub** (Ready, base `main`, nunca draft) |
| `GITHUB_TOKEN=… pnpm configure:branch-protection [-- --dry-run]` | Proteção de `main` no **GitHub**: required check `CI (PR) / checks` (idempotente — cria/atualiza/no-op) |
| `pnpm db:seed:minimal` | DB mínimo sintético |
Labels: `ready|in-progress|blocked|done|in-prod`, `prio:*`, `kind:*`, `needs:*`.
Labels (Forgejo): `ready|in-progress|blocked|done|in-prod`, `prio:*`, `kind:*`, `needs:*`.
**plan-issue (OPS17 + OPS18):** gate pós-overview fecha o lote antes de qualquer Issue/PR; register com `--plan` nasce `blocked` (não entra na fila do claim/pool); após o PR de planos mergear em `main` (`Related #N`), promote dual idempotente: (A) `pnpm agent:ready` na sessão; (B) Action `plan-issue-ready-on-main-merge.yml` lê `Related #N` e promove Issues ainda “aguardando plano” (`blocked` + link `docs/plans/`). Chores sem plano continuam nascendo `ready`.
## Contrato de PR
1. Schema → atualizar `db:seed:minimal` no mesmo PR (`needs:migration`).
2. Migrations NÃO são serializadas entre PRs: o CI (migrate + int, incl. `campaignMigrationReconciliation`) valida a cadeia em todo PR e na main; rebase antes de `migrate:create` continua obrigatório.
3. Ready + auto-merge: `pnpm push -u origin HEAD` → PR via API/MCP (Ready, base `main`, `Closes #N`) → o safety net `agent-pr-ready-automerge.yml` (`scripts/forgejo-pr-automerge.mjs`) espera o rollup `CI (PR) / checks` e mergea por rebase — não há `gh` em nenhuma máquina (OPS50), o safety net é quem espera os checks. **Merge é por rebase** (nunca merge commit): preserva a linha histórica limpa do `main` e é o modo que o CI guarda contra conflitos latentes.
4. **Changelog da entrega (OPS44):** toda entrega escreve `docs/changelog/<data>-<id>.md` (ex. `2026-08-13-ops44.md`) e roda `pnpm changelog:build` antes do push — o agregado `docs/CHANGELOG-AGENTS.md` é **insert-only** (nunca remove/reescreve entradas; entradas históricas não são migradas). Guard de CI `docs-guards`: (a) agregado é append-only (multiset sobre blobs — perda de linha falha); (b) `docs/changelog/` é additions-only (M/D falham); (c) agregado está up to date com `docs/changelog/` (`changelog:check`); (d) nenhum marcador de conflito em diffs de markdown (docs/, AGENTS.md, .agents/ — cobre PRs docs-only, onde a suíte unit não roda). **Desde OPS63 os mesmos três checks rodam também no pre-push local** (`gate:push` → `gate:ci`, fase 1 — mesmos scripts, default `origin/main`). Restauração legítima (header muda, restauração estilo D8) escapa escrevendo **`changelog-rewrite: <motivo>` como linha própria** no body do PR — definição canônica aqui; o texto do checkbox do PR template nunca ativa (linha-ancorado). **O escape é CI-only (body do PR):** no pre-push local a restauração legítima falha com a mesma mensagem do CI — bypass documentado é `git push --no-verify` direto (o CI, com o escape no body, continua sendo a via da restauração). A própria troca `--merge` → `--rebase` deste doc usou o escape — header do agregado mudou.
2. Migrations NÃO são serializadas entre PRs: o CI (migrate + int, incl. `campaignMigrationReconciliation`) valida a cadeia em todo PR; rebase antes de `migrate:create` continua obrigatório.
3. Ready + auto-merge: `pnpm push -u origin HEAD` (GitHub) → PR via `node scripts/github-pr.mjs` (Ready, base `main`, `Closes #N`) → o safety net `agent-pr-ready-automerge.yml` (`scripts/github-pr-automerge.mjs`) arma o **auto-merge nativo do GitHub por rebase** — o servidor espera o required check `CI (PR) / checks` e mergea; nada a armar por PR, não há `gh` (OPS50) e não há poll (OPS71). **Merge é por rebase** (nunca merge commit): preserva a linha histórica limpa do `main` e é o modo do auto-merge.
4. **Changelog da entrega (OPS44):** toda entrega escreve `docs/changelog/<data>-<id>.md` (ex. `2026-08-13-ops44.md`) e roda `pnpm changelog:build` antes do push — o agregado `docs/CHANGELOG-AGENTS.md` é **insert-only** (nunca remove/reescreve entradas; entradas históricas não são migradas). Guard de CI `docs-guards`: (a) agregado é append-only (multiset sobre blobs — perda de linha falha); (b) `docs/changelog/` é additions-only (M/D falham); (c) agregado está up to date com `docs/changelog/` (`changelog:check`); (d) nenhum marcador de conflito em diffs de markdown (docs/, AGENTS.md, .agents/ — cobre PRs docs-only, onde a suíte unit não roda). **Desde OPS63 os mesmos três checks rodam também no pre-push local** (`gate:push` → `gate:ci`, fase 1 — mesmos scripts, default `origin/main`). Restauração legítima (header muda, restauração estilo D8) escapa escrevendo **`changelog-rewrite: <motivo>` como linha própria** no body do PR — definição canônica aqui; o texto do checkbox do PR template nunca ativa (linha-ancorado). **O escape é CI-only (body do PR):** no pre-push local a restauração legítima falha com a mesma mensagem do CI — bypass documentado é `git push --no-verify` direto (o CI, com o escape no body, continua sendo a via da restauração).
## Registries compartilhados (serialização por arquivo, não por PR)
@ -67,39 +68,46 @@ Arquivos de "lista" que N agentes escrevem em paralelo (changelog, plans, migra
- **`serializes` no plano de intenção** nomeia o registro exato que a Issue toca (ex. `serializes: 'docs/plans/'`), para o executor agendar com clareza — é dica de ordem, não lock (rebase continua obrigatório).
- O rebase (Contrato de PR item 3) é o mecanismo real de conciliação: conflitos de append resolvem com `git checkout --ours/--theirs` por lado.
## CI por alvo
## CI por alvo (GitHub Actions, OPS71)
| Workflow | Trigger | Banco | Passos |
| ------------------------------------ | ----------------------------------- | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ci-pr.yml` | PR → `main` | 2× service Postgres 17 | Job único `checks` sequencial (OPS62 X1): guards → lint→format→typecheck→knip→cycles→unit→int→build→e2e; skips via `ci-scope.mjs` (step `scope`); **fail-fast estrutural** — step vermelho interrompe o job, o run falha e o slot é liberado; e2e 1 processo com `PLAYWRIGHT_WORKERS=4`; 2 services por nome na rede do job (`postgres-int`/`postgres-build`, sem publish host) |
| `ci.yml` | schedule `*/30` / dispatch `main` | 2× service Postgres 17 | Janela fixa (OPS65): gate `gate` (host, main-only, sem pnpm) classifica mudança de produção desde a **revision do container rodando** no homeserver (`.dockerignore` = fonte da verdade; fail-open; incapaz de classificar → run **vermelho**); sem mudança → suite + deploy skipped, run verde; com mudança → job único `checks` sequencial (mesma mecânica X1) + job `deploy` (runs-on `host`) publica no homeserver via `scripts/deploy-homeserver.sh` (OPS53, guard "already deployed") |
| `issue-done-on-main-merge.yml` | PR merged → `main` (same-repo) | — | `Closes`/`Fixes` → `done` + `in-prod` (PAT `FORGEJO_API_TOKEN`; `GITHUB_TOKEN` 403 em Issues) |
| `plan-issue-ready-on-main-merge.yml` | PR merged → `main` (same-repo) | — | `Related #N` aguardando plano → `ready` (OPS18; soft-skip; mesmo PAT) |
| `agent-pr-ready-automerge.yml` | PR same-repo → `main` (open/sync/…) | — | Safety net via `forgejo-pr-automerge.mjs` (plain Node): espera o rollup `checks` e mergea por rebase com `FORGEJO_API_TOKEN` (o `GITHUB_TOKEN` nativo não tem write no repo — o push do merge é rejeitado com 409); draft `cursor/*` → Ready; draft não-`cursor/*` = veto, skip |
| `archive-cursor-agent.yml` | dispatch manual (ops) | — | Helper: cancela run + arquiva um agente Cursor Cloud (`CURSOR_API_KEY`) — **dormente (OPS65)** como o pool |
| Workflow | Trigger | Banco | Passos |
| ------------------------------------ | --------------------------------------------------------------- | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `ci-pr.yml` | PR → `main` (same-repo) | 2× service Postgres 17 | Job único `checks` sequencial (OPS62 X1): guards → lint→format→typecheck→knip→cycles→unit→int→build→e2e; skips via `ci-scope.mjs` (step `scope`); **fail-fast estrutural** — step vermelho interrompe o job, o run falha; `concurrency` nativo cancela o run em push novo; e2e **apenas blast radius** (`selected`, nunca `full` — OPS72) em 1 processo com `PLAYWRIGHT_WORKERS=4`; 2 services por nome na rede do job (`postgres-int`/`postgres-build`, sem publish host); fork-PR excluída no `if:` do job (repo público — não queima minutes) |
| `deploy.yml` | **`workflow_dispatch` manual** | 2× service Postgres 17 | **Dois jobs:** `verify` (hosted, suíte **full** sem skips — lint→format→typecheck→knip→cycles→unit→int→build→e2e full) → `deploy` (`needs: [verify]`, `runs-on: [self-hosted, homeserver]`) executa `scripts/deploy-homeserver.sh <sha>` **localmente** no homeserver (sem SSH; idempotente; HEAD guard; flock; migrator→migrate→runner→rollout→smoke) |
| `issue-done-on-main-merge.yml` | PR merged → `main` (same-repo) + **dispatch manual (recovery)** | — | `Closes`/`Fixes` → `done` + `in-prod` + **close** **no Forgejo** (PAT `FORGEJO_API_TOKEN`; body do PR via `PR_BODY` do evento GitHub — no dispatch, busca via `gh` com o mesmo contrato: merged + main + same-repo) |
| `plan-issue-ready-on-main-merge.yml` | PR merged → `main` (same-repo) + **dispatch manual (recovery)** | — | `Related #N` aguardando plano → `ready` **no Forgejo** (OPS18; soft-skip; mesmo PAT) |
| `agent-pr-ready-automerge.yml` | PR same-repo → `main` (open/sync/…) | — | Safety net via `github-pr-automerge.mjs` (plain Node, REST+GraphQL): draft `cursor/*` → Ready (PATCH); arm `enablePullRequestAutoMerge` (rebase) **com o `AUTOMERGE_PAT`** (fail-closed — nunca o `GITHUB_TOKEN` nativo, cujo merge como `github-actions[bot]` não cria runs no `closed`; OPS71-FLIP) — o **servidor** espera o required check `CI (PR) / checks` (o OPS64 "rollup pode mentir" é estrutural aqui: job único posta um check-run honesto); draft não-`cursor/*` = veto, skip |
| `archive-cursor-agent.yml` | dispatch manual (ops) | — | Helper: cancela run + arquiva um agente Cursor Cloud (`CURSOR_API_KEY`) — **dormente (OPS65)** como o pool |
`agent-pool.yml` foi **removido no OPS65** (pool dormente; o tick `*/10` custava 144 `pnpm install`/dia na workstation).
`agent-pool.yml` foi **removido no OPS65** (pool dormente; o tick `*/10` custava 144 `pnpm install`/dia na workstation). O verificador de main (`ci.yml`) foi **eliminado no OPS71** (gate 2026-08-19): só o `verify` do dispatch manual roda a suíte full.
Action runtimes: `actions/checkout@v5`, `actions/setup-node@v5`, `pnpm/action-setup@v6` e `styfle/cancel-workflow-action@0.13.1` usam Node 24 nativo — sem `FORCE_JAVASCRIPT_ACTIONS_TO_NODE24`.
Action runtimes: `actions/checkout@v5`, `actions/setup-node@v5`, `pnpm/action-setup@v6` e `actions/cache@v4`.
Fast gate: `pnpm gate:fast`. Push: `pnpm push`.
### Secrets (humano, uma vez)
### Secrets (humano, uma vez — repo GitHub)
| Secret | Uso |
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `FORGEJO_API_TOKEN` | Supervisor do pool e scripts Forgejo (`forgejo-api.mjs`); também nos workflows pós-merge (`issue-done`, `plan-issue-ready`) e no safety net — o `GITHUB_TOKEN` nativo 403 em Issues e não tem write no repo (OPS61) |
| `CURSOR_API_KEY` | Supervisor do pool (Cursor Cloud) — **dormente (OPS65)**; só o helper `archive-cursor-agent.yml` a usa hoje |
| Secret | Uso |
| ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `FORGEJO_API_TOKEN` | Scripts Forgejo (`forgejo-api.mjs`) e flips pós-merge (`issue-done`, `plan-issue-ready`) — o `GITHUB_TOKEN` nativo do GitHub não toca Issues do Forgejo (OPS61) |
| `AUTOMERGE_PAT` | **Arm do auto-merge nativo** (`agent-pr-ready-automerge.yml`) — PAT de usuário real (fsolla) com `pull-requests: write` + `contents: write` no repo. **Sem ele o job falha fail-closed**: o `GITHUB_TOKEN` nativo armado faria o merge como `github-actions[bot]` e o `closed` do PR não criaria runs (anti-recursão do GitHub) — os flips pós-merge ficariam silenciosamente mortos (OPS71-FLIP, PR #746) |
| `CURSOR_API_KEY` | Supervisor do pool (Cursor Cloud) — **dormente (OPS65)**; só o helper `archive-cursor-agent.yml` a usa hoje |
`POOL_GITHUB_TOKEN` é **legado** (era da transição GitHub): o `scripts/agent-pool.mjs` o ignora quando presente; não criar no Forgejo. Envs de produção (DB, S3, VAPID, `REVALIDATE_SECRET`, `NEXT_PUBLIC_SITE_URL`) vivem em `~/stack/teqo-1313.env` no homeserver — nunca como secrets do Forgejo.
Local: `GITHUB_TOKEN` (PAT com escopo `repo`) para `node scripts/github-pr.mjs`, `pnpm configure:branch-protection` e qualquer script GitHub — o push SSH usa a chave `~/.ssh/id_ed25519` registrada no GitHub. Envs de produção (DB, S3, VAPID, `REVALIDATE_SECRET`, `NEXT_PUBLIC_SITE_URL`) vivem em `~/stack/teqo-1313.env` no homeserver — nunca como secrets do GitHub. `POOL_GITHUB_TOKEN` é **legado** (era da transição GitHub pré-OPS50).
Branch protection de `main` (aplicada em 2026-08-18, OPS61): required status check `CI (PR) / checks` (o rollup do `ci-pr.yml` — único contexto que cobre a cascata inteira), `strict=false`, 0 reviews. O `waitForChecks` do safety net espera esse mesmo rollup antes de mergear (o próprio status do job é `pending` durante o run e nunca entra no veredito); a regra no servidor é o gate real — o Forgejo bloqueia o merge POST com 405 se o contexto exigido não estiver verde, inclusive merges manuais via API (a PR #52 entrou em main com CI vermelho por merge manual). **OPS64:** o CLI nunca confia no rollup sozinho — varre os statuses individuais e falha em qualquer `failure`/`error` (pin em `forgejoApi.unit.spec.ts`, cenário run 607: rollup verde com shard de e2e vermelho → throw). Com o desenho de job único do OPS62 (sem matrix, sem shards) o status do job `checks` já é o veredito honesto da cascata — o bug de agregação do parent de matrix deixou de existir estruturalmente. Reaplicar/consertar drift: `pnpm configure:branch-protection` (idempotente).
### Flips pós-merge (OPS71-FLIP): o evento `closed` e a recuperação manual
Deploy (OPS53 + OPS65): o `ci.yml` do main roda em **janela fixa de 30 min** (`schedule */30` + `workflow_dispatch` — não dispara mais a cada push). O job `gate` (host, sem pnpm) lê a **revision do container rodando** no homeserver (label `org.opencontainers.image.revision` — o compose pode mentir após rollback falho) e classifica "mudança de produção" pelos padrões do `.dockerignore` (fonte da verdade do artefato; `docs/`, `.agents/`, `.env*` viram skip; `scripts/`, `tests/`, `.forgejo/`, `AGENTS.md` contam como produção — conservador). Deployed == HEAD → suite e deploy skipped (run verde); estado desconhecido (sem revision) → suite roda fail-open; gate incapaz de classificar → suite roda e o run fica **vermelho** (nunca blackout). Com mudança → suite full verde → job `deploy` (runs-on `host`) streama `scripts/deploy-homeserver.sh` para o homeserver via SSH — build no homeserver (secrets de `~/stack/teqo-1313.env`), registry `localhost:5000`, migrate pelo serviço de maintenance `teqo-1313-migrate` antes do rollout, smoke pós-deploy; o script é idempotente (revision do container já no SHA → skip verde "already deployed"). Nenhum secret novo no Forgejo. Runbook (rollback, falhas conhecidas): `docs/ops/teqo-1313-deploy.md`. O deploy Vercel (`vercel-promote.yml`, scripts, `vercel.json`) foi removido no OPS50.
O flip `issue-done`/`plan-issue-ready` roda no `pull_request: closed` do PR no GitHub — mas **o merge feito pelo auto-merge armado com o `GITHUB_TOKEN` nativo é atribuído a `github-actions[bot]` e NÃO cria workflow runs** (anti-recursão documentada do GitHub: eventos acionados por ações do `GITHUB_TOKEN` não geram runs; o `closed` fica no timeline do PR com 0 runs — achado ao vivo no PR #746). Isso só não acontece porque o arm agora usa o `AUTOMERGE_PAT` (merge atribuído a usuário real → runs criados, comportamento do PR #742).
**Se mesmo assim o flip não rodou** (evento perdido, run falhou, PR mergeado por bot antes do fix): o workflow tem **`workflow_dispatch` de recuperação** — dispatch de `issue-done-on-main-merge.yml` (ou `plan-issue-ready-on-main-merge.yml`) com o input `pr` = número do PR; o job busca o PR via `gh` e aplica o mesmo contrato do evento (merged + base main + same-repo, senão skip). Fallback local (sem GitHub): `PR_BODY='Closes #N' node scripts/forgejo-issue-transition.mjs --pr <N>` com o `GITHUB_TOKEN` do ambiente (PAT). O flip agora também **fecha** a Issue (`PATCH state=closed` — o merge no GitHub não fecha Issues do Forgejo como o merge nativo da era Forgejo fazia). Falha em qualquer passo = job vermelho (OPS61, nunca mentir); o `forgejo-api.mjs` retenta 410/5xx transitórios (OPS67 + OPS71-FLIP: 410 do proxy retenta em qualquer método — a origem nunca viu o request).
Branch protection de `main` no **GitHub** (aplicada no cutover do OPS71, via `pnpm configure:branch-protection`): required check `CI (PR) / checks` (a UI exibe workflow/job; o **literal de match é `checks`** — o GitHub casa pelo nome do check-run — pin ao vivo PR #742), `strict=false`, 0 reviews, `enforce_admins: true` (nem admin mergea com CI vermelho — mesmo invariante do rule-based Forgejo). O auto-merge nativo espera esse mesmo check antes de mergear; a regra no servidor é o gate real de qualquer caminho de merge, inclusive API manual. Reaplicar/consertar drift: `pnpm configure:branch-protection` (idempotente).
Deploy (OPS53 + OPS71): **manual** via `workflow_dispatch` no `deploy.yml` — o job `verify` (hosted, suíte full incl. e2e full) roda **antes** do job `deploy` (`needs: [verify]`), que executa `scripts/deploy-homeserver.sh <sha>` no runner self-hosted do GitHub **no homeserver** (conecta outbound ao GitHub — funciona atrás do Cloudflare tunnel; o hosted nunca toca o homeserver; o self-hosted não conta minutos hosted). O script: HEAD guard (`TEQO_REPO_URL` — default `https://github.com/fsolla/teqo.git`, público) → flock → guard "already deployed" (revision do container) → build do migrator (BuildKit, secrets de `~/stack/teqo-1313.env`) → push/tag em `localhost:5000` → swap dos tags no compose (backup) → **migrations** (`teqo-1313-migrate` — antes do build do runner, OPS66) → build do runner (contra o banco JÁ migrado) → rollout → healthcheck → smoke. Falha = job vermelho; nada é publicado pela metade. Runbook (rollback, falhas conhecidas): `docs/ops/teqo-1313-deploy.md`. O deploy Vercel foi removido no OPS50; o verificador automático de main foi eliminado no OPS71 — se um dia o merge em main quiser rede de regressão, o `ci.yml` volta com 1 linha de trigger (sem deploy).
## Cursor Cloud
`.cursor/environment.json` instala deps + Postgres nativo + `db:seed:minimal`. Sem secrets de prod. Preferir `pnpm push`; escape `git push --no-verify` só no cutover documentado acima.
`.cursor/environment.json` instala deps + Postgres nativo + `db:seed:minimal`. Sem secrets de prod. Preferir `pnpm push`; escape `git push --no-verify` só no cutover documentado acima. PR via `ManagePullRequest` (draft: false) — o merge continua pelo safety net do GitHub.
## Agent pool

View file

@ -8,6 +8,28 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente
**C141 — perfis de permissão por assessor: Visão × Edição configuráveis (2026-08-19):** o coordenador/candidato define por assessor, em `/campanha/assessores` (coluna "Permissão" com badge + popover, seção no detalhe e no draft de novo assessor), o que ele **vê** (`carteira|tudo`) e o que ele **edita** (`carteira|tudo|somente_leitura`) — contas novas e existentes seguem no padrão Carteira·Edita carteira até configurarem. Enforcement fail-closed no servidor: a leitura alarga com Visão "Tudo" via prólogo novo `resolveProfileScopedRead` (municípios, lideranças, updates, pledges, atividades, contatos derivados), a escrita segue o eixo de Edição (`advisorEditingAccess`) nos `canUpdate*`/`canCreate*` de todos os domínios, no check do giro e no create individual de atividade (ambos contra `getWritableMunicipalityIds`), e a ficha de contatos passou a ser autorizada pelo escopo de escrita (`assertPersonContactWritable` — antes era "você edita o que vê", que a Visão "Tudo" alargaria em escrita de PII). Caps mantidos: demandas seguem a regra própria (C143), apoiadores (PII) e telefones de contas ficam na carteira mesmo com Tudo, e escritas de coordenação (contas, `municipality.advisors`, nível de envolvimento E14, `allocationDecision`) continuam restritas. Combinação incoerente (Edição "Tudo" sem Visão "Tudo") é rejeitada no zod, na action e no hook `enforceCoherentAdvisorProfile` (REST/admin incluídos). Migration `20260819_214755_add_campaign_user_advisor_permission_profile` (duas colunas select com default `'carteira'`, sem backfill).
**OPS73 — deploy do fix do admin em produção (2026-08-20):** o `/admin` do Payload (jorgesolla1313.com.br) voltou a renderizar o formulário de login em produção — o bug de "tela branca" que travava o CMS do time editorial/campanha está resolvido e verificado. Durante o diagnóstico descobriu-se que a premissa do plano ("produção nunca recebeu o fix OPS69") estava errada: o deploy das 23:09 de 2026-08-19 (revision `8c734ba`) já continha o fix do importMap **no histórico**, mas o commit S9 (`8d2352c0`, captura de novidades na home) tinha regenerado o importMap **sem** as envs `S3_*`, removendo o handler `@payloadcms/storage-s3/client#S3ClientUploadHandler` e reintroduzindo o erro `getFromImportMap: PayloadComponent not found in importMap` — o admin abria em branco de novo ("funcionou e no deploy seguinte parou"). Verificado também que o **main do GitHub havia divergido do Forgejo** (pós-cutover OPS71): o GitHub tinha o fix + OPS71, mas não as features S8 (seção "Nossa história") e S9 (captura de novidades) que produção já rodava — deployar o main como estava regrediria as duas. A entrega: PR #751 re-homou no GitHub main as features S8+S9 (cherry-pick dos commits aprovados no Forgejo `8d2352c0` + `488dcf7d`), mantendo o handler no importMap (prova: `generate:importmap` com envs `S3_*` dummy → diff vazio vs main); CI verde; aguardando o merge. Deploy pós-merge via o pipeline canônico do OPS71 (dispatch manual do `deploy.yml`: `verify` full → job `deploy` no runner self-hosted recém-instalado no homeserver — 2 tentativas "stale run" por merges paralelos no meio do verify, terceira aplicou `dba75ff8`); migração C143 (responsáveis de demandas, aditiva+backfill) aplicada. Verificação pós-deploy: container na revision `dba75ff8`, healthy, **log sem nenhum `getFromImportMap`**, `/admin` renderizando o formulário de login (2 inputs email/senha + botão Login, título "Login - Payload", 0 erros de console), e a home pública mantendo "Nossa história" (S8) e o form de novidades (S9). Evidência: `docs/plans/ops73-deploy-admin-prod-fixed-evidence.png`. Lições registradas: a regeneração do importMap sem envs `S3_*` re-órfão o handler (classe exata que o guard #87 deve impedir no CI); divergência de plataformas pós-cutover — workspace do homeserver ainda apontava para o Forgejo local (`localhost:3000`) e o S10 (pixel) segue pendente de re-home no GitHub.
**OPS73 — deploy do fix do admin em produção (2026-08-20):** o `/admin` do Payload (jorgesolla1313.com.br) voltou a renderizar o formulário de login em produção — o bug de "tela branca" que travava o CMS do time editorial/campanha está resolvido e verificado. O diagnóstico mostrou que a premissa do plano (fix nunca tinha chegado a prod) estava errada em dois pontos: (1) o deploy das 23:09 de 2026-08-19 (revision `8c734ba`) já continha o fix do importMap no histórico, mas o commit S9 (`8d2352c0`) regenerou o importMap **sem** as envs `S3_*`, removendo o handler `@payloadcms/storage-s3/client#S3ClientUploadHandler` e reintroduzindo `getFromImportMap: PayloadComponent not found` (admin em branco de novo); (2) o **main do GitHub havia divergido do Forgejo** (pós-cutover OPS71) — o GitHub tinha o fix + OPS71, mas não as features S8 ("Nossa história") e S9 (captura de novidades) que produção já rodava. A entrega (PR #751): re-home do S8+S9 no GitHub main (cherry-pick `8d2352c0`+`488dcf7d`), mantendo o handler no importMap (prova: `generate:importmap` com envs `S3_*` dummy → diff vazio vs main); deploy via `deploy.yml` no runner self-hosted do homeserver (`dba75ff8`), após 2 "stale run" por merges paralelos no verify; migração C143 (responsáveis de demandas, aditiva+backfill) aplicada. Verificação pós-deploy: revision `dba75ff8`, healthy, **log sem `getFromImportMap`**, `/admin` renderizando o login (2 inputs + botão, título "Login - Payload", 0 erros de console), home mantendo S8/S9. Evidência: `docs/plans/ops73-deploy-admin-prod-fixed-evidence.png`. Lições: regeneração do importMap sem envs `S3_*` re-órfão o handler (classe do guard OPS70/#87); divergência de plataformas pós-cutover (workspace homeserver ainda aponta Forgejo local; S10 pixel pendente de re-home).
**S11** — o feed do Instagram configurado **sem aparecer na home** fica diagnosticável: fim do fail-closed silencioso da S3. A assessoria vê, na mesma global onde preenche o token, o estado da sincronização (`instagramSyncStatus` jsonb hidden na `social-feed-settings`) e o **porquê** de os posts não aparecerem — o painel `InstagramSyncStatusPanel` (campo `ui` irmão do picker) mostra 5 estados fiéis ao rascunho aprovado (desativado / ainda não configurado / falha com motivo em linguagem de produto + "Tentar sincronizar de novo" / sincronizado · há X min · N posts / aguardando 1ª sync). A causa da ocorrência de produção (board vazio com token+ID preenchidos) é agora confirmável em 1 clique após o deploy. Engenharia: `loadInstagramFeed` passou a parsear o body de erro da Graph API e lançar `InstagramApiError` (`status` + `{message,type}`); `describeInstagramError` mapeia para copy pt-BR com a correção — OAuthException/expired → "token inválido/expirado ou emitido via Facebook Login — gere pelo Instagram Login" (o refresh endpoint da Graph API só renova tokens de Instagram Login), "Invalid user id" → "confira o ID da conta Business/Creator", 5xx → indisponível, `TypeError` → rede. Estado escrito por 3 caminhos: render da home (`getInstagramFeed`, dentro do `unstable_cache`, SQL direto sem hooks — padrão anti-`revalidateTag`-no-cache da S2), `afterChange` da global (sync **awaited** quando a credencial IG muda — `instagramCredentialsChanged(doc, previousDoc)`, gate que pula save de exclusões/YouTube; o sync é importado lazy no hook para o `payload.config.ts` não puxar o módulo do feed no load, que é o que todo spec int importa) e a rota `POST /api/social-feed/sync` (botão; `payload.auth` sessão admin exigindo `users` + `isSameOriginRequest` CSRF — allowlistada no spec de convenção de POST routes com razão, é auth de admin ≠ envelope `/campanha`). **Bug real descoberto e corrigido na entrega:** o `afterChange` roda DENTRO da transação do save (a row lock fica presa até os hooks retornarem), então escrever o status/snapshot via pool numa segunda conexão **deadlockava no lock da própria linha** (~80-230s por save, transações-zumbi segurando o lock). A correção roteia os persists do sync-hook pela **mesma transação** (`getPostgresTransactionDatabase` — o helper transacional da C6) com um `req` opcional no `syncInstagramFeed`; o render e a rota sem transação aberta seguem no pool. Diagnóstico reproduzido em `pg_stat_activity` (UPDATE `transactionid` Lock vs `idle in transaction`) e fix verificado (45ms vs 232s). Stub IG ganha estado `invalid-token` (400 OAuthException) para o e2e da cena de falha; runbook `docs/ops/instagram-feed-token-runbook.md` (o que o painel mostra, validação via curl `/me` e `refresh_access_token`, geração pelo Instagram Login, o que NÃO fazer). Migration `20260820_003426_add_instagram_sync_status` (ADD COLUMN jsonb nullable, não destrutiva). **/simplify (3 reviewers):** absorvi a correção P1 de diagnóstico — o mapeamento de ID exigia só "user" (regex frouxo) e mensagens reais de token OAuthException ("the **user** must be logged in", "user changed password") caíam na branch de ID, mostrando a razão errada; passei a exigir "user" E "id" juntos (`isInvalidUserIdMessage`) e adicionei o mapeamento de `AbortError` (timeout → "demorou demais"). Painel: gate passou a respeitar o master `enabled` (master off → "Instagram desativado", nunca "Sincronizado no board"), rótulo do botão no estado de primeira sync ("Tentar sincronizar" vs "de novo"), `outcome` transiente limpo ao editar credencial e `aria-live` nos boxes interativos. Testes: caminho transacional do `syncInstagramFeed` (persist na mesma transação) + fallback pool cobertos por unit; e2e do painel ganhou as cenas fail-500 e "desativado"; cleanup do stub global virou `afterEach`. Débitos registrados: #124 (janela de lock: fetch de rede no sync-hook segura a row lock até 10s, acoplando a home ao save) e #125 (polish P3 — tipo de persist duplicado, rota 200 vs 400, last-writer-wins stale, `5 posts` hardcoded). Verificação: unit 2463 + int 791 verdes; lint/format/tsc/cycles verdes; `pnpm build` ok; e2e local — smoke admin 5/5 (painel ok → falha token → retry → falha 500 → desativado → não configurado) + describe serial frontend 19/19.
**C143** — demandas visíveis apenas para responsáveis explícitos + candidato/coordenador. Novo campo `campaignDemand.responsibles` (hasMany → `campaignUser`, staff elegível via `eligibleCampaignStaffWhere` com hook `beforeValidate` que descarta ids não-staff, fail-closed) e regra de leitura/atualização por membership: assessor só vê demandas onde é responsável (`{ responsibles: { contains } }`), candidato/coordenador/admin sempre, leader nunca — a regra substitui o escopo municipal de demandas em `src/utilities/access/demands.ts` e todas as superfícies (lista, URL, busca global, demandas de atividade, C90) herdam via `overrideAccess: false`; criação sem responsáveis fica visível só a unrestricted (fail-closed). Criador entra automaticamente como responsável no hook `enforceDemandWorkflow` (cobre form, wizard e rascunhos C90); migration `20260819_233503_add_campaign_demand_responsibles` adiciona `campaign_user_id` a `campaign_demand_rels` (FK cascade) e faz backfill de `created_by_id` → responsável para demandas pré-C143. UI: picker multi-select `DemandResponsibleMultiSelect` (chips com avatar, chip do criador rotulado "(criador)" e travado na criação, sugestões = assessores do município) na criação (`/demandas/nova` em 2 colunas + wizard "Registrar pedido" empilhado) e card de gestão `DemandResponsiblesCard` no detalhe (add/remove com save full-replace + advisory lock, rótulo "Visível para: Responsáveis · Candidato · Coordenador"); busca por `searchDemandResponsibleOptions` (query curta → assessores do município; senão → staff por nome). Testes: int reescritos para a regra nova (workflow/homeSearch) + 4 casos novos (membership, fail-closed sem responsáveis, gestão add/remove/self-removal, responsável fora da carteira, descarte de id não-staff); e2e novo `campaignDemandVisibility.e2e.spec.ts` (assessor do mesmo município não vê lista/URL; hand-off de acesso add/revoke pela UI); fixture e2e ganhou `createCampaignDemand`. Verificação: unit 2444 + int 795 verdes, e2e 5/5 local (visibility + wizard + demanda do municipalidades), lint/format/knip/cycles/tsc/build limpos; importMap regenerado com `S3_*` dummy (OPS69). Nota de deploy: conferir na primeira aplicação em prod `SELECT count(*) FROM campaign_demand_rels WHERE path='responsibles'` = nº de demandas com `created_by_id`. Plano de implementação: `docs/plans/demandas-responsaveis-impl.md`.
**OPS72** — política de e2e: **CI de PR sem e2e full; local afetado como passo da skill; full só antes do deploy**. Decisão do gate (2026-08-19): (1) **local = discricionário** — o agente roda os e2e que criou + os da mesma superfície afetada, decide quais (`pnpm test:e2e:affected` é a ferramenta, não um gate mecânico; não está no `gate:push`); (2) **CI de PR = blast radius** — `ci-pr.yml` perdeu o step `E2E tests (full suite)`; o e2e roda só em modo `selected` (specs que o classifier detectou como tocadas); diff high-risk (schema/lockfile/harness) classifica `full` e o PR simplesmente não roda e2e — deliberado: o espelho local roda full nesse mesmo diff e o `verify` do deploy roda full antes de publicar; (3) **main = full no deploy** — inegociável, já existia no `deploy.yml` (nada mudou lá). Docs: `work-issue` e `agent-work-issue` (checklist + Passo 4), `execution-pipeline.md` (nova seção "E2E local afetado" com o critério + a **limitação da #72**: `--no-deps` + projetos paralelos colide no `seedTestUser` → `--workers=1` ou a cadeia padrão de projetos), `AGENTS.md` (parágrafo OPS59 → política OPS72), `docs/AGENT-OPS.md` (fluxo do agente + linha do `ci-pr.yml` na tabela), `.agents/rules/agent-pr-workflow.mdc` (descrição do `gate:push`), mensagens informativas do `gate-ci.mjs` (texto-only — e2e continua fora do gate) e comentários do workflow. Mecânica intocada por escopo (`ci-scope.mjs`, manifest, `run-e2e-affected.mjs`); o fix da #72 fica na fila (só documentado). Verificação: format/lint/tsc verdes, unit 2444 + int 791 verdes; o e2e local afetado rodou a suíte **full** (o diff toca `scripts/gate-ci.mjs`, high-risk → modo `full` — a política em ação): 154 passed em dev mode; 8 falhas de ambiente (timeouts/cold-compile sob carga) triadas — 5/8 passaram em re-run e as 3 restantes passaram em `E2E_PROD=1` (o modo do CI/verify; 41 passed + 2 flaky que passaram isoladas), zero regressão de produto; o CI deste PR não roda e2e (modo `full` por design desta mesma política). Impl plan: `docs/plans/ops72-e2e-local-afetado-ci-sem-full-impl.md`. Nota: `E2E_PROD=1` local regenera o `importMap.js` **sem** as envs de S3 (classe OPS69 — o guard do OPS70/#87 cobre; restaurado na entrega com envs dummy).
**OPS72** — política de e2e: **CI de PR sem e2e full; local afetado como passo da skill; full só antes do deploy**. Decisão do gate (2026-08-19): (1) **local = discricionário** — o agente roda os e2e que criou + os da mesma superfície afetada, decide quais (`pnpm test:e2e:affected` é a ferramenta, não um gate mecânico; não está no `gate:push`); (2) **CI de PR = blast radius** — `ci-pr.yml` perdeu o step `E2E tests (full suite)`; o e2e roda só em modo `selected` (specs que o classifier detectou como tocadas); diff high-risk (schema/lockfile/harness) classifica `full` e o PR simplesmente não roda e2e — deliberado: o espelho local roda full nesse mesmo diff e o `verify` do deploy roda full antes de publicar; (3) **main = full no deploy** — inegociável, já existia no `deploy.yml` (nada mudou lá). Docs: `work-issue` e `agent-work-issue` (checklist + Passo 4), `execution-pipeline.md` (nova seção "E2E local afetado" com o critério + a **limitação da #72**: `--no-deps` + projetos paralelos colide no `seedTestUser` → `--workers=1` ou a cadeia padrão de projetos), `AGENTS.md` (parágrafo OPS59 → política OPS72), `docs/AGENT-OPS.md` (fluxo do agente + linha do `ci-pr.yml` na tabela), `.agents/rules/agent-pr-workflow.mdc` (descrição do `gate:push`), mensagens informativas do `gate-ci.mjs` (texto-only — e2e continua fora do gate) e comentários do workflow. Mecânica intocada por escopo (`ci-scope.mjs`, manifest, `run-e2e-affected.mjs`); o fix da #72 fica na fila (só documentado). Verificação: format/lint/tsc verdes, unit 2444 + int 791 verdes; o e2e local afetado rodou a suíte **full** (o diff toca `scripts/gate-ci.mjs`, high-risk → modo `full` — a política em ação): 154 passed em dev mode; 8 falhas de ambiente (timeouts/cold-compile sob carga) triadas — 5/8 passaram em re-run e as 3 restantes passaram em `E2E_PROD=1` (o modo do CI/verify; 41 passed + 2 flaky que passaram isoladas), zero regressão de produto; o CI deste PR não roda e2e (modo `full` por design desta mesma política). Impl plan: `docs/plans/ops72-e2e-local-afetado-ci-sem-full-impl.md`. Nota: `E2E_PROD=1` local regenera o `importMap.js` **sem** as envs de S3 (classe OPS69 — o guard do #77 cobre; restaurado na entrega com envs dummy).
**OPS72** — política de e2e: **CI de PR sem e2e full; local afetado como passo da skill; full só antes do deploy**. Decisão do gate (2026-08-19): (1) **local = discricionário** — o agente roda os e2e que criou + os da mesma superfície afetada, decide quais (`pnpm test:e2e:affected` é a ferramenta, não um gate mecânico; não está no `gate:push`); (2) **CI de PR = blast radius** — `ci-pr.yml` perdeu o step `E2E tests (full suite)`; o e2e roda só em modo `selected` (specs que o classifier detectou como tocadas); diff high-risk (schema/lockfile/harness) classifica `full` e o PR simplesmente não roda e2e — deliberado: o espelho local roda full nesse mesmo diff e o `verify` do deploy roda full antes de publicar; (3) **main = full no deploy** — inegociável, já existia no `deploy.yml` (nada mudou lá). Docs: `work-issue` e `agent-work-issue` (checklist + Passo 4), `execution-pipeline.md` (nova seção "E2E local afetado" com o critério + a **limitação da #72**: `--no-deps` + projetos paralelos colide no `seedTestUser` → `--workers=1` ou a cadeia padrão de projetos), `AGENTS.md` (parágrafo OPS59 → política OPS72), `docs/AGENT-OPS.md` (fluxo do agente + linha do `ci-pr.yml` na tabela), `.agents/rules/agent-pr-workflow.mdc` (descrição do `gate:push`), mensagens informativas do `gate-ci.mjs` (texto-only — e2e continua fora do gate) e comentários do workflow. Mecânica intocada por escopo (`ci-scope.mjs`, manifest, `run-e2e-affected.mjs`); o fix da #72 fica na fila (só documentado). Verificação: format/lint/tsc verdes, unit 2444 + int 791 verdes; o e2e local afetado rodou a suíte **full** (o diff toca `scripts/gate-ci.mjs`, high-risk → modo `full` — a política em ação): 154 passed em dev mode; 8 falhas de ambiente (timeouts/cold-compile sob carga) triadas — 5/8 passaram em re-run e as 3 restantes passaram em `E2E_PROD=1` (o modo do CI/verify; 41 passed + 2 flaky que passaram isoladas), zero regressão de produto; o CI deste PR não roda e2e (modo `full` por design desta mesma política). Impl plan: `docs/plans/ops72-e2e-local-afetado-ci-sem-full-impl.md`. Nota: `E2E_PROD=1` local regenera o `importMap.js` **sem** as envs envs de S3 (classe OPS69 — o guard do #77 cobre; restaurado na entrega com envs dummy).
**OPS72** — política de e2e: **CI de PR sem e2e full; local afetado como passo da skill; full só antes do deploy**. Decisão do gate (2026-08-19): (1) **local = discricionário** — o agente roda os e2e que criou + os da mesma superfície afetada, decide quais (`pnpm test:e2e:affected` é a ferramenta, não um gate mecânico; não está no `gate:push`); (2) **CI de PR = blast radius** — `ci-pr.yml` perdeu o step `E2E tests (full suite)`; o e2e roda só em modo `selected` (specs que o classifier detectou como tocadas); diff high-risk (schema/lockfile/harness) classifica `full` e o PR simplesmente não roda e2e — deliberado: o espelho local roda full nesse mesmo diff e o `verify` do deploy roda full antes de publicar; (3) **main = full no deploy** — inegociável, já existia no `deploy.yml` (nada mudou lá). Docs: `work-issue` e `agent-work-issue` (checklist + Passo 4), `execution-pipeline.md` (nova seção "E2E local afetado" com o critério + a **limitação da #72**: `--no-deps` + projetos paralelos colide no `seedTestUser` → `--workers=1` ou a cadeia padrão de projetos), `AGENTS.md` (parágrafo OPS59 → política OPS72), `docs/AGENT-OPS.md` (fluxo do agente + linha do `ci-pr.yml` na tabela), `.agents/rules/agent-pr-workflow.mdc` (descrição do `gate:push`), mensagens informativas do `gate-ci.mjs` (texto-only — e2e continua fora do gate) e comentários do workflow. Mecânica intocada por escopo (`ci-scope.mjs`, manifest, `run-e2e-affected.mjs`); o fix da #72 fica na fila (só documentado). Verificação: format/lint/tsc verdes, unit 2444 + int 791 verdes; o e2e local afetado rodou a suíte **full** (o diff toca `scripts/gate-ci.mjs`, HIGH*RISK → modo `full` — a política em ação): 154 passed em dev mode; 8 falhas de ambiente (timeouts/cold-compile sob carga) triadas — 5/8 passaram em re-run e as 3 restantes passaram em `E2E_PROD=1` (o modo do CI/verify; 41 passed + 2 flaky que passaram isoladas), zero regressão de produto; o CI deste PR não roda e2e (modo `full` por design desta mesma política). Impl plan: `docs/plans/ops72-e2e-local-afetado-ci-sem-full-impl.md`. Nota: `E2E_PROD=1` local regenera o `importMap.js` **sem** as envs `S3*\*` (classe OPS69 — o guard do #77 cobre; restaurado na entrega com envs dummy).
**OPS72** — política de e2e: **CI de PR sem e2e full; local afetado como passo da skill; full só antes do deploy**. Decisão do gate (2026-08-19): (1) **local = discricionário** — o agente roda os e2e que criou + os da mesma superfície afetada, decide quais (`pnpm test:e2e:affected` é a ferramenta, não um gate mecânico; não está no `gate:push`); (2) **CI de PR = blast radius** — `ci-pr.yml` perdeu o step `E2E tests (full suite)`; o e2e roda só em modo `selected` (specs que o classifier detectou como tocadas); diff high-risk (schema/lockfile/harness) classifica `full` e o PR simplesmente não roda e2e — deliberado: o espelho local roda full nesse mesmo diff e o `verify` do deploy roda full antes de publicar; (3) **main = full no deploy** — inegociável, já existia no `deploy.yml` (nada mudou lá). Docs: `work-issue` e `agent-work-issue` (checklist + Passo 4), `execution-pipeline.md` (nova seção "E2E local afetado" com o critério + a **limitação da #72**: `--no-deps` + projetos paralelos colide no `seedTestUser` → `--workers=1` ou a cadeia padrão de projetos), `AGENTS.md` (parágrafo OPS59 → política OPS72), `docs/AGENT-OPS.md` (fluxo do agente + linha do `ci-pr.yml` na tabela), `.agents/rules/agent-pr-workflow.mdc` (descrição do `gate:push`), mensagens informativas do `gate-ci.mjs` (texto-only — e2e continua fora do gate) e comentários do workflow. Mecânica intocada por escopo (`ci-scope.mjs`, manifest, `run-e2e-affected.mjs`); o fix da #72 fica na fila (só documentado). Verificação: format/lint/tsc verdes, unit 2444 + int 791 verdes; o e2e local afetado rodou a suíte **full** (o diff toca `scripts/gate-ci.mjs`, HIGH_RISK → modo `full` — a política em ação): 154 passed em dev mode; 8 falhas de ambiente (timeouts/cold-compile sob carga) triadas — 5/8 passaram em re-run e as 3 restantes passaram em `E2E_PROD=1` (o modo do CI/verify; 41 passed + 2 flaky que passaram isoladas), zero regressão de produto; o CI deste PR não roda e2e (modo `full` por design desta mesma política). Impl plan: `docs/plans/ops72-e2e-local-afetado-ci-sem-full-impl.md`.
**OPS72** — política de e2e: **CI de PR sem e2e full; local afetado como passo da skill; full só antes do deploy**. Decisão do gate (2026-08-19): (1) **local = discricionário** — o agente roda os e2e que criou + os da mesma superfície afetada, decide quais (`pnpm test:e2e:affected` é a ferramenta, não um gate mecânico; não está no `gate:push`); (2) **CI de PR = blast radius** — `ci-pr.yml` perdeu o step `E2E tests (full suite)`; o e2e roda só em modo `selected` (specs que o classifier detectou como tocadas); diff high-risk (schema/lockfile/harness) classifica `full` e o PR simplesmente não roda e2e — deliberado: o espelho local roda full nesse mesmo diff e o `verify` do deploy roda full antes de publicar; (3) **main = full no deploy** — inegociável, já existia no `deploy.yml` (nada mudou lá). Docs: `work-issue` e `agent-work-issue` (checklist + Passo 4), `execution-pipeline.md` (nova seção "E2E local afetado" com o critério + a **limitação da #72**: `--no-deps` + projetos paralelos colide no `seedTestUser` → `--workers=1` ou a cadeia padrão de projetos), `AGENTS.md` (parágrafo OPS59 → política OPS72), `docs/AGENT-OPS.md` (fluxo do agente + linha do `ci-pr.yml` na tabela), `.agents/rules/agent-pr-workflow.mdc` (descrição do `gate:push`), mensagens informativas do `gate-ci.mjs` (texto-only — e2e continua fora do gate) e comentários do workflow. Mecânica intocada por escopo (`ci-scope.mjs`, manifest, `run-e2e-affected.mjs`); o fix da #72 fica na fila (só documentado). Verificação: format/lint/tsc/unit verdes; `test:e2e:affected` com modo `none` (diff sem blast radius e2e); CI do PR valida (e2e não roda — docs/skills/workflow não são superfície e2e). Impl plan: `docs/plans/ops72-e2e-local-afetado-ci-sem-full-impl.md`.
**OPS71-INFRA (2026-08-19):** runner **self-hosted do GitHub instalado no homeserver** (Issue #113) — o job `deploy` do `deploy.yml` (dispatch manual) agora roda localmente no homeserver, sem SSH. Detalhes: `~/actions-runner` v2.336.0 linux-x64 (sha256 conferido contra a API de releases), nome `teqo-1313-runner`, labels `self-hosted` + `homeserver`, registrado com o PAT do OPS71 (registration token TTL 1h gerado e aplicado na sequência). Roda como **systemd user** `actions.runner.fsolla-teqo.teqo-1313-runner.service` (linger já ativo; `svc.sh install` exige root — unit user criado manualmente do template oficial), usuário `fsolla` (docker + `~/stack` envs), workdir `_work`, outbound-only (funciona atrás do Cloudflare tunnel). Verificado `online` na API do GitHub com as labels corretas. Registrado em infra-solla `STATE.md` (seção Teqo: runner + fluxo deploy manual atualizado; linha do repo/CI corrigida para o pós-cutover GitHub). Workspace `~/teqo-deploy` re-aponta para o GitHub no primeiro deploy via `set-url` idempotente do script. Pendência registrada: secret `CURSOR_API_KEY` no GitHub fica dormente (helper `archive-cursor-agent.yml`; a key não existe no ambiente — pool morto OPS65) até o usuário pedir a reativação.
**S9 — captura de novidades da campanha na home pública (2026-08-19):** a home de campanha (`jorgesolla1313.com.br`) ganhou a seção "Receba as novidades da campanha" logo acima do rodapé — nome + WhatsApp obrigatórios, e-mail/estado/cidade/comentário opcionais, toggle pré-selecionado "Quero fazer parte do time" (frequência + grupos de WhatsApp vs. comunicações esporádicas) e confirmação in-place com atalho para as bandeiras. O CTA secundário do hero virou o atalho da seção (âncora `#novidades`, scroll suave no container da home; "Conhecer bandeiras" sai, as bandeiras seguem no rodapé e no estado de sucesso). O registro é `Contact` + `Subscription` em transação com consentimento NOVO por chave estável `campanha-novidades` (fail-closed: sem o texto aprovado no admin, o form recusa a captura — nada é gravado); a escolha do toggle sobrevive como `subscription.campaignLevel` (`time|esporadico`, coluna própria na lista do admin). Migração `20260819_192605_add_campaign_newsletter_capture` (DROP NOT NULL em `contact.state` — estado opcional no form, ficha staff via zod segue exigindo — + coluna `campaign_level` com enum). Precedente `submitWhatsapp` preservado; action nova usa `withPayloadTransaction` (o dono do concern de transação, não o hand-roll gêmeo — `submitWhatsapp`/`submitPetitionSignature` ficam como débito de alinhamento). S10 (pixel Meta) dispara `Lead` na costura do sucesso do form. Chave de consentimento pendente de aprovação jurídica antes da ativação.
**S7** — polimento dos cards da seção "Acompanhe de perto" da home de campanha (jorgesolla1313.com.br), look editorial e direto no lugar do "card de app": cantos retos (**sem `rounded-xl` no card e `rounded-lg` na capa**), capa de **borda a borda** (o `p-3` saiu do contêiner do card e foi para o bloco de texto — título/subtítulo/meta continuam respirando `p-3` das bordas; `mt-auto` da meta e `overflow-hidden` do zoom hover intactos) e, por decisão do humano posterior ao gate, **sem borda (`border --campaign-line`) e sem sombra** (`shadow-sm`/`hover:shadow-md`) — o card branco lê contra a banda do S5 só pela forma. **Chips de badge removidos de todos os cards** (decisão B do gate): nem os tipos de post do site (`POST_TYPE_BADGE_LABELS` deletado de `src/utilities/posts.ts`, junto com o describe no unit test) nem as origens externas YouTube/Instagram (sinalização externa segue por `target="_blank"`/`rel="noopener"`); `badgeLabel` saiu do tipo `CampaignContentCardData`. Superfície única no componente dono (`CampaignContentCard.tsx`) — bento e carrossel herdam; `ContentShareButton` (S4), banda do S5, carrossel e fail-closed da seção intocados. Zero migration/schema/Consent. e2e do describe da seção atualizados para o novo estado: loop de badges → ausência (`toHaveCount(0)` para os 4 tipos + YouTube/Instagram), pins CSS do card (`border-radius`/`border-width`/`box-shadow` + raio da capa) e pin geométrico da capa de borda a borda (boundingBox do card × capa). Verificação: gate:fast verde, unit 2415 + int 791 verdes, e2e frontend 19/19 local, format/knip/cycles limpos. Plano de implementação: `docs/plans/cards-acompanhe-de-perto-polimento-impl.md`.
@ -42,10 +64,16 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente
**PUB5** — slogan da campanha "Mais saúde mais futuro" no hero e no rodapé da home (jorgesolla1313.com.br), no lugar de "Um mandato do tamanho da Bahia" (discurso do mandato vigente; o novo é da campanha 2026 — saúde é o tema-âncora do candidato). Troca de copy cirúrgica, sem redesenho nem migration: h1 do hero em duas linhas uppercase "MAIS SAÚDE / MAIS FUTURO" com `aria-label` acompanhando ("MAIS SAÚDE MAIS FUTURO"), segunda linha já em `font-black` (anatomia existente), e o rodapé em sentence case com ponto ("Mais saúde mais futuro."). Testes que pinavam a string antiga atualizados (`campaignHero.unit`, `campaignHome.unit`, `frontend.e2e`). Fora de escopo decidido no gate: cabeçalho de artigos ("O mandato do tamanho da Bahia") e docs históricos. Verificação: unit 2269 + int 791 verdes, e2e frontend 8/8 em prod mode (`E2E_PROD=1`, `.next-e2e`), lint/format/typecheck/cycles/knip/build verdes; render conferido por screenshot + DOM (hero duas linhas, rodapé com ponto). `/simplify` 3 reviewers: sem P0/P1; P2 corrigidos no impl plan (redação + line refs); débitos descartados por serem convenções pré-existentes ou decisões do gate.
**Recently resolved (2026-08-20):** **OPS71-FLIP** — flip pós-merge falhou/silencioso no fluxo GitHub, validado e endurecido (Issue #119, achado ao vivo no merge do PR #746). **Investigação (GitHub API pública):** o `closed` do PR #746 (merge por `github-actions[bot]`, auto-merge armado com o `GITHUB_TOKEN` nativo) **não criou run nenhum** — anti-recursão documentada do GitHub (eventos acionados por ações do `GITHUB_TOKEN` não geram runs; o `closed` fica no timeline com 0 runs; comparação com o #742, mergeado por `fsolla`, cujos runs nasceram 3s após o merge). O 410 do run do #742 é falha separada (API do Forgejo respondeu 410 Gone nginx, transitório). **Endurecimento:** (1) **fix de raiz** — o arm do auto-merge (`agent-pr-ready-automerge.yml` → `scripts/github-pr-automerge.mjs`) agora usa o secret novo **`AUTOMERGE_PAT`** (PAT real do fsolla; decisão pura `automergeArmingToken` em `github-pr-flow.mjs` com fail-closed — sem o secret o job sai 1, nunca fallback pro bot token; o merge passa a ser atribuído a usuário real e o `closed` volta a gerar runs); (2) **recuperação manual** — `workflow_dispatch` com input `pr` nos dois flips (`issue-done-on-main-merge.yml`, `plan-issue-ready-on-main-merge.yml`): o job busca o PR via `gh` com o mesmo contrato do evento (merged + base main + same-repo, senão skip) e repassa o body como `PR_BODY`; (3) **close da Issue** — `forgejo-issue-transition.mjs` agora `PATCH state=closed` via novo `closeIssue` do `forgejo-api.mjs` (era-Forgejo o merge fechava nativo; era-GitHub o PR não toca o tracker — antes, toda Issue mergeada ficava `OPEN` com `done`+`in-prod`); (4) **410 no retry** — `forgejo-api.mjs` retenta **410 em qualquer método** (proxy respondeu antes da origem — sem efeito duplicado; 5xx segue fail-closed em escrita) e corpo nginx vira marker compacto `corpo não-JSON (proxy/nginx…)` no erro. **Testes:** 8 specs novas (retry 410 GET/write, marker após 410 esgotado, `closeIssue` PATCH pin, `automergeArmingToken` ok/fail-closed/vazio) — 50 unit verdes nas specs tocadas. **Docs:** `AGENTS.md` (frase do auto-merge com o PAT + porquê), `docs/AGENT-OPS.md` (tabelas de workflows/secrets + runbook de recuperação manual com fallback local `PR_BODY`). Sem migration/access/Consent/UI. Impl: [`ops71-flip-pos-merge-falhou-silencioso-impl.md`](plans/ops71-flip-pos-merge-falhou-silencioso-impl.md).
**Recently resolved (2026-08-19):** **OPS66** — deadlock de migrations em caminho estático: o deploy buildava o runner **antes** do migrate e, se uma migration criasse tabela lida em geração estática (caso S2, 2026-08-18: `social_feed_settings` na home), o `next build` falhava para sempre (`relation ... does not exist`) e o migrate nunca rodava — 4 deploys seguidos vermelhos até o migrator manual. Fix em `scripts/deploy-homeserver.sh` (reordenação de fases, sem custo novo — migrate e build do migrator já rodavam em todo deploy): **build do migrator** (o estágio não roda `next build` — builda contra o schema antigo) → push/tag do migrator → swap do compose (backup + trap na mesma posição) → **migrate** via `docker compose run teqo-1313-migrate` (caminho canônico do serviço de maintenance, agora com a imagem do SHA novo) → **build do runner** contra o banco JÁ migrado → push/tag → rollout → healthcheck → smoke. Alternativas rejeitadas: `payload migrate` dentro do build do runner (efeito colateral de escrita em prod a partir de um processo de build; contraria o desenho deliberado do Dockerfile) e `docker run` direto do migrator com env-file (duplicaria a definição do serviço de maintenance — caminho paralelo ao canônico). Caveat do rollback ajustado no runbook: migrate antes do build do runner deixa o banco à frente do código se o build falhar depois (mesma classe do caveat atual — migrations append-only e revisadas). Testes: `tests/unit/deployScript.unit.spec.ts` com pin de ordem completo (migrator build < swap < migrate < runner build < rollout) + invariante novo do Dockerfile (estágio migrator nunca roda `next build`). Runbook `docs/ops/teqo-1313-deploy.md` atualizado (fluxo, caveat, falha conhecida nova). Dockerfile/AGENTS.md com a precisão build-after-migrate.
**Recently resolved (2026-08-19):** **OPS68** — fechamento da parte 2 do OPS50: reescrita dos docs canônicos operacionais que ainda descreviam a era GitHub/Vercel. **12 arquivos, 78+/82−, edições linha-a-linha:** `docs/AGENT-OPS.md` (norte "só Forgejo + Cursor"; fonte canônica = Issues do Forgejo; fluxo do agente e Contrato de PR sem `gh` — criação via API/MCP + merge pelo safety net `agent-pr-ready-automerge.yml`, o único que espera o rollup `CI (PR) / checks`; "Só humano" = envs de `~/stack/teqo-1313.env` + branch protection + runbook; tabela de secrets sem `POOL_GITHUB_TOKEN` (legado — o script o ignora) e com nota de pool dormente; linha `archive-cursor-agent.yml` na tabela CI; "smoke Neon stage" → sem smoke stage), `README.md` (cheatsheet de 5 linhas reescrito: claim → push → PR via API/MCP → safety net mergea → ci.yml em janela de 30 min + deploy homeserver; Neon → homeserver Postgres; `PROD_DATABASE_URL` = `DATABASE_URL` de `~/stack/teqo-1313.env`; migrações no deploy via `teqo-1313-migrate`; "GitHub mirror" → Forgejo Actions; papéis `coordinator/advisor/leader/candidate`; "electoral nuclei" → municípios; Issues → link Forgejo), `docs/roadmap.md` (só o ponteiro da fonte canônica — o stub continua congelado), `AGENTS.md` (refs Neon → homeserver `teqo_1313`; job do gate `check`→`gate`, key real do YAML; "Vercel preview" → host genérico na rationale do WebAuthn), `.agents/rules/agent-pr-workflow.mdc` (comandos `gh pr create/merge/checks` morrem — `gh` não existe em nenhuma máquina (OPS50); regra descreve API/MCP + safety net; "There is no deploy from CI" corrigido — OPS53 colocou o deploy no `ci.yml`), e as skills `work-issue` (SKILL + `execution-pipeline`), `plan-issue` (frontmatter, gate "nada no Forgejo", dedup via `pnpm issue all`), `project-status`, `capture-review-debts`, `engineering-audit` (`gh issue list` → `pnpm issue all`/API; `issue-done-on-stage-merge.yml`→`on-main`; merge `stage`→`main`), `worktree-next-issue` ("resto do Forgejo") e `agent-pool` (descrição Forgejo Actions; workflow marcado removido OPS65; estado em `pool-state.json`, não mais variables). **Mantido como registro histórico** (exclusões explícitas do plano): menções de Vercel/Neon/POOL_GITHUB que documentam remoções (AGENTS.md L15/L105/L167, AGENT-OPS L94/L98, "Vercel/Git-integration deploy gate" como guardrail no rule). Fonte canônica única: `git.solla.dev/fsolla/teqo`. Sem migration/access/Consent/UI. Gate: grep de saída zero nas classes `gh pr`/`gh issue`/`github.com/fsolla`/`GitHub Issues`/`GitHub mirror`/`POOL_GITHUB`/Neon-em-uso; docs-only → CI roda os docs-guards.
**Recently resolved (2026-08-19):** **OPS71** — CI/PR/deploy do Forgejo Actions de volta para o **GitHub Actions**, com o **tracker de Issues permanecendo no Forgejo** (labels, claims, `pnpm issue`/`agent:*` e flips pós-merge continuam via API do Forgejo; o GitHub é só o host do Actions). O PR CI (`ci-pr.yml`, job único `checks` — OPS62) roda no GitHub com a mesma cascata (guards → lint → format → typecheck → knip → cycles → unit → int → build → e2e selected/afetado via `ci-scope.mjs`), com `concurrency` nativo cancelando o run em push novo e `if:` same-repo (repo público — fork não queima minutes). Seu check-run `CI (PR) / checks` é o required check do `main` e o gate do **auto-merge nativo** (`agent-pr-ready-automerge.yml` → `scripts/github-pr-automerge.mjs`: draft `cursor/*` → Ready via PATCH, arm `enablePullRequestAutoMerge` GraphQL com REBASE — o servidor espera o required check verde; sem poll; o pin OPS64 "rollup pode mentir" é estrutural aqui: job único posta um check-run honesto). **`ci.yml` (verificador de main) eliminado** — publicar é **dispatch manual** (`deploy.yml`): `verify` (hosted, suíte full incl. e2e full) → `deploy` (`needs`, `runs-on: [self-hosted, homeserver]`) executa `scripts/deploy-homeserver.sh <sha>` **localmente** no runner do GitHub instalado no homeserver (sem SSH; outbound-only; o hosted nunca toca o homeserver); o script re-aponta o workspace `~/teqo-deploy` para `TEQO_REPO_URL` (default `https://github.com/fsolla/teqo.git`, público). `pnpm push` passa a empurrar para `origin` = **GitHub** (cutover manual de remote documentado; o repo Forgejo congela em main, tracker vivo por API). Novos helpers plain-Node zero-dep: `scripts/lib/github-api.mjs` (REST+GraphQL, retry OPS67), `scripts/lib/github-pr-flow.mjs` (decisão draft/veto/skip, pura), `scripts/lib/github-branch-protection.mjs` (regra desejada + drift, `enforce_admins: true`), `scripts/github-pr.mjs` (criação de PR Ready — nunca draft), `scripts/configure-branch-protection.mjs` reescrito para a REST do GitHub; `forgejo-issue-transition.mjs` aceita `PR_BODY` (body do PR via evento GitHub; flip continua no Forgejo por `FORGEJO_API_TOKEN`). **Cutover em duas fases (gate):** nada do Forgejo foi apagado (`.forgejo/workflows/`, `forgejo-pr-automerge.mjs`, `forgejo-dispatch.mjs`, `ci-classify-production.mjs` preservados como rollback) — a remoção é entrega sucessora pós-validação. Testes: `githubApi` (10), `githubBranchProtection` (10), `githubPrFlow` (9) unit novos. Docs: `AGENTS.md`, `docs/AGENT-OPS.md`, `.agents/rules/agent-pr-workflow.mdc`, `work-issue/execution-pipeline.md`, `agent-work-issue`, `capture-review-debts`, runbook `docs/ops/teqo-1313-deploy.md` (dispatch manual, runner no homeserver, cutover com ordem, rollback).
**Recently resolved (2026-08-19):** **OPS71 Fase 2** — remoção dos arquivos do fluxo Forgejo após validação ao vivo do GitHub Actions (PRs #742/#743: CI verde, auto-merge nativo, flips via API). **14 arquivos deletados:** `.forgejo/workflows/` (6 — ci-pr.yml, ci.yml, agent-pr-ready-automerge.yml, issue-done-on-main-merge.yml, plan-issue-ready-on-main-merge.yml, archive-cursor-agent.yml), `scripts/forgejo-pr-automerge.mjs`, `scripts/forgejo-dispatch.mjs`, `scripts/ci-classify-production.mjs`, `scripts/lib/dockerignore.mjs`, `scripts/lib/branch-protection.mjs` e as specs órfãs `ciClassifyProduction`/`dockerignoreMatch`/`branchProtection` `.unit.spec.ts`. **Consumidores funcionais achados pela varredura (fora do escopo literal do body) e ajustados na mesma mudança:** `scripts/lib/test-affected-core.mjs` perdeu as 2 entries de `HIGH_RISK_EXACT` dos scripts mortos; `tests/unit/ciSkipInvariants.unit.spec.ts` tirou os 2 `.forgejo/workflows/*` do loop `.next-e2e`, os 2 paths do pin de high-risk e **reescreveu o teste da janela de 30 min (OPS65) para o invariante vivo da era GitHub**: `deploy.yml` é `workflow_dispatch`-only (sem `push:`/`schedule:` — deploy nunca automático) e `ci-pr.yml` não referencia o gate morto. **Docs:** `AGENTS.md` (frase "preservados até Fase 2" → removidos; rollback = revert do PR ou clone do main congelado do Forgejo), runbook `docs/ops/teqo-1313-deploy.md` (Rollback do cutover idem), `README.md` (cheatsheet de 5 linhas era-Forgejo com janela de 30 min + deploy automático → era-GitHub: auto-merge nativo + deploy manual dispatch), comentários com referência morta em `scripts/gate-ci.mjs`, `scripts/github-pr-automerge.mjs` e `.github/workflows/agent-pr-ready-automerge.yml`. **Intocados (anti-goal tracker):** `forgejo-api.mjs`, `agent-*.mjs`, `issue.mjs`, `forgejo-issue-transition.mjs`, `agent-promote-related-on-merge.mjs`, `.forgejo/worktree.env` (consumido pelo provisionamento de worktree), `.dockerignore` (o arquivo — `docker build` do deploy o consome; só o lib de parse morreu). `docs/AGENT-OPS.md` já era era-GitHub (zero referências). Histórico (plans/changelogs antigos) congelado. Sem migration/access/Consent/UI. Gate: tsc/lint/format/knip/cycles/unit+int/build verdes; grep de saneamento `forgejo-pr-automerge|forgejo-dispatch|ci-classify|dockerignore.mjs|branch-protection.mjs` → só histórico.
**Recently resolved (2026-08-18):** **OPS52** — a media saiu do Vercel Blob para o Garage S3 do homeserver (bucket `teqo-media` + key `teqo-key`, já existentes no Garage v2.1.0). O plugin `@payloadcms/storage-vercel-blob` (e o `@vercel/blob` do seed) foi trocado por `@payloadcms/storage-s3` na config, no padrão R2 da doc do Payload (`endpoint` + `forcePathStyle: true`, region `garage`). **Decisão de arquitetura (revisão A→B durante a execução, gate com o humano):** o Garage v2.1.0 **não suporta acesso anônimo na API S3** (sem `--public-read`; source: "when we support anonymous access in the future"; testado: `anonymous`/`anon` → `NoSuchAccessKey`), então o bucket fica **privado** e a app **serve a media por proxy** — sem `disablePayloadAccessControl`, o `staticHandler` do plugin streama o objeto do Garage em `/api/media/file/<filename>` (range + etag/304 nativos) — o **mesmo contrato de URL relativa que as 40 rows pré-existentes já têm** (a recuperação dos arquivos é a Issue #10/OPS52-media). **Fail-closed:** `resolveS3StorageEnv` (`src/utilities/mediaStorage.ts`, unit-testado) — envs `S3_*` parciais → throw no boot com a lista exata de faltantes; nenhuma → storage local (dev/test). Envs novas: `S3_BUCKET`, `S3_ENDPOINT`, `S3_REGION` (default `garage`), `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY` (`.env.example`, `.env.test`, worktree.mjs com cópia all-or-nothing). Seed de posts: pre-delete do Blob removido (o S3 sobrescreve o key determinístico nativamente — idempotência intacta). Prova de contrato real: PUT/GET/DELETE via SDK contra o Garage (tailnet `http://100.119.220.31:3900`), depois media criada pelo Payload local com S3 ativo → `media.url` relativa + `GET /api/media/file/...` → 200 image/png; bucket devolvido a 0 objects e `.env.local` do worktree limpo (sem credenciais de prod). Sem migration (config-only; importMap regenerado sem `VercelBlobClientUploadHandler`). Runbook de cutover registrado no impl plan: setar as 5 `S3_*` no `~/stack/teqo-1313.env` (endpoint tailnet) antes do próximo deploy (OPS53). Repo: `docs/plans/ops52-blob-vercel-para-garage-impl.md`; infra-solla `STATE.md` atualizado à parte.
**Recently resolved (2026-08-18):** **OPS52-media** — os objetos de media de produção (as ~40 capas dos posts, que existiam como rows com URL relativa `/api/media/file/<filename>` mas sem arquivo em lugar nenhum) são recuperáveis do WordPress de origem com `pnpm media:recover` (`scripts/recover-media.mjs`, novo): lê as rows de `media` + `post.coverImage` (mapeamento exato row→slug; fallback pelo filename determinístico `<slug>.<ext>`), resolve a capa com a MESMA lógica do seed (featured media do REST `_embed`, senão 1ª imagem inline do corpo — só 7/43 posts têm featured), baixa e faz `PutObject` no bucket das envs `S3_*` com key = `filename` da row, **sem nunca escrever no DB** (rows e URLs intocadas; o staticHandler do Payload passa a servir). Extraído para `scripts/lib/wpArticles.mjs` o fetch ao vivo do WordPress (seed-posts agora importa de lá — sem twin; unit-testado). Fail-closed: exige as 4 `S3_*` completas (`resolveS3StorageEnv` reusado; recuperação nunca vai pro storage local), `ALLOW_REMOTE_DB=true` explícito para DB remoto + echo do alvo (DB/bucket/endpoint), e timeouts curtos no SDK (`@smithy/node-http-handler`, 5s/15s) — endpoint inalcançável falha em segundos em vez de pendurar (a rota pública de media de prod pendurava >25s; evidência de possível falta de rota container→Garage, diagnosticada no runbook do impl plan). Modos: reconcile (padrão, upload idempotente por overwrite), `--dry-run` (plano + `headObject`), `--verify` (HEAD da URL pública por filename — o aceite da Issue, sem credencial). Runbook de execução no homeserver: `docs/plans/ops52-media-recuperar-arquivos-impl.md`.

View file

@ -8,9 +8,9 @@ Three layers, all local-database-only (see `local-database` skill; tests refuse
- **Integration** (`tests/int/**/*.int.spec.ts`, 71 specs): real Payload + Postgres (`teqo_test`); campaign fixtures allocate seeded municipalities via a Postgres sequence so parallel spec files never share one, and purge residue on claim. Since the miss #73 guardrail (Pass 4), specs that mutate municipalities must use the allocator (no pinned slugs) — enforced by `testMunicipalityAllocatorConventions`. Shared consent rows with stable keys are leased (`tests/helpers/testDatabaseLease.ts`), never owned.
- **E2E** (`tests/e2e`, Playwright, 17 specs / 51 cases (Pass 5, 2026-08-03; was 48 at Pass 4)): smoke-level; most campaign behavior is pinned at the int layer.
CI (`ci-pr.yml`) is a **single sequential job `checks`** (OPS62 X1): guards → lint (zero warnings) → format → typecheck → knip → cycles → unit/int (**affected by the PR diff**: `vitest --changed` against the PR base via `scripts/test-affected.mjs`, full-suite fallback on high-risk paths) → build → e2e (manifest path→spec via `scripts/e2e-affected.mjs` + `scripts/lib/e2e-affected-manifest.mjs`, blocking; production build + `list`/`github` reporters in CI) — against two isolated Postgres 17 services (int / build+e2e) with **`db:seed:minimal`**. Fail-fast is structural: a failed step stops the run; e2e runs with `PLAYWRIGHT_WORKERS=4`. Push to `main` (`ci.yml`) always runs the full net including e2e.
CI (`ci-pr.yml`) is a **single sequential job `checks`** (OPS62 X1): guards → lint (zero warnings) → format → typecheck → knip → cycles → unit/int (**affected by the PR diff**: `vitest --changed` against the PR base via `scripts/test-affected.mjs`, full-suite fallback on high-risk paths) → build → e2e (manifest path→spec via `scripts/e2e-affected.mjs` + `scripts/lib/e2e-affected-manifest.mjs`, blocking; **only in mode `selected` — OPS72: the PR never runs full e2e**; production build + `list`/`github` reporters in CI) — against two isolated Postgres 17 services (int / build+e2e) with **`db:seed:minimal`**. Fail-fast is structural: a failed step stops the run; e2e runs with `PLAYWRIGHT_WORKERS=4`. There is no main verifier (`ci.yml` eliminated in OPS71): the full net including e2e runs in the deploy `verify` job before publishing (manual dispatch).
**Local e2e before push (same blast radius as PR CI):** `pnpm test:e2e:affected` — runs `e2e-affected.mjs` against `origin/stage`, migrates/seeds, then `pnpm test:e2e` with the selected specs (or the full suite on high-risk diffs). Forward extra Playwright args after `--` for a narrowed repro. Opt into production mode (after fixing the known municipalities-list prod client bug) with `E2E_PROD=1 pnpm test:e2e:affected`.
**Local e2e (OPS72 — skill step, discretionary):** before push, run the e2e specs you created + the same affected surface — `pnpm test:e2e:affected` runs `e2e-affected.mjs` against `origin/main`, migrates/seeds, then `pnpm test:e2e` with the selected specs (full suite on high-risk diffs). High-risk diffs diverge from PR CI by design: the PR runs no e2e (selected only, never full) and the local mirror runs the full suite. Forward extra Playwright args after `--` for a narrowed repro. Opt into production mode with `E2E_PROD=1 pnpm test:e2e:affected` (CI and prod mode serve the production build via `pnpm start`; plain local runs use `pnpm dev` — dev-mode flakes like cold-compile timeouts and dev-only console warnings do not reproduce in prod mode).
**Navigation discipline in e2e (miss #54, 2026-07-30):** `page.goto` is for cold loads and URL contracts (deep links, redirects, legacy params); when the scenario is _continuity inside the authenticated shell_ — especially after a heavy list route — navigate in-shell instead (sidebar links, buttons, form submits), the way the user does. Never fire two `page.goto` back-to-back with nothing settling the first navigation (an `await expect(...)`, `waitForURL`, or interaction): the second aborts the first's in-flight RSC fetch (`net::ERR_ABORTED`, measured in `campaignSavedFilters`). The `e2eNavigationConventions` unit guard fails the build on unsettled goto pairs. Two sibling rules of the same family: wait for **interactive state** before clicking dynamic controls — `toBeVisible` proves SSR paint, not hydration (pre-hydration clicks no-op silently; `checkRadixWhenHydrated` is the precedent, P3-C class) — and gate WebAuthn ceremonies on `expectCampaignBiometricsReady` before the island's one-shot probe runs (miss #53). **Dev-mode caveat (closed 2026-08-10, P4-L/OPS30):** `campaignLeaderships`' chip-cell spec was failing deterministically under `pnpm dev` before B34 rewrote it; with the OPS30 prewarm/login hardening it ran ×3 green in dev + ×1 `E2E_PROD=1` (evidence in TECH-DEBT).

View file

@ -0,0 +1 @@
**C143** — demandas visíveis apenas para responsáveis explícitos + candidato/coordenador. Novo campo `campaignDemand.responsibles` (hasMany → `campaignUser`, staff elegível via `eligibleCampaignStaffWhere` com hook `beforeValidate` que descarta ids não-staff, fail-closed) e regra de leitura/atualização por membership: assessor só vê demandas onde é responsável (`{ responsibles: { contains } }`), candidato/coordenador/admin sempre, leader nunca — a regra substitui o escopo municipal de demandas em `src/utilities/access/demands.ts` e todas as superfícies (lista, URL, busca global, demandas de atividade, C90) herdam via `overrideAccess: false`; criação sem responsáveis fica visível só a unrestricted (fail-closed). Criador entra automaticamente como responsável no hook `enforceDemandWorkflow` (cobre form, wizard e rascunhos C90); migration `20260819_233503_add_campaign_demand_responsibles` adiciona `campaign_user_id` a `campaign_demand_rels` (FK cascade) e faz backfill de `created_by_id` → responsável para demandas pré-C143. UI: picker multi-select `DemandResponsibleMultiSelect` (chips com avatar, chip do criador rotulado "(criador)" e travado na criação, sugestões = assessores do município) na criação (`/demandas/nova` em 2 colunas + wizard "Registrar pedido" empilhado) e card de gestão `DemandResponsiblesCard` no detalhe (add/remove com save full-replace + advisory lock, rótulo "Visível para: Responsáveis · Candidato · Coordenador"); busca por `searchDemandResponsibleOptions` (query curta → assessores do município; senão → staff por nome). Testes: int reescritos para a regra nova (workflow/homeSearch) + 4 casos novos (membership, fail-closed sem responsáveis, gestão add/remove/self-removal, responsável fora da carteira, descarte de id não-staff); e2e novo `campaignDemandVisibility.e2e.spec.ts` (assessor do mesmo município não vê lista/URL; hand-off de acesso add/revoke pela UI); fixture e2e ganhou `createCampaignDemand`. Verificação: unit 2444 + int 795 verdes, e2e 5/5 local (visibility + wizard + demanda do municipalidades), lint/format/knip/cycles/tsc/build limpos; importMap regenerado com `S3_*` dummy (OPS69). Nota de deploy: conferir na primeira aplicação em prod `SELECT count(*) FROM campaign_demand_rels WHERE path='responsibles'` = nº de demandas com `created_by_id`. Plano de implementação: `docs/plans/demandas-responsaveis-impl.md`.

View file

@ -0,0 +1 @@
**Recently resolved (2026-08-19):** **OPS71 Fase 2** — remoção dos arquivos do fluxo Forgejo após validação ao vivo do GitHub Actions (PRs #742/#743: CI verde, auto-merge nativo, flips via API). **14 arquivos deletados:** `.forgejo/workflows/` (6 — ci-pr.yml, ci.yml, agent-pr-ready-automerge.yml, issue-done-on-main-merge.yml, plan-issue-ready-on-main-merge.yml, archive-cursor-agent.yml), `scripts/forgejo-pr-automerge.mjs`, `scripts/forgejo-dispatch.mjs`, `scripts/ci-classify-production.mjs`, `scripts/lib/dockerignore.mjs`, `scripts/lib/branch-protection.mjs` e as specs órfãs `ciClassifyProduction`/`dockerignoreMatch`/`branchProtection` `.unit.spec.ts`. **Consumidores funcionais achados pela varredura (fora do escopo literal do body) e ajustados na mesma mudança:** `scripts/lib/test-affected-core.mjs` perdeu as 2 entries de `HIGH_RISK_EXACT` dos scripts mortos; `tests/unit/ciSkipInvariants.unit.spec.ts` tirou os 2 `.forgejo/workflows/*` do loop `.next-e2e`, os 2 paths do pin de high-risk e **reescreveu o teste da janela de 30 min (OPS65) para o invariante vivo da era GitHub**: `deploy.yml` é `workflow_dispatch`-only (sem `push:`/`schedule:` — deploy nunca automático) e `ci-pr.yml` não referencia o gate morto. **Docs:** `AGENTS.md` (frase "preservados até Fase 2" → removidos; rollback = revert do PR ou clone do main congelado do Forgejo), runbook `docs/ops/teqo-1313-deploy.md` (Rollback do cutover idem), `README.md` (cheatsheet de 5 linhas era-Forgejo com janela de 30 min + deploy automático → era-GitHub: auto-merge nativo + deploy manual dispatch), comentários com referência morta em `scripts/gate-ci.mjs`, `scripts/github-pr-automerge.mjs` e `.github/workflows/agent-pr-ready-automerge.yml`. **Intocados (anti-goal tracker):** `forgejo-api.mjs`, `agent-*.mjs`, `issue.mjs`, `forgejo-issue-transition.mjs`, `agent-promote-related-on-merge.mjs`, `.forgejo/worktree.env` (consumido pelo provisionamento de worktree), `.dockerignore` (o arquivo — `docker build` do deploy o consome; só o lib de parse morreu). `docs/AGENT-OPS.md` já era era-GitHub (zero referências). Histórico (plans/changelogs antigos) congelado. Sem migration/access/Consent/UI. Gate: tsc/lint/format/knip/cycles/unit+int/build verdes; grep de saneamento `forgejo-pr-automerge|forgejo-dispatch|ci-classify|dockerignore.mjs|branch-protection.mjs` → só histórico.

View file

@ -0,0 +1 @@
**Recently resolved (2026-08-19):** **OPS71** — CI/PR/deploy do Forgejo Actions de volta para o **GitHub Actions**, com o **tracker de Issues permanecendo no Forgejo** (labels, claims, `pnpm issue`/`agent:*` e flips pós-merge continuam via API do Forgejo; o GitHub é só o host do Actions). O PR CI (`ci-pr.yml`, job único `checks` — OPS62) roda no GitHub com a mesma cascata (guards → lint → format → typecheck → knip → cycles → unit → int → build → e2e selected/afetado via `ci-scope.mjs`), com `concurrency` nativo cancelando o run em push novo e `if:` same-repo (repo público — fork não queima minutes). Seu check-run `CI (PR) / checks` é o required check do `main` e o gate do **auto-merge nativo** (`agent-pr-ready-automerge.yml` → `scripts/github-pr-automerge.mjs`: draft `cursor/*` → Ready via PATCH, arm `enablePullRequestAutoMerge` GraphQL com REBASE — o servidor espera o required check verde; sem poll; o pin OPS64 "rollup pode mentir" é estrutural aqui: job único posta um check-run honesto). **`ci.yml` (verificador de main) eliminado** — publicar é **dispatch manual** (`deploy.yml`): `verify` (hosted, suíte full incl. e2e full) → `deploy` (`needs`, `runs-on: [self-hosted, homeserver]`) executa `scripts/deploy-homeserver.sh <sha>` **localmente** no runner do GitHub instalado no homeserver (sem SSH; outbound-only; o hosted nunca toca o homeserver); o script re-aponta o workspace `~/teqo-deploy` para `TEQO_REPO_URL` (default `https://github.com/fsolla/teqo.git`, público). `pnpm push` passa a empurrar para `origin` = **GitHub** (cutover manual de remote documentado; o repo Forgejo congela em main, tracker vivo por API). Novos helpers plain-Node zero-dep: `scripts/lib/github-api.mjs` (REST+GraphQL, retry OPS67), `scripts/lib/github-pr-flow.mjs` (decisão draft/veto/skip, pura), `scripts/lib/github-branch-protection.mjs` (regra desejada + drift, `enforce_admins: true`), `scripts/github-pr.mjs` (criação de PR Ready — nunca draft), `scripts/configure-branch-protection.mjs` reescrito para a REST do GitHub; `forgejo-issue-transition.mjs` aceita `PR_BODY` (body do PR via evento GitHub; flip continua no Forgejo por `FORGEJO_API_TOKEN`). **Cutover em duas fases (gate):** nada do Forgejo foi apagado (`.forgejo/workflows/`, `forgejo-pr-automerge.mjs`, `forgejo-dispatch.mjs`, `ci-classify-production.mjs` preservados como rollback) — a remoção é entrega sucessora pós-validação. Testes: `githubApi` (10), `githubBranchProtection` (10), `githubPrFlow` (9) unit novos. Docs: `AGENTS.md`, `docs/AGENT-OPS.md`, `.agents/rules/agent-pr-workflow.mdc`, `work-issue/execution-pipeline.md`, `agent-work-issue`, `capture-review-debts`, runbook `docs/ops/teqo-1313-deploy.md` (dispatch manual, runner no homeserver, cutover com ordem, rollback).

View file

@ -0,0 +1 @@
**OPS72** — política de e2e: **CI de PR sem e2e full; local afetado como passo da skill; full só antes do deploy**. Decisão do gate (2026-08-19): (1) **local = discricionário** — o agente roda os e2e que criou + os da mesma superfície afetada, decide quais (`pnpm test:e2e:affected` é a ferramenta, não um gate mecânico; não está no `gate:push`); (2) **CI de PR = blast radius** — `ci-pr.yml` perdeu o step `E2E tests (full suite)`; o e2e roda só em modo `selected` (specs que o classifier detectou como tocadas); diff high-risk (schema/lockfile/harness) classifica `full` e o PR simplesmente não roda e2e — deliberado: o espelho local roda full nesse mesmo diff e o `verify` do deploy roda full antes de publicar; (3) **main = full no deploy** — inegociável, já existia no `deploy.yml` (nada mudou lá). Docs: `work-issue` e `agent-work-issue` (checklist + Passo 4), `execution-pipeline.md` (nova seção "E2E local afetado" com o critério + a **limitação da #72**: `--no-deps` + projetos paralelos colide no `seedTestUser` → `--workers=1` ou a cadeia padrão de projetos), `AGENTS.md` (parágrafo OPS59 → política OPS72), `docs/AGENT-OPS.md` (fluxo do agente + linha do `ci-pr.yml` na tabela), `.agents/rules/agent-pr-workflow.mdc` (descrição do `gate:push`), mensagens informativas do `gate-ci.mjs` (texto-only — e2e continua fora do gate) e comentários do workflow. Mecânica intocada por escopo (`ci-scope.mjs`, manifest, `run-e2e-affected.mjs`); o fix da #72 fica na fila (só documentado). Verificação: format/lint/tsc verdes, unit 2444 + int 791 verdes; o e2e local afetado rodou a suíte **full** (o diff toca `scripts/gate-ci.mjs`, high-risk → modo `full` — a política em ação): 154 passed em dev mode; 8 falhas de ambiente (timeouts/cold-compile sob carga) triadas — 5/8 passaram em re-run e as 3 restantes passaram em `E2E_PROD=1` (o modo do CI/verify; 41 passed + 2 flaky que passaram isoladas), zero regressão de produto; o CI deste PR não roda e2e (modo `full` por design desta mesma política). Impl plan: `docs/plans/ops72-e2e-local-afetado-ci-sem-full-impl.md`. Nota: `E2E_PROD=1` local regenera o `importMap.js` **sem** as envs de S3 (classe OPS69 — o guard do OPS70/#87 cobre; restaurado na entrega com envs dummy).

View file

@ -0,0 +1 @@
**S11** — o feed do Instagram configurado **sem aparecer na home** fica diagnosticável: fim do fail-closed silencioso da S3. A assessoria vê, na mesma global onde preenche o token, o estado da sincronização (`instagramSyncStatus` jsonb hidden na `social-feed-settings`) e o **porquê** de os posts não aparecerem — o painel `InstagramSyncStatusPanel` (campo `ui` irmão do picker) mostra 5 estados fiéis ao rascunho aprovado (desativado / ainda não configurado / falha com motivo em linguagem de produto + "Tentar sincronizar de novo" / sincronizado · há X min · N posts / aguardando 1ª sync). A causa da ocorrência de produção (board vazio com token+ID preenchidos) é agora confirmável em 1 clique após o deploy. Engenharia: `loadInstagramFeed` passou a parsear o body de erro da Graph API e lançar `InstagramApiError` (`status` + `{message,type}`); `describeInstagramError` mapeia para copy pt-BR com a correção — OAuthException/expired → "token inválido/expirado ou emitido via Facebook Login — gere pelo Instagram Login" (o refresh endpoint da Graph API só renova tokens de Instagram Login), "Invalid user id" → "confira o ID da conta Business/Creator", 5xx → indisponível, `TypeError` → rede. Estado escrito por 3 caminhos: render da home (`getInstagramFeed`, dentro do `unstable_cache`, SQL direto sem hooks — padrão anti-`revalidateTag`-no-cache da S2), `afterChange` da global (sync **awaited** quando a credencial IG muda — `instagramCredentialsChanged(doc, previousDoc)`, gate que pula save de exclusões/YouTube; o sync é importado lazy no hook para o `payload.config.ts` não puxar o módulo do feed no load, que é o que todo spec int importa) e a rota `POST /api/social-feed/sync` (botão; `payload.auth` sessão admin exigindo `users` + `isSameOriginRequest` CSRF — allowlistada no spec de convenção de POST routes com razão, é auth de admin ≠ envelope `/campanha`). **Bug real descoberto e corrigido na entrega:** o `afterChange` roda DENTRO da transação do save (a row lock fica presa até os hooks retornarem), então escrever o status/snapshot via pool numa segunda conexão **deadlockava no lock da própria linha** (~80-230s por save, transações-zumbi segurando o lock). A correção roteia os persists do sync-hook pela **mesma transação** (`getPostgresTransactionDatabase` — o helper transacional da C6) com um `req` opcional no `syncInstagramFeed`; o render e a rota sem transação aberta seguem no pool. Diagnóstico reproduzido em `pg_stat_activity` (UPDATE `transactionid` Lock vs `idle in transaction`) e fix verificado (45ms vs 232s). Stub IG ganha estado `invalid-token` (400 OAuthException) para o e2e da cena de falha; runbook `docs/ops/instagram-feed-token-runbook.md` (o que o painel mostra, validação via curl `/me` e `refresh_access_token`, geração pelo Instagram Login, o que NÃO fazer). Migration `20260820_003426_add_instagram_sync_status` (ADD COLUMN jsonb nullable, não destrutiva). **/simplify (3 reviewers):** absorvi a correção P1 de diagnóstico — o mapeamento de ID exigia só "user" (regex frouxo) e mensagens reais de token OAuthException ("the **user** must be logged in", "user changed password") caíam na branch de ID, mostrando a razão errada; passei a exigir "user" E "id" juntos (`isInvalidUserIdMessage`) e adicionei o mapeamento de `AbortError` (timeout → "demorou demais"). Painel: gate passou a respeitar o master `enabled` (master off → "Instagram desativado", nunca "Sincronizado no board"), rótulo do botão no estado de primeira sync ("Tentar sincronizar" vs "de novo"), `outcome` transiente limpo ao editar credencial e `aria-live` nos boxes interativos. Testes: caminho transacional do `syncInstagramFeed` (persist na mesma transação) + fallback pool cobertos por unit; e2e do painel ganhou as cenas fail-500 e "desativado"; cleanup do stub global virou `afterEach`. Débitos registrados: #124 (janela de lock: fetch de rede no sync-hook segura a row lock até 10s, acoplando a home ao save) e #125 (polish P3 — tipo de persist duplicado, rota 200 vs 400, last-writer-wins stale, `5 posts` hardcoded). Verificação: unit 2463 + int 791 verdes; lint/format/tsc/cycles verdes; `pnpm build` ok; e2e local — smoke admin 5/5 (painel ok → falha token → retry → falha 500 → desativado → não configurado) + describe serial frontend 19/19.

View file

@ -0,0 +1 @@
**Recently resolved (2026-08-20):** **OPS71-FLIP** — flip pós-merge falhou/silencioso no fluxo GitHub, validado e endurecido (Issue #119, achado ao vivo no merge do PR #746). **Investigação (GitHub API pública):** o `closed` do PR #746 (merge por `github-actions[bot]`, auto-merge armado com o `GITHUB_TOKEN` nativo) **não criou run nenhum** — anti-recursão documentada do GitHub (eventos acionados por ações do `GITHUB_TOKEN` não geram runs; o `closed` fica no timeline com 0 runs; comparação com o #742, mergeado por `fsolla`, cujos runs nasceram 3s após o merge). O 410 do run do #742 é falha separada (API do Forgejo respondeu 410 Gone nginx, transitório). **Endurecimento:** (1) **fix de raiz** — o arm do auto-merge (`agent-pr-ready-automerge.yml` → `scripts/github-pr-automerge.mjs`) agora usa o secret novo **`AUTOMERGE_PAT`** (PAT real do fsolla; decisão pura `automergeArmingToken` em `github-pr-flow.mjs` com fail-closed — sem o secret o job sai 1, nunca fallback pro bot token; o merge passa a ser atribuído a usuário real e o `closed` volta a gerar runs); (2) **recuperação manual** — `workflow_dispatch` com input `pr` nos dois flips (`issue-done-on-main-merge.yml`, `plan-issue-ready-on-main-merge.yml`): o job busca o PR via `gh` com o mesmo contrato do evento (merged + base main + same-repo, senão skip) e repassa o body como `PR_BODY`; (3) **close da Issue** — `forgejo-issue-transition.mjs` agora `PATCH state=closed` via novo `closeIssue` do `forgejo-api.mjs` (era-Forgejo o merge fechava nativo; era-GitHub o PR não toca o tracker — antes, toda Issue mergeada ficava `OPEN` com `done`+`in-prod`); (4) **410 no retry** — `forgejo-api.mjs` retenta **410 em qualquer método** (proxy respondeu antes da origem — sem efeito duplicado; 5xx segue fail-closed em escrita) e corpo nginx vira marker compacto `corpo não-JSON (proxy/nginx…)` no erro. **Testes:** 8 specs novas (retry 410 GET/write, marker após 410 esgotado, `closeIssue` PATCH pin, `automergeArmingToken` ok/fail-closed/vazio) — 50 unit verdes nas specs tocadas. **Docs:** `AGENTS.md` (frase do auto-merge com o PAT + porquê), `docs/AGENT-OPS.md` (tabelas de workflows/secrets + runbook de recuperação manual com fallback local `PR_BODY`). Sem migration/access/Consent/UI. Impl: [`ops71-flip-pos-merge-falhou-silencioso-impl.md`](plans/ops71-flip-pos-merge-falhou-silencioso-impl.md).

View file

@ -0,0 +1 @@
**OPS71-INFRA (2026-08-19):** runner **self-hosted do GitHub instalado no homeserver** (Issue #113) — o job `deploy` do `deploy.yml` (dispatch manual) agora roda localmente no homeserver, sem SSH. Detalhes: `~/actions-runner` v2.336.0 linux-x64 (sha256 conferido contra a API de releases), nome `teqo-1313-runner`, labels `self-hosted` + `homeserver`, registrado com o PAT do OPS71 (registration token TTL 1h gerado e aplicado na sequência). Roda como **systemd user** `actions.runner.fsolla-teqo.teqo-1313-runner.service` (linger já ativo; `svc.sh install` exige root — unit user criado manualmente do template oficial), usuário `fsolla` (docker + `~/stack` envs), workdir `_work`, outbound-only (funciona atrás do Cloudflare tunnel). Verificado `online` na API do GitHub com as labels corretas. Registrado em infra-solla `STATE.md` (seção Teqo: runner + fluxo deploy manual atualizado; linha do repo/CI corrigida para o pós-cutover GitHub). Workspace `~/teqo-deploy` re-aponta para o GitHub no primeiro deploy via `set-url` idempotente do script. Pendência registrada: secret `CURSOR_API_KEY` no GitHub fica dormente (helper `archive-cursor-agent.yml`; a key não existe no ambiente — pool morto OPS65) até o usuário pedir a reativação.

View file

@ -0,0 +1 @@
**OPS73 — deploy do fix do admin em produção (2026-08-20):** o `/admin` do Payload (jorgesolla1313.com.br) voltou a renderizar o formulário de login em produção — o bug de "tela branca" que travava o CMS do time editorial/campanha está resolvido e verificado. Durante o diagnóstico descobriu-se que a premissa do plano ("produção nunca recebeu o fix OPS69") estava errada: o deploy das 23:09 de 2026-08-19 (revision `8c734ba`) já continha o fix do importMap **no histórico**, mas o commit S9 (`8d2352c0`, captura de novidades na home) tinha regenerado o importMap **sem** as envs `S3_*`, removendo o handler `@payloadcms/storage-s3/client#S3ClientUploadHandler` e reintroduzindo o erro `getFromImportMap: PayloadComponent not found in importMap` — o admin abria em branco de novo ("funcionou e no deploy seguinte parou"). Verificado também que o **main do GitHub havia divergido do Forgejo** (pós-cutover OPS71): o GitHub tinha o fix + OPS71, mas não as features S8 (seção "Nossa história") e S9 (captura de novidades) que produção já rodava — deployar o main como estava regrediria as duas. A entrega: PR #751 re-homou no GitHub main as features S8+S9 (cherry-pick dos commits aprovados no Forgejo `8d2352c0` + `488dcf7d`), mantendo o handler no importMap (prova: `generate:importmap` com envs `S3_*` dummy → diff vazio vs main); CI verde; aguardando o merge. Deploy pós-merge via o pipeline canônico do OPS71 (dispatch manual do `deploy.yml`: `verify` full → job `deploy` no runner self-hosted recém-instalado no homeserver — 2 tentativas "stale run" por merges paralelos no meio do verify, terceira aplicou `dba75ff8`); migração C143 (responsáveis de demandas, aditiva+backfill) aplicada. Verificação pós-deploy: container na revision `dba75ff8`, healthy, **log sem nenhum `getFromImportMap`**, `/admin` renderizando o formulário de login (2 inputs email/senha + botão Login, título "Login - Payload", 0 erros de console), e a home pública mantendo "Nossa história" (S8) e o form de novidades (S9). Evidência: `docs/plans/ops73-deploy-admin-prod-fixed-evidence.png`. Lições registradas: a regeneração do importMap sem envs `S3_*` re-órfão o handler (classe exata que o guard #87 deve impedir no CI); divergência de plataformas pós-cutover — workspace do homeserver ainda apontava para o Forgejo local (`localhost:3000`) e o S10 (pixel) segue pendente de re-home no GitHub.

View file

@ -0,0 +1,71 @@
# Runbook — Feed do Instagram: validar/gerar o token (S11)
Issue: #115 · Entrega: S11 (painel de status da sincronização no admin)
## O que o painel mostra
Na global **Configurações → Feed de redes sociais**, o bloco "Instagram — estado
da sincronização" diz o estado da última tentativa:
| Estado | Significado | Ação |
| --------------------------------------- | ------------------------------------------------------------------------- | ------------------------------------------------------------------ |
| **Sincronizado · há X min · N posts** | Credenciais aceitas; posts no board "Acompanhe de perto" | Nenhuma |
| **Falha na última sincronização** | A Graph API recusou a última tentativa; o texto diz o motivo e a correção | Corrigir conforme o motivo e clicar **Tentar sincronizar de novo** |
| **Instagram ainda não configurado** | Falta token ou ID | Preencher os dois campos |
| **Aguardando a primeira sincronização** | Configurado, nenhuma tentativa ainda | Clicar **Tentar sincronizar de novo** |
O estado é atualizado no salvar (quando as credenciais mudam), em cada render da
home (cache de 5 min) e no clique do botão. A home pública nunca quebra quando a
API falha — o board segue com artigos + YouTube.
## Diagnóstico da ocorrência de 2026-08-19 (causa provável)
Produção mostrava picker vazio e nenhum card IG com token + ID preenchidos — o
fail-closed silencioso da S3 não dizia o porquê. A causa mais provável, na ordem:
1. **Token emitido via Facebook Login** (page token) — a `graph.instagram.com`
recusa; o refresh automático não renova (o endpoint `refresh_access_token`
só aceita tokens do Instagram Login).
2. **Token expirado/revogado**.
3. **ID do usuário errado** (não é o da conta Business/Creator vinculada).
**Confirmação em 1 clique:** no admin, **Tentar sincronizar de novo** — o painel
mostra o motivo exato devolvido pela API. Se a causa for a 1, aparece a mensagem
de token recusado com a correção; se for a 3, a mensagem de ID não reconhecido.
## Validar um token sem o admin
```bash
# O endpoint /me responde com o username e o tipo de conta se o token é válido
# para a Graph API do Instagram (Business/Creator):
curl -s "https://graph.instagram.com/me?fields=user_id,username&access_token=SEU_TOKEN"
# Teste o refresh (só tokens emitidos pelo Instagram Login passam):
curl -s "https://graph.instagram.com/refresh_access_token?grant_type=ig_refresh_token&access_token=SEU_TOKEN"
```
- `/me` com `OAuthException` → token inválido/expirado ou emitido via
**Facebook Login** (a Graph API do Instagram só aceita tokens do Instagram
Login).
- `/me` ok mas o ID configurado não bate com `user_id` → corrigir o campo
"ID do usuário".
## Gerar um token de longa duração (Instagram Login)
1. Acessar `developers.facebook.com` com a conta que administra a página →
criar/usar um app do tipo **Business** (qualquer tipo serve para o fluxo de
negócios, mas o app precisa estar em modo Live para uso fora do sandbox).
2. Produto **Instagram** → **Instagram Login with Basic Display** → **Gerar
token** com a conta Business/Creator do @depjorgesolla.
3. Trocar o token de curta duração pelo de longa duração usando o
**Long-Lived Token Exchange** da documentação do Instagram Basic Display
(ou usar o endpoint `refresh_access_token` do passo anterior após a troca).
4. Colar o token e o ID numérico da conta no admin e clicar
**Tentar sincronizar de novo**.
## O que NÃO fazer
- Não usar token da API Graph do Facebook (página) — o app recusa.
- Não colar o token em arquivos, Issues ou chat — o campo é admin-only e deve
continuar.
- Não zerar o token para "testar" — o painel de status dispensa tentativa-e-erro.

View file

@ -1,40 +1,30 @@
# Runbook: deploy do site 1313 no homeserver (OPS53, janela OPS65)
# Runbook: deploy do site 1313 no homeserver (OPS53 + OPS71 — dispatch manual)
O deploy de produção é uma etapa do CI (`ci.yml` no Forgejo): mergir em `main`
com a suite verde publica o site em `jorgesolla1313.com.br` — sem passo manual.
Merge **com mudança de produção** = site atualizado em ~30 min (janela) + tempo
da suite/deploy (~15–25 min cada; tipicamente < 1 h no total).
O deploy de produção é uma **action manual** (`workflow_dispatch` no GitHub
Actions, `.github/workflows/deploy.yml`): mergir em `main` **não** publica
nada. O operador dispara o deploy quando decide publicar; antes de tocar o
homeserver, o job `verify` (hosted) roda a suíte **full** (incl. e2e full).
## Gatilho e fluxo
1. `git push` para `main` (via PR merge) — o `ci.yml` **não dispara mais a cada
push**: roda por **janela fixa de 30 min** (`schedule */30`) ou via
`workflow_dispatch` (deploy manual, imediato, sempre full suite).
2. Job `gate` (barato, host, sem pnpm): lê a **revision do container rodando**
no homeserver (`docker inspect` → label `org.opencontainers.image.revision`
do `teqo-1313` — a fonte de verdade; o compose pode mentir após rollback
falho, pois é trocado antes do rollout) e classifica "mudança de produção"
com o `.dockerignore` como fonte da verdade (o que não entra na imagem não
justifica build novo; `docs/`, `.agents/`, `.env*` viram skip;
`scripts/`, `tests/`, `.forgejo/`, `AGENTS.md` contam como produção —
conservador, qualquer dúvida roda a suite).
- Deployed == HEAD → suite e deploy **skipped**; run verde.
- Sem revision conhecida (label ausente, container sem label) → fail-open:
suite + deploy rodam na janela (run verde; comportamento conservador).
- Gate incapaz de classificar (erro de git/`.dockerignore`) → suite roda e
o run fica **vermelho** — nunca blackout silencioso.
3. Com mudança de produção: suite completa verde (static/int/build/e2e/checks).
4. Job `deploy` (runs-on `host`, na **workstation**) roda
`ssh homeserver "bash -s -- <sha>" < scripts/deploy-homeserver.sh`.
O script é **idempotente**: se o container rodando já tem a revision do SHA
(ex.: dispatch duplicado), sai verde "already deployed" sem rebuild.
5. O script, no **homeserver**: guarda de HEAD (`main` remoto == SHA do job,
senão skip) → `flock` (serializa) → guard "already deployed" (revision do
container) → **build do migrator** (o estágio migrator não roda `next
build` — builda mesmo contra o schema antigo; BuildKit, secrets do
`~/stack/teqo-1313.env`) → push/tag do migrator em `localhost:5000` →
swap dos tags de imagem no `~/stack/docker-compose.yml` (backup antes) →
**migrations**
1. GitHub → Actions → **Deploy (manual)** → Run workflow (ref: `main`).
2. Job `verify` (hosted `ubuntu-latest`, ~50 min): suíte full sem skips —
check-test-locations → lint → format → typecheck → knip → cycles → unit →
int (migrate+seed nos services) → build → e2e full.
3. Se `verify` verde, o job `deploy` (`needs: [verify]`,
`runs-on: [self-hosted, homeserver]`) roda no **runner do GitHub instalado
no homeserver** e executa `scripts/deploy-homeserver.sh <sha>` **localmente**
(sem SSH; o runner conecta outbound ao GitHub — funciona atrás do
Cloudflare tunnel; o hosted nunca toca o homeserver; o self-hosted não
conta minutos hosted).
4. O script, no **homeserver**: guarda de HEAD (`git ls-remote` de
`TEQO_REPO_URL` — default `https://github.com/fsolla/teqo.git`, público —
== SHA do job, senão "stale run" skip) → `flock` (serializa) → guard
"already deployed" (revision do container rodando) → **build do migrator**
(o estágio migrator não roda `next build` — builda mesmo contra o schema
antigo; BuildKit, secrets do `~/stack/teqo-1313.env`) → push/tag do
migrator em `localhost:5000` → swap dos tags de imagem no
`~/stack/docker-compose.yml` (backup antes) → **migrations**
(`docker compose --profile maintenance run --rm teqo-1313-migrate </dev/null`,
já com a imagem do SHA novo) → **build do runner** (contra o banco JÁ
migrado — o `next build` da geração estática lê o schema novo, OPS66;
@ -43,27 +33,63 @@ build` — builda mesmo contra o schema antigo; BuildKit, secrets do
→ push/tag do runner → `docker compose up -d teqo-1313` → healthcheck →
smoke (`/`, `/campanha/login`, `/admin`, barreira 307, WebAuthn
login-options, `api/revalidate` com o secret real).
6. Falha = job vermelho no commit; nada é publicado pela metade.
5. Falha = job vermelho; nada é publicado pela metade (rollback automático
após o swap).
**Primeira janela (verificação ao vivo):** após o merge do OPS65, acompanhe um
run agendado — confira no log do job `gate` o `deployed_sha` lido do container e
o veredito da classificação, e que um dispatch manual roda suite + deploy
(idempotência: segundo dispatch no mesmo SHA sai "already deployed"). Isso
prova ao vivo a paridade Forgejo de `needs.<gate>.outputs` com gate falho
(suite roda; deploy pula; run vermelho).
**Primeiro deploy (verificação ao vivo):** depois do cutover OPS71, dispare o
deploy manual e confira no log do job `deploy` o `set-url` do workspace
(origem Forgejo → GitHub), o `deployed_sha` lido do container e o guard
"already deployed" num segundo dispatch do mesmo SHA.
## Onde roda cada coisa
| Máquina | Papel |
| -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **workstation** (16c/61GB) | runner Forgejo (`workstation-runner`); job `deploy` roda no host (chave SSH `~/.ssh/id_ed25519` → `ssh 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; workspace `~/teqo-deploy` (clone do Forgejo local) |
| 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; **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) |
Segredos **não** ficam no Forgejo: o script sourceia `~/stack/teqo-1313.env` e
`~/stack/.env` (chmod 600) no próprio homeserver. Nenhum secret novo em
Settings → Actions.
Segredos **não** ficam no GitHub: o script sourceia `~/stack/teqo-1313.env` e
`~/stack/.env` (chmod 600) no próprio homeserver. O repo GitHub é público — o
clone do workspace não precisa de credencial. No GitHub ficam apenas os
secrets de integração: `FORGEJO_API_TOKEN` (flips pós-merge) e
`CURSOR_API_KEY` (archive helper, dormente).
## Rollback
## Cutover OPS71 (passos manuais, ordem)
1. Remotes locais (config compartilhada pelos worktrees):
`git remote set-url origin git@github.com:fsolla/teqo.git` e
`git remote add forgejo ssh://git@192.168.15.142:2222/fsolla/teqo.git`
(ou `https://git.solla.dev/fsolla/teqo.git`) — o repo Forgejo congela em
main; o tracker (Issues) segue vivo por API.
2. GitHub repo: secrets `FORGEJO_API_TOKEN` + `CURSOR_API_KEY`
(Settings → Secrets and variables → Actions) e **`Allow auto-merge`**
(Settings → General → Pull Requests; ou `PATCH /repos/...` com
`allow_auto_merge: true`). Repo é público: em Settings → Actions →
General, defina fork PR workflows como não executar (ou exigir aprovação).
3. PAT local: criar token GitHub com escopo `repo` e exportar `GITHUB_TOKEN`
(para `node scripts/github-pr.mjs` e `pnpm configure:branch-protection`).
4. `GITHUB_TOKEN=… pnpm configure:branch-protection` — required check
`CI (PR) / checks` (literal de match: `checks` — o GitHub casa pelo nome do
check-run; a UI exibe workflow/job; pin PR #742), 0 reviews,
`enforce_admins: true`. **Antes do primeiro push** (senão o PR mergearia
sem required check).
5. **Desligar o runner do Forgejo** na workstation (ex.:
`systemctl --user stop forgejo-runner`) — após o primeiro PR GitHub
validado; para o schedule do `ci.yml` antigo.
6. Instalar o runner self-hosted do GitHub no homeserver (labels
`self-hosted` + `homeserver`; usuário com docker + acesso a `~/stack`;
registrar em infra-solla: `STATE.md`).
7. Push do PR do OPS71 → CI verde → auto-merge → flip da Issue no Forgejo →
deploy manual de validação.
Rollback do cutover (se o GitHub falhar cedo): religar o runner do Forgejo e
abrir PRs no Forgejo a partir do main congelado — os workflows de
`.forgejo/workflows/` foram removidos na OPS71 Fase 2, mas restauram do git
history (`git revert` do PR da remoção, ou clone do main congelado do Forgejo)
— remotes: `git remote set-url origin ssh://git@192.168.15.142:2222/fsolla/teqo.git`.
## Rollback (do deploy)
A imagem anterior **não** fica mais local após a limpeza pós-deploy (INF3/F2:
`docker builder prune` + remoção das tags locais antigas, exceto o SHA em uso)
@ -91,39 +117,40 @@ roda após migrator build+push+swap ok; rollout só após runner build ok). Desd
o OPS66 o migrate roda **antes** do build do runner (o build precisa do schema
novo): um build do runner que falhe depois do migrate deixa o banco à frente
do código — migrations são append-only e revisadas antes do deploy (checklist);
a correção se re-mergeia e a próxima janela completa o deploy.
a correção se re-mergeia e o próximo deploy completo publica.
## Falhas conhecidas
| Sintoma | Causa | Tratamento |
| ----------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Job verde sem deploy ("stale run") | Push mais novo venceu; o deploy antigo saiu da fila | Nada a fazer — o run do SHA novo deploya |
| Job verde sem deploy ("already deployed") | O container rodando já tem a revision do SHA do job (ex.: `workflow_dispatch` duplicado da mesma HEAD) | Nada a fazer — o site já roda esse SHA. Forçar rebuild da MESMA HEAD não é suportado via dispatch: edite o tag da imagem no compose (`image: teqo-1313:<sha>-rebuild`) e `docker compose up -d`, ou espere um commit novo |
| Gate `gate` vermelho (erro de git/`.dockerignore`) | SHA deployado ilegível para o git (ex.: história reescrita) ou `.dockerignore` corrompido no checkout | Fail-open: suite roda; deploy pula (nunca deploy às cegas). Corrigir a causa e esperar a próxima janela (auto-cura). Se `ssh homeserver` estiver fora, o gate lê revision vazia e roda tudo (verde) — checar tailnet se a suite roda sem deploy recorrente |
| Sem revision no container (label ausente no compose) | O compose do homeserver não define `org.opencontainers.image.revision` (deploy pré-OPS53 ou edição manual) | Degradação conservadora: toda janela roda suite + rebuild (verde). Corrigir adicionando o label ao serviço `teqo-1313` no compose e re-deployando |
| Todo job docker quebra com `exec: "node": executable file not found in $PATH` | `runner.envs.PATH` do `~/.forgejo-runner/config.yaml` (workstation) com paths só de host (nvm/bun) — o container perde o node do toolcache (`/opt/acttoolcache`); edição do config exige o toolcache primeiro (ver comentário no próprio config) | Corrigir o PATH no config (toolcache primeiro — ver comentário no próprio arquivo) + `systemctl --user restart forgejo-runner` (incidente 2026-08-17) |
| Deploy para logo após o migrate ("Done." e nada mais, EXIT=0) | `docker compose run` anexa stdin por padrão — o container consome o resto do script que vai no pipe do `bash -s`; o bash chega a EOF e termina sem rodar o rollout | O script já usa `< /dev/null` no `run --rm` do migrate (não remover); sintoma visto 2026-08-17 no primeiro deploy |
| Build falha: `network mode "stack_default" not supported by buildkit` | BuildKit (drivers docker e docker-container) recusa rede bridge custom no `--network` | O script builda com `--network host` + proxy socat `teqo-1313-build-proxy` (na `stack_default`, publicado em 127.0.0.1:5433, criado idempotentemente com `restart: unless-stopped`) + `DATABASE_URL` reescrita para o loopback |
| Build OOM no homeserver | Laptop 8c/16GB com o stack ativo (~12GB livres medidos) | Re-dispatch; se recorrente, item futuro: build na workstation com túnel |
| Build do runner falha: `relation "..." does not exist` no `next build` | Migration nova criou tabela lida em geração estática. Pré-OPS66 a ordem era build→migrate e o deploy morria aqui para sempre (incidente 2026-08-18, S2) | Pós-OPS66 não deve ocorrer: migrate roda antes do build do runner. Se reaparecer, confira no log se o passo migrate rodou; recovery manual: `docker build --target migrator` + `docker run --rm --network stack_default --env-file ~/stack/teqo-1313.env localhost:5000/teqo-1313-migrator:<sha>` e re-dispatch do workflow |
| `checkout@v5` falha no job `host` | Label host do act_runner | Fallback: baixar o script direto do Forgejo (`curl -s https://git.solla.dev/fsolla/teqo/raw/branch/main/scripts/deploy-homeserver.sh`) |
| Migrate falha | Drift/erro de schema | Job vermelho; site segue no container antigo; corrigir e re-mergear |
| Smoke falha pós-up | Regressão de runtime | Rollback automático (restore + `up -d`) + job vermelho; investigar |
| Sintoma | Causa | Tratamento |
| ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Job verde sem deploy ("stale run") | Push mais novo venceu; o dispatch apontava um SHA que não é mais o HEAD do main do GitHub | Nada a fazer — dispatch novo no main atual |
| Job verde sem deploy ("already deployed") | O container rodando já tem a revision do SHA do job (ex.: `workflow_dispatch` duplicado da mesma HEAD) | Nada a fazer — o site já roda esse SHA. Forçar rebuild da MESMA HEAD não é suportado via dispatch: edite o tag da imagem no compose (`image: teqo-1313:<sha>-rebuild`) e `docker compose up -d`, ou espere um commit novo |
| Job `deploy` nunca roda / runner offline | Runner self-hosted do homeserver não instalado ou parado (passo manual do cutover OPS71) | Instalar/religar o runner (labels `self-hosted`, `homeserver`); os PRs e o `verify` não dependem dele — só o deploy |
| Deploy dispara mas o job `verify` falha | Regressão real na suíte full | Corrigir e re-dispatchar — o homeserver nunca é tocado |
| Deploy para logo após o migrate ("Done." e nada mais, EXIT=0) | `docker compose run` anexa stdin por padrão — o container consome o resto do script; o bash chega a EOF e termina sem rodar o rollout | O script já usa `< /dev/null` no `run --rm` do migrate (não remover); sintoma visto 2026-08-17 no primeiro deploy |
| Build falha: `network mode "stack_default" not supported by buildkit` | BuildKit (drivers docker e docker-container) recusa rede bridge custom no `--network` | O script builda com `--network host` + proxy socat `teqo-1313-build-proxy` (na `stack_default`, publicado em 127.0.0.1:5433, criado idempotentemente com `restart: unless-stopped`) + `DATABASE_URL` reescrita para o loopback |
| Build OOM no homeserver | Laptop 8c/16GB com o stack ativo (~12GB livres medidos) | Re-dispatch; se recorrente, item futuro: build na workstation com túnel |
| Build do runner falha: `relation "..." does not exist` no `next build` | Migration nova criou tabela lida em geração estática. Pré-OPS66 a ordem era build→migrate e o deploy morria aqui para sempre (incidente 2026-08-18, S2) | Pós-OPS66 não deve ocorrer: migrate roda antes do build do runner. Se reaparecer, confira no log se o passo migrate rodou; recovery manual: `docker build --target migrator` + `docker run --rm --network stack_default --env-file ~/stack/teqo-1313.env localhost:5000/teqo-1313-migrator:<sha>` e re-dispatch do workflow |
| Workspace clone sem o SHA novo | O workspace antigo apontava para o Forgejo local (pré-OPS71) e o fetch não achou o SHA | O script agora re-aponta `origin` para `TEQO_REPO_URL` (idempotente) antes do fetch — se ainda falhar, `git -C ~/teqo-deploy remote -v` e corrigir manualmente |
| Migrate falha | Drift/erro de schema | Job vermelho; site segue no container antigo; corrigir e re-mergear |
| Smoke falha pós-up | Regressão de runtime | Rollback automático (restore + `up -d`) + job vermelho; investigar |
## Segurança (decisão deliberada)
- O job `deploy` usa `runs-on: host` — executa como `fsolla` na workstation.
Está **apenas** no `ci.yml` (janela `*/30` em `main` + dispatch), nunca no
`ci-pr.yml`. O job `gate` tem o mesmo perfil e é restrito a
`github.ref == 'refs/heads/main'` (dispatch em branch não roda código do
branch no host — a suite roda fail-open em containers com secrets de teste).
`main` só anda por PR mergeado com suite verde. Fork-PRs não disparam
workflows no Forgejo (default off — verificado).
- O job `deploy` usa `runs-on: [self-hosted, homeserver]` — executa como o
usuário do runner no homeserver. Está **apenas** no `deploy.yml`
(dispatch manual), nunca no `ci-pr.yml`; o hosted (`verify`) não tem acesso
ao homeserver e o self-hosted não recebe secrets de produção (as envs vivem
no próprio homeserver). `main` só anda por PR mergeado com CI verde; o
deploy só roda com `verify` full verde. Fork-PRs não rodam CI (same-repo
gate + setting do repo).
- Segredos nunca ecoados: sem `set -x`, senhas via `--password-stdin` /
build-secrets; envs só no homeserver.
## Referências
- `scripts/deploy-homeserver.sh` — o script remoto (fonte da verdade do fluxo)
- `docs/plans/ops53-ci-deploy-homeserver*.md` — intenção e decisões
- `scripts/deploy-homeserver.sh` — o script (fonte da verdade do fluxo)
- `.github/workflows/deploy.yml` — verify → deploy
- `docs/plans/ops53-ci-deploy-homeserver*.md` — intenção e decisões (era Forgejo)
- `docs/plans/ops71-ci-github-actions-tracker-forgejo*.md` — o cutover
- infra-solla: `STATE.md`, `plano-infra-final.md` §"Arquitetura de deploy"

View file

@ -0,0 +1,89 @@
# Impl: Demandas visíveis apenas para responsáveis explícitos + candidato/coordenador
Status: aprovado
Atualizado em: 2026-08-19
Issue: #106
Intenção: docs/plans/demandas-responsaveis.md
Appetite restante: herdado (~1–1,5 dias eng; tracer primeiro, polish depois)
## Leitura da intenção
- **Outcome:** a demanda tem um conjunto de **responsáveis** (staff da campanha); candidato e coordenador sempre a veem; assessor do município relacionado que não é responsável **não vê** — nem lista, nem URL, nem busca. Criador entra automaticamente como responsável. Quem atualiza a demanda gerencia os responsáveis. Leader lockdown intacto; custo/comprovantes seguem a visibilidade da demanda.
- **O que NÃO negociar:** visibilidade **fail-closed** por vínculo explícito (nada de herança do município); regra nos dados (access), não escondida no cliente; leader nunca vê demandas; escalada continua candidato/coordenador.
- **O que reavaliar:** a hipótese da intenção era "`canReadCampaignDemand` passa a ser unrestricted ou responsável". Confirma-se no código: hoje o escopo vive **só** em `src/utilities/access/demands.ts:33` (`resolveActorScopedRead` com `municipality`), e **todos** os consumidores (lista, detalhe, busca global do dashboard, demandas vinculadas da atividade, C90, omnibox) herdam via Local API `overrideAccess: false` — não há REST nem client query. O corte é cirúrgico: regra nova + campo + migration + UI de gestão.
## Abordagem recomendada
```mermaid
flowchart LR
F[CampaignDemand.responsibles<br/>hasMany → campaignUser] --> A[canRead/UpdateCampaignDemand<br/>advisor → responsibles contains id]
H[enforceDemandWorkflow<br/>criador vira responsável na criação] --> F
M[Migration: campaign_demand_rels<br/>+campaign_user_id + backfill createdBy] --> F
S[updateCampaignDemandResponsibles<br/>tx + advisory lock] --> A
U[DemandResponsibleMultiSelect<br/>nova + wizard + detalhe] --> S
```
**Opções consideradas:**
- **A — Campo `responsibles` hasMany → `campaignUser` + regra `where` de membership** (espelho de `municipality.advisors` / `{ advisors: { contains } }`).
- **B — Relação polimórfica `responsible` (union campaignUser/leadership/stateDeputy) como a `activity`.**
- **C — `or: [responsibles contains, createdBy equals]`** (criador visível mesmo se removido dos responsáveis).
**Recomendação:** **A** — single collection, sem union desnecessária (responsável é sempre staff da campanha — `eligibleCampaignStaffWhere`), query escalar `{ responsibles: { contains: id } }` (mesmo mecanismo do `advisors`), e a join table `campaign_demand_rels` **já existe** (hoje usada por `receipts`/media) — a migration só adiciona a coluna `campaign_user_id` + FK + índice.
**Rejeitadas:** **B** porque responsável é sempre `campaignUser` (não há liderança/dobradinha como responsável de demanda — a intenção fala de "pessoas da campanha"/staff; union polimórfica traria o custo do `equals { relationTo, value }` e catalog triplo sem ganho). **C** porque fura o modelo de responsável explícito: remover o criador da lista não o tiraria da visibilidade, e a intenção fixou "criador entra **como responsável**" (vínculo explícito, único source of truth). O criador entra na lista por hook **na criação** + **backfill** dos existentes.
### Decisões de engenharia
1. **Regra de leitura/atualização** — `canReadCampaignDemand` deixa de usar `resolveActorScopedRead` (que é o prólogo municipal) e vira função própria:
`admin → true; leader → false; unrestricted → true; advisor → { responsibles: { contains: id } }; resto → false`. `canUpdateCampaignDemand = canReadCampaignDemand` (regra única "quem vê gerencia" — aceite: "quem pode atualizar pode gerenciar os responsáveis"). `canCreateCampaignDemand` **inalterado** (staff no município da carteira). `canDeleteCampaignDemand` admin-only inalterado. `canReadCampaignStaffField`/`canManageCampaignStaffField` inalterados.
2. **Criador auto-responsável** — no branch `create` do hook `enforceDemandWorkflow` (`CampaignDemand.ts:129-143`), quando `actor` é campaignUser: `data.responsibles = union(data.responsibles, [actor.id])`. Cobre **todos** os caminhos de criação: form `/demandas/nova`, wizard (A5), rascunhos C90 (activity.ts passa `user: currentActor`), admin UI (payload admin → actor null → sem responsáveis → fail-closed: invisível a assessores, o que é aceitável — quem criou via admin vê tudo). Sem auto-add no update.
3. **Backfill dos existentes** — na própria migration (padrão C90 `20260808_184113_remodel_activity_responsible.ts`, `RAISE NOTICE` + SQL no up): `INSERT INTO campaign_demand_rels (order, parent_id, path, campaign_user_id) SELECT 0, id, 'responsibles', created_by_id FROM campaign_demand WHERE created_by_id IS NOT NULL`. Preserva "a criação não some do criador" para as demandas pré-c143. Campo novo → nenhuma linha tem responsável → sem dedup.
4. **Gestão (server)** — `updateCampaignDemandResponsiblesRecord` em `actions/demand.ts`, mesmo shape dos irmãos: schema zod novo (`{ id, responsibles: array de positiveRelationshipId }`), `withPayloadTransaction`, `reloadCampaignActor`, staff check, advisory lock `campaign-demand:{id}`, `findByID` scoped (`overrideAccess: false` — a row access autoriza antes do write), `payload.update` com a lista **completa** (replace). Replace é a forma mais simples e segura: o estado do client já é a lista inteira e o advisory lock serializa editores concorrentes (add/remove atômico seria 2 formas de escrita para o mesmo dado).
5. **Busca de candidatos (UI)** — server action `searchDemandResponsibleOptions(query, municipalityId)` (padrão `searchDemandActivityOptions`): query < 2 chars → sugestões = assessores do município da demanda (read scoped de `municipality.advisors`); senão → busca por nome em `campaignUser` com `eligibleCampaignStaffWhere` (reusa o padrão de `loadEligibleAdvisorOptions`), `overrideAccess: false` (campaign users leem `campaignUser` — `canReadCampaignUsers`). Sugestão NÃO é herança: é atalho de preenchimento, a visibilidade vem só do campo salvo.
6. **Componente de multi-select** — `DemandResponsibleMultiSelect` novo em `src/components/campaign/demand/` (domain, não shared): single collection, chips removíveis, chip do criador rotulado "Você (criador)", sugestões no diálogo quando query curta, hidden inputs repetidos `responsibles` (contrato de `RelationMultiSelect`, não o JSON de `ResponsibleMultiSelect`). **Não reusar `ResponsibleMultiSelect` (C90)**: é polimórfico (typeLabel/relationTo), tem cap hardcoded `MAX_ACTIVITY_RESPONSIBLES=20`, e o contrato de FormData é JSON tipado — forçar modo single-collection seria 4 props novas + branch, um módulo raso; dois componentes com contratos distintos é honesto (mesmo critério que separou `RelationMultiSelect` de `ResponsibleMultiSelect`).
7. **Detalhe** — card `DemandResponsiblesCard` (client) com a lista (nome + avatar via `loadCampaignUserDisplayByIds`, helper existente), add por combobox, remove por chip, e o rótulo estático "Visível para: Responsáveis · Candidato · Coordenador". Layout do rascunho: grade `lg:grid-cols-3` — coluna 1–2 com os blocos atuais (descrição, decisão, workflow, controle interno), coluna 3 com o card. Todos os readers são updaters (update access = read access), então o card de gestão aparece para qualquer staff que abra a demanda — sem flag.
8. **Criação** — `DemandFields` ganha o campo responsáveis (opcional via prop `responsiblesMunicipalityId`/`currentUser`): `/demandas/nova` e wizard compartilham o mesmo parse (`parseCampaignDemandCreateFormData` ganha `responsibles: formData.getAll('responsibles')`), schema de create ganha `responsibles: z.array(positiveRelationshipId)` (sem cap — como `municipality.advisors`; o hook faz o union, dedup implícito). Draft: `/demandas/nova` em grade 2 colunas (form | responsáveis); wizard empilhado (o wizard herda via `DemandFields`).
### Componentes / mudanças
- **`CampaignDemand.responsibles`** (`src/collections/CampaignDemand.ts`): `relationship hasMany → campaignUser`, label "Responsáveis", index; sem field access (row access já gateia; staff nativo não abre `/admin`).
- **`enforceDemandWorkflow`** (`CampaignDemand.ts`): union do criador no create (campaignUser only).
- **`canReadCampaignDemand`/`canUpdateCampaignDemand`** (`src/utilities/access/demands.ts`): regra de membership; remove `resolveActorScopedRead`/`getAccessibleMunicipalityIds` deste módulo.
- **Migration:** `add_campaign_demand_responsibles` (drizzle): `ALTER TABLE campaign_demand_rels ADD COLUMN campaign_user_id` + FK (`ON DELETE cascade`, padrão `state_deputy_rels`) + índice; + backfill no up. Gerar com `pnpm migrate:create`.
- **Access / Consent:** sem Consent (dado interno staff). Fail-closed no where da access.
- **Server actions:** `updateCampaignDemandResponsiblesRecord` + `setDemandResponsiblesFormAction` (`[slug]/formActions.ts`, `revalidatePath`); `searchDemandResponsibleOptions` (`demandas/responsibleSearchActions.ts`).
- **UI:** `DemandResponsibleMultiSelect` + `DemandResponsiblesCard` em `src/components/campaign/demand/`; `DemandFields`/`DemandForm`/`WizardRegisterDemandStep` ganham o campo; `[slug]/page.tsx` ganha o card + grade lg. Impeccable B — seguir o rascunho (`demandas-responsaveis-ui-draft-*.png`): chips, placeholder "Buscar assessor…"/"Adicionar responsável…", textos informativos do rascunho.
- **View-model:** `DemandDetailViewModel.responsibles: Array<{ id, name, avatarUrl }>` via `loadCampaignUserDisplayByIds`; `nova/page.tsx` carrega o `currentUser` (id/nome) para seed do picker.
### Dados → forma
- Sem dados apresentados ao usuário além de nomes/avatares de responsáveis — forma já existente (chips com nome + avatar, padrão do código). Lista da demanda **não** ganha coluna de responsáveis (fora do aceite — apenas visibilidade muda).
## Fases verificáveis
1. **Schema + server (tracer)** — migration + campo + hook do criador + access rule; `pnpm generate:types`; int tests de access reescritos/adicionados (`campaignDemandWorkflow.int.spec.ts`, `homeSearchDemands.int.spec.ts`); backfill verificado no local.
2. **Ações + busca** — `updateCampaignDemandResponsiblesRecord` + zod + `searchDemandResponsibleOptions`; int de gestão (add/remove/self-removal, advisor não-responsável não lê).
3. **UI** — multi-select na criação (nova + wizard) e card no detalhe; shape → critique → polish contra o rascunho; e2e: assessor não-responsável não vê (lista vazia + URL → 404); ajustar e2e existentes se o aceite mudou o comportamento (criador auto-responsável preserva os fluxos atuais).
4. **Gates** — `tsc`, `lint`, `format:check`, `knip`, `check:cycles`, unit+int, `build` local; e2e afetado no CI. Aikido nos arquivos tocados.
## Rabbit holes / Não escopo (engenharia)
- **Notificação ao ser marcado responsável** (fora de escopo da intenção).
- **Coluna de responsáveis na lista / omnibox** — visibilidade herda a access, sem mudança de UI de lista.
- **C141 "Visão Tudo"** — não existe ainda; a regra nova já falha fechado para assessor em qualquer perfil futuro.
- **Generalizar `ResponsibleMultiSelect`/`RelationMultiSelect`** — rejeitado (contratos distintos, módulo raso).
- **Overflow "+N" dos chips no detalhe** — o padrão do código é wrap; o "+2" do rascunho é affordance de wireframe, não spec.
## Riscos e mitigação
- **Regra nova muda o que assessores veem em todos os pontos de demanda** (lista, detalhe, busca home, activity detail, C90). Mitigação: tudo herda por `overrideAccess: false` — um único ponto de corte; int tests cobrem cada superfície; C90 cria com `user: currentActor` → criador auto-responsável → a atividade continua listando as próprias demandas.
- **Self-removal deixa a demanda sem responsável** → visível só a candidato/coordenador (fail-closed, aceitável e coerente com o modelo explícito — gestão é dos responsáveis).
- **Join table já existente** (`campaign_demand_rels` com `path='media'`) — a migration adiciona coluna/FK; conferir o SQL gerado (não regenerar a tabela).
- **`codebaseConventions.unit.spec.ts`** — o guard anti-fragmento municipal não é violado (a regra nova não re-escreve `{ municipality: { in } }`); rodar o guard no gate.
## Aceite de engenharia
- [x] Aceite de produto da intenção ainda coberto (visibilidade fail-closed por responsáveis explícitos; criador automático; gestão por quem atualiza; leader lockdown; escalada inalterada)
- [x] Invariantes AGENTS/engineering-standards (Local API com `user`/`overrideAccess: false`; transação + advisory lock nas escritas multi-step; pt-BR em labels, identificadores em inglês)
- [x] Testes de domínio previstos (int: access rule, gestão de responsáveis, backfill; e2e: invisibilidade para não-responsável; adjust: workflow/homeSearch/listData)

View file

@ -0,0 +1,123 @@
# Impl: Feed do Instagram configurado não aparece na home pública
Status: aprovado
Atualizado em: 2026-08-19
Issue: #115
Intenção: docs/plans/feed-instagram-nao-aparece-home.md
Appetite restante: ~1 dia eng
## Leitura da intenção
- **Outcome:** a assessoria vê, na própria global "Feed de redes sociais", o estado da sincronização do Instagram — "Sincronizado · há X min · N posts", "Falha na última sincronização" com o motivo em linguagem de produto e a correção esperada, ou "Instagram ainda não configurado" — e tem um botão "Tentar sincronizar de novo" para feedback imediato. O board público permanece fail-closed como hoje (artigos + YouTube quando o IG falha); a home pública não muda em nada.
- **O que NÃO negociar:** painel de status por plataforma na própria global, só estado atual + motivo (sem histórico, sem alertas, sem dashboard); sem wizard OAuth; sem embeds no site público; token nunca exposto fora do admin (`read` admin-only preservado); copy em pt; identificadores em inglês; a home pública nunca quebra nem vaza erro.
- **O que reavaliar:** a hipótese da intenção apontava `InstagramPostExclusionPicker` como superfície do status — o picker é a lista de exclusão (S3) e **fica intacto**; o painel de status é um **irmão** (`InstagramSyncStatusPanel`), mesma família de componente `admin.components`, mesmo padrão do rascunho (caixa de status separada da lista). `revalidateRequest.ts` realmente não muda (a tag `social-feed` já cobre). O diagnóstico da ocorrência em produção é impossível deste worktree (sem acesso ao DB de prod — `PROD_DATABASE_URL` não existe localmente): o próprio painel vira o instrumento de diagnóstico (1 clique após o deploy confirma a causa), e o runbook documenta validação/geração de token.
## Abordagem recomendada
```mermaid
flowchart LR
A[Global SocialFeedSettings<br/>+ instagramSyncStatus jsonb] --> H[afterChange<br/>revalidate + sync se credencial mudou]
H --> S[syncInstagramFeed<br/>load + snapshot + status]
B[Botão Tentar sincronizar] --> R[POST /api/social-feed/sync<br/>auth admin + same-origin]
R --> S
C[Render home<br/>getInstagramFeed · unstable_cache] -->|sucesso| P[persist snapshot + status<br/>SQL cru, sem hooks]
C -->|falha| Q[persist status erro +<br/>fallback snapshot]
S --> P
S --> Q
P --> G[(social_feed_settings)]
Q --> G
G --> PANEL[InstagramSyncStatusPanel<br/>ui field · 5 estados]
```
**Opções consideradas:** A | B | C
**Recomendação:** A — estado da sincronização **persistido** numa coluna `jsonb` da própria global (escrito por render, hook e botão, todos pelo mesmo módulo `instagramSync.ts`); painel admin como campo `ui` irmão do picker; rota `POST /api/social-feed/sync` (auth de sessão admin + `isSameOriginRequest`) para o botão; erro da API mapeado para copy de produto (`InstagramApiError` + `describeInstagramError`). O caminho de render ganha apenas a persistência do status dentro do try/catch existente — zero mudança visual na home, fail-closed intacto.
**Rejeitadas:** B) estado derivado em runtime (o painel admin não renderiza a home — sem estado não há como mostrar nada; e a intenção exige o estado da **última** sincronização, que precisa sobreviver a restart) | C) server action no botão (primeiro uso sem precedente no shell do admin do Payload; rota HTTP é previsível, testável e segue o precedente de `/api/revalidate`).
### Decisões de engenharia
1. **Estado persistido como `instagramSyncStatus` (jsonb, campo hidden) — uma coluna, shape `{ lastSyncAt?, postCount?, error?, errorAt? }`.**
Opções: A) uma coluna jsonb (padrão do `instagramFeedSnapshot`) | B) 3 colunas (`lastSyncAt`, `postCount`, `lastError`) | C) estado em memória/cache.
Recomendação: A — o snapshot já é jsonb escrito por SQL cru; uma coluna nova espelha o padrão; o shape é autodescritivo e muda sem migration futura. `lastSyncAt`/`postCount` são da última sincronização **bem-sucedida**; `error`/`errorAt` da última **falha** (falha após sucesso preserva os campos de sucesso? **não** — o painel em falha mostra só o erro, cena 1 do rascunho; overwrite é mais simples e sem leitura-modificação-escrita).
Rejeitadas: B 3 colunas = 3 DDLs para o mesmo dado; C não sobrevive a restart e o painel admin não compartilha o render.
→ **Migration:** `pnpm migrate:create add_instagram_sync_status` (ADD COLUMN jsonb nullable, não destrutiva; revisar o DDL gerado — precedente S2/S3 de drift em migrações hand-written).
2. **`InstagramApiError` (status + apiMessage) no `loadInstagramFeed` + `describeInstagramError` para copy de produto.**
Opções: A) o fetch parseia o body de erro da Graph API (`{ error: { message, type } }`) e lança erro tipado; `describeInstagramError` mapeia para pt-BR com a correção | B) mensagem genérica "falhou com status N" (status quo) | C) mapa estático por status.
Recomendação: A — o produto exige o "porquê" (token recusado vs ID inválido vs rede); o body da Graph API distingue OAuthException (token inválido/expirado/emitido via Facebook Login — copy do draft) de "Invalid user id"; status 5xx = API indisponível; `TypeError` de fetch = rede. O mesmo `describeInstagramError` serve render, hook e rota (uma spelling).
Rejeitadas: B é o bug atual (assessoria cega); C não lê o body e não cobre casos reais.
- Mapeamentos: `400|401|403` + OAuthException/`expired|invalid|revoked` → "O token do Instagram foi recusado pela Graph API — está inválido/expirado ou foi emitido via Facebook Login (a Graph API só aceita tokens gerados pelo Instagram Login). Gere um novo token de longa duração pelo Instagram Login e atualize o campo acima."; `400` + menção a user/id → "O ID do usuário não foi reconhecido pela Graph API. Confira se é o ID numérico da conta Business/Creator."; `5xx`/outros → "A Graph API está indisponível no momento (status N). Tente novamente em alguns minutos."; `TypeError` de fetch → "Não foi possível falar com a API do Instagram (rede indisponível)."; desconhecido → "Erro inesperado ao sincronizar o Instagram."
3. **Rota `POST /api/social-feed/sync` para o botão — auth de sessão admin + CSRF same-origin.**
Opções: A) route handler em `(frontend)/api/social-feed/sync` com `payload.auth({ headers })` exigindo `collection === 'users'` + `isSameOriginRequest` (helper já existente) | B) server action | C) rota sem auth (confia no botão).
Recomendação: A — o precedente `sameOriginRequest.ts` é literalmente para "cookie-authenticated JSON route handlers"; `payload.auth` lê o cookie `payload-token` da sessão admin (precedente `campaignAuth`); a rota vive ao lado de `/api/revalidate` (grupo `(frontend)` — route handlers não passam por layouts; o URL é `/api/social-feed/sync`). Retorna `{ ok, status }`; status `null`/400 quando IG não configurado (o botão nem renderiza nesse estado — defesa).
Rejeitadas: B sem precedente no shell admin (risco de incompatibilidade com o RSC do Payload, custo de spike); C permite abuso do rate da Graph API.
→ Depois do sync, a rota chama `revalidateTag('social-feed')` (fora de cache — permitido).
4. **Sync no `afterChange` da global quando a credencial IG muda (awaited, bounded, à prova de erro).**
Opções: A) hook `afterChange` [revalidateFeed, syncInstagramAfterChange] — sync **awaited** quando `doc` ≠ `previousDoc` em `instagramAccessToken`/`instagramUserId`/`instagramEnabled` (false→true); `AbortSignal.timeout(10_000)` no fetch; try/catch total — erro de sync nunca quebra o save | B) sync em todo save | C) sem hook — só botão e render.
Recomendação: A — o fluxo da intenção é "preenche token + ID e salva → o admin mostra o estado": com o sync awaited, o reload da global após o salvar já mostra o resultado **com as credenciais novas** (o estado antigo mostraria a falha da credencial antiga — confuso e o problema do status quo). Gate por mudança: save de exclusões/YouTube não gasta chamada de API nem adiciona latência. Timeout: Graph API responde em <1s mesmo em erro; 10s cap em API pendurada.
Rejeitadas: B latência e custo em todo save sem ganho; C deixa o fluxo principal da intenção em dois cliques e o estado inicialmente mentiroso.
→ O hook usa o MESMO `syncInstagramFeed` da rota (uma implementação, dois call sites).
5. **Painel `InstagramSyncStatusPanel` (campo `ui`, irmão do picker) — 5 estados.**
Opções: A) componente client próprio lendo os campos do form via `useAllFormFields` + estado local pós-botão | B) estender o picker | C) campo `json` não-hidden renderizado cru.
Recomendação: A — o picker prova o padrão (campo `ui` + componente client + `useAllFormFields`); o painel lê `instagramSyncStatus` (inicial), `instagramAccessToken`/`instagramUserId`/`instagramEnabled` e mantém estado local só para o pós-clique (o status já está persistido no servidor — o form não precisa ser modificado). Estados: (1) `instagramEnabled === false` → "Instagram desativado — os posts não aparecem no board."; (2) sem token ou sem ID → "Instagram ainda não configurado" + explicação (cena 3); (3) `error` → "Falha na última sincronização" + mensagem + botão "Tentar sincronizar de novo" (cena 1); (4) `lastSyncAt` → "Sincronizado · há X min · N posts" + "Próxima atualização automática em ~5 min. Os N posts mais recentes estão no board 'Acompanhe de perto' da home." (cena 2, sem botão — fiel ao rascunho); (5) configurado sem status → "Aguardando a primeira sincronização" + botão. Pós-clique: `pending` ("Sincronizando…") → sucesso cena 2 / falha cena 1 com o motivo retornado. Tempo relativo com `Intl.RelativeTimeFormat('pt-BR')` local (o painel é client; `formatRelativePostDate` é server-only — `posts.ts` abre com `import 'server-only'`).
Rejeitadas: B mistura duas responsabilidades no picker (o picker é lista de exclusão; o status é estado operacional — o rascunho os desenha como caixas separadas); C expõe JSON cru (anti-produto).
→ **UI:** Impeccable C — encaixe na linguagem do admin (caixas zinc/red do draft, `text-sm`, bordas `rounded-lg`), shape → craft → critique → polish no navegador (admin, 3 cenas + estados).
6. **`loadInstagramFeed` ganha `signal?: AbortSignal` (repasse ao `fetchImpl`).**
O hook precisa do deadline; o `LoadInstagramFeedArgs` aceita `init` no fetchImpl — o signal flui sem mudar a assinatura do fetch. Unit-testado.
7. **Stub IG ganha estado `invalid-token` (400 com body `{ error: { type: 'OAuthException', ... } }`).**
A cena 1 do draft é literalmente esse erro; o e2e do painel precisa exercitá-lo (o estado `fail` atual responde 500 genérico). O painel de falha com a copy do token é o aceite central do item.
### Componentes / mudanças
- **`InstagramApiError` + `parseInstagramErrorBody`** (`src/utilities/socialFeed/instagramFeed.ts`, estender): parseia body de erro em resposta não-2xx, lança `InstagramApiError` (`status` + `apiMessage`); `loadInstagramFeed` aceita `signal` opcional repassado ao `fetchImpl` (fetch do usuário, media e refresh).
- **`src/utilities/socialFeed/instagramSync.ts`** (novo, `server-only`, dentro do subfolder `socialFeed/` — a pin de top-level da `codebaseConventions.unit.spec.ts` não se aplica a subfolders, o comentário já cobre o domínio):
- `InstagramSyncStatus` (`{ lastSyncAt?: string; postCount?: number; error?: string; errorAt?: string }`).
- `describeInstagramError(cause: unknown): string` — mapeamentos da Decisão 2 (puro, exportado p/ unit).
- `successInstagramSyncStatus(postCount)` / `failedInstagramSyncStatus(error)` — builders puros.
- `persistInstagramSyncStatus(payload, status)` — SQL cru UPDATE+INSERT (mesmo padrão/precondição do snapshot; rodado DENTRO de `unstable_cache` no render — sem hooks).
- `syncInstagramFeed(payload, { signal? }): Promise<{ ok: true; status } | { ok: false; status }>` — lê a global, retorna "não configurado" sem tocar na API quando `enabled === false`/`instagramEnabled === false`/sem token/ID; sucesso → `persistInstagramSnapshot` + persist token renovado (reuso dos privados? **não** — os `persist*` atuais são module-private do `instagramFeed.ts`; exportar `persistInstagramSnapshot`/`persistInstagramAccessToken` do `instagramFeed.ts` para o sync module — o dono do persist é o feed, o sync é o orquestrador) + status de sucesso; falha → status de erro via `describeInstagramError`; nunca lança (status é o resultado).
- **`getInstagramFeed`** (`instagramFeed.ts`, estender): no try (sucesso) persiste também o status de sucesso; no catch persiste o status de erro ANTES do fallback snapshot (a ordem não importa para o board; importa para o painel). Nada mais muda no caminho público.
- **`SocialFeedSettings`** (`src/globals/SocialFeedSettings.ts`, estender): campo `instagramSyncStatus` (json, `admin.hidden: true` — o painel renderiza, o dado não aparece cru); campo `ui` `instagramSyncStatusPanel` (após `instagramEnabled`, antes do token — status no topo do bloco IG, fiel ao rascunho; `admin.components.Field` → painel); `afterChange` = `[revalidateFeed, syncInstagramAfterChange]` (gate da Decisão 4, try/catch total). Copy do campo token/`admin.description`: ajuste de 1 linha para apontar o painel de status ("veja o estado da sincronização abaixo/ao lado") — opcional, não bloquear.
- **`src/app/(frontend)/api/social-feed/sync/route.ts`** (novo): `POST`; `isSameOriginRequest` (401 se cross-origin); `payload.auth({ headers })` exigindo `user.collection === 'users'` (401 sem sessão admin); lê a global, IG desconfigurado → `400 { ok: false, error }`; `syncInstagramFeed` com `AbortSignal.timeout(10_000)`; `revalidateTag(REVALIDATE_SOCIAL_FEED_TAG)`; `200 { ok, status }`. Erro interno → `500` (sem stack).
- **`src/components/admin/InstagramSyncStatusPanel.tsx`** (novo, `'use client'`): estados da Decisão 5; fetch para a rota no clique; `aria-busy` no pending; sem escrita no form (status é persistido no servidor).
- **`tests/e2e/instagram-stub.mjs`** (estender): estado `'invalid-token'` → `400 { error: { type: 'OAuthException', message: 'Error validating access token' } }` em `/user` e `/media` (e refresh → 400); `POST /__stub/state` aceita o novo estado.
- **Migration:** `add_instagram_sync_status` (jsonb nullable). **Types/importmap:** `pnpm generate:types` + `pnpm generate:importmap` (com envs `S3_*` dummy setadas — OPS69). **Access/Consent:** sem consent (estado operacional interno, não PII); global segue admin-only; a rota exige sessão admin. **UI:** Impeccable C (encaixe no admin; shape → craft → critique → polish).
### Dados → forma
- O painel mostra **estado operacional**, não métrica: contagem de posts sincronizados (cru, da API), tempo desde a última boa, motivo da última falha. Nada de histórico, alertas ou engajamento (corte da intenção). Forma: caixas do rascunho (verde/zinc neutro sucesso, red falha, zinc neutro não-configurado) — contraste e semântica visuais primeiro, sem cor inventada.
## Fases verificáveis
1. **Schema + servidor** (~metade do appetite): migration + `generate:types`; `InstagramApiError` + parse do body + `signal` no `loadInstagramFeed`; `instagramSync.ts` (status builders + `describeInstagramError` + persist SQL + `syncInstagramFeed`); persistência de status no `getInstagramFeed`; rota `/api/social-feed/sync`. Unit tests: `describeInstagramError` (OAuthException→token, invalid-user→ID, 5xx→indisponível, TypeError→rede, unknown→inesperado), `loadInstagramFeed` com fetchImpl 400+body de erro lançando `InstagramApiError`, repasse de `signal`, builders de status, gate `instagramCredentialsChanged` (doc/previousDoc: token mudou, ID mudou, enabled false→true, exclusão não dispara). Gates parciais: tsc + `pnpm test`.
2. **Painel + hook + runbook** (restante): `InstagramSyncStatusPanel` + campo `ui` + hook `afterChange` com sync awaited; stub `invalid-token`; runbook `docs/ops/instagram-feed-token-runbook.md` (o que o painel mostra; validação do token via refresh/me com curl; geração pelo Instagram Login vs Facebook Login; a provável causa da ocorrência atual e como o painel confirma em 1 clique pós-deploy); alinhar a copy do vazio do picker com o rascunho ("Nenhum post sincronizado. Após uma sincronização bem-sucedida, os últimos posts aparecem aqui…"); `generate:importmap` + AGENTS.md (1 linha: painel de status + rota de sync na seção do `social-feed`). Polish no navegador (admin, 3 cenas).
3. **e2e + gates finais**: admin smoke — seed com token+ID → painel "Aguardando a primeira sincronização" → clique → "Sincronizado" com contagem + assert do `instagramSyncStatus` via REST; stub `invalid-token` → falha com copy do token + botão; stub `fail` → falha 500; reset do helper `resetSocialFeedSettings` ganha `instagramSyncStatus: null` (determinismo do serial); smoke do picker existente deve continuar verde (o hook passa a sincronizar no seed com credenciais — stub ok, determinístico). Gates: `pnpm gate:fast`, `pnpm test`, `pnpm build` local, e2e no CI; changelog `docs/changelog/2026-08-19-s11.md` + `pnpm changelog:build`.
## Rabbit holes / Não escopo (engenharia)
- Não sincronizar no salvar de exclusões/YouTube (gate por credencial — Decisão 4).
- Não fazer histórico/alerta/dashboard de saúde; não tocar no board público nem nos cards.
- Não mexer no fluxo de refresh de token existente (o `loadInstagramFeed` já refresca e persiste; o sync só reporta).
- Não paginar o feed, não buscar stories/métricas.
- Não backfill de status retroativo (a primeira sincronização após o deploy escreve o estado).
- Não acesso ao DB de prod para o diagnóstico (fora do alcance do worktree); o painel + runbook cobrem o aceite.
- Não mudar a pin de `codebaseConventions` (o módulo novo fica no subfolder `socialFeed/` já documentado).
## Riscos e mitigação
- **Rota auth no admin:** `payload.auth({ headers })` com cookie `payload-token` — precedente `campaignAuth`; sem sessão → 401 silencioso (o painel mostra o erro como falha de sincronização? **não** — o painel trata 401 como "sessão expirada": recarrega a página para o admin re-autenticar; copy honesta). e2e cobre.
- **Hook sync com latência no save:** cap `AbortSignal.timeout(10_000)`; sync nunca lança (try/catch); save de credenciais é raro (assessoria configura uma vez).
- **`revalidateTag` dentro de cache:** a persistência de status no render usa SQL cru (sem hooks) — mesma restrição documentada do snapshot (Next 15.4 lança `revalidateTag` dentro de `unstable_cache`); a rota/hook revalidam fora do cache (permitido).
- **e2e serial com hook novo:** todo seed de credenciais via REST dispara o sync (stub determinístico, ok/fail por estado); resets incluem `instagramSyncStatus: null`; orçamento de polling do serial mantido.
- **Formato do body de erro da Graph API:** documentado (`{ error: { message, type } }`); parse defensivo — body inesperado → mensagem genérica por status (nunca quebra o fluxo).
- **Painel com estado stale após save:** o sync awaited no hook resolve (status escrito antes do reload); botão cobre o resto; "há X min" é honesto (última boa real).
## Aceite de engenharia
- [ ] Aceite de produto da intenção coberto: painel de status na global (sincronizado/falha com motivo+não configurado), botão "Tentar sincronizar de novo", home pública inalterada e fail-closed, runbook curto com validação/geração de token, token nunca fora do admin
- [ ] Invariantes AGENTS/engineering-standards: migration não destrutiva; sem consent novo; identificadores em inglês; copy/admin em pt; access admin-only preservado; rota com auth de sessão + CSRF same-origin; tag `social-feed` já cobre revalidação; SQL cru fora de cache
- [ ] Testes de domínio previstos: unit (mapeamentos de erro, `InstagramApiError`, signal, builders, gate do hook) + e2e (painel ok/falha token/falha 500, status persistido via REST, serial determinístico)

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

View file

@ -0,0 +1,315 @@
<!doctype html>
<html lang="pt-BR">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
<title>UI draft — Feed do Instagram configurado não aparece na home</title>
</head>
<body class="bg-zinc-950 text-zinc-100 antialiased">
<main class="mx-auto max-w-6xl p-8">
<p class="text-sm text-zinc-400">
Assessoria de comunicação configura o feed do Instagram no admin (global "Feed de redes
sociais") e precisa ver, no mesmo lugar, por que os posts não aparecem na home pública.
</p>
<!-- Cena: falha na sincronização (o caso real em produção) -->
<section data-shot="admin-falha-token" class="mt-10">
<p class="mb-3 text-sm font-medium text-zinc-300">Cena 1 — token recusado pela Graph API</p>
<div class="rounded-xl border border-zinc-700 bg-zinc-100 text-zinc-900">
<div class="flex items-center justify-between border-b border-zinc-300 px-6 py-4">
<div class="flex items-center gap-2">
<span class="rounded bg-zinc-900 px-2 py-0.5 text-xs font-semibold text-zinc-100">
Configurações
</span>
<span class="text-sm text-zinc-600">/ Feed de redes sociais</span>
</div>
<span class="rounded border border-zinc-400 bg-white px-3 py-1 text-xs text-zinc-600">
Draft · Salvar · Cancelar
</span>
</div>
<div class="px-6 py-5">
<h1 class="text-lg font-bold">Feed de redes sociais</h1>
<p class="mt-1 max-w-2xl text-sm text-zinc-600">
Fonte automática do board "Acompanhe de perto" da home de campanha. Sem chave
configurada, os cards da plataforma não aparecem.
</p>
<div class="mt-5 rounded-lg border border-red-300 bg-red-50 p-4">
<p class="text-sm font-bold text-red-800">Falha na última sincronização</p>
<p class="mt-1 max-w-2xl text-sm text-red-700">
A Graph API recusou o token configurado (token inválido/expirado ou emitido via
Facebook Login — a Graph API só aceita tokens gerados pelo Instagram Login).
Enquanto isso, nenhum post do Instagram aparece na home.
</p>
<p class="mt-1 max-w-2xl text-sm text-red-700">
<strong>Correção:</strong> gere um token de longa duração pelo Instagram Login
(conta Business/Creator vinculada à página) e atualize o campo abaixo.
</p>
<button
class="mt-3 rounded-md bg-red-600 px-3 py-1.5 text-sm font-semibold text-white"
>
Tentar sincronizar de novo
</button>
</div>
<div class="mt-6 grid max-w-2xl grid-cols-1 gap-4">
<div>
<label class="text-xs font-semibold text-zinc-700"
>Token de acesso (Instagram Graph API)</label
>
<div
class="mt-1 rounded-md border border-zinc-400 bg-white px-3 py-2 text-sm text-zinc-700"
>
IGAA...abc123
</div>
</div>
<div>
<label class="text-xs font-semibold text-zinc-700"
>ID do usuário (conta Business/Creator)</label
>
<div
class="mt-1 rounded-md border border-zinc-400 bg-white px-3 py-2 text-sm text-zinc-700"
>
17841400000000000
</div>
</div>
<div>
<label class="text-xs font-semibold text-zinc-700">Máximo de posts</label>
<div
class="mt-1 w-24 rounded-md border border-zinc-400 bg-white px-3 py-2 text-sm text-zinc-700"
>
3
</div>
</div>
</div>
<div class="mt-6 max-w-2xl rounded-lg border border-zinc-300 bg-white p-4">
<p class="text-sm font-bold text-zinc-800">Instagram — posts recentes</p>
<p class="mt-1 text-sm text-zinc-500">
Nenhum post sincronizado. Após uma sincronização bem-sucedida, os últimos posts
aparecem aqui para marcar os que não devem ir para a home.
</p>
</div>
<div class="mt-6 flex items-center justify-end gap-2 border-t border-zinc-300 pt-4">
<button
class="rounded-md border border-zinc-400 bg-white px-4 py-1.5 text-sm font-semibold text-zinc-700"
>
Cancelar
</button>
<button class="rounded-md bg-red-600 px-4 py-1.5 text-sm font-semibold text-white">
Salvar
</button>
</div>
</div>
</div>
</section>
<!-- Cena: sincronizado, picker com posts -->
<section data-shot="admin-ok" class="mt-12">
<p class="mb-3 text-sm font-medium text-zinc-300">Cena 2 — sincronizado com sucesso</p>
<div class="rounded-xl border border-zinc-700 bg-zinc-100 text-zinc-900">
<div class="flex items-center justify-between border-b border-zinc-300 px-6 py-4">
<div class="flex items-center gap-2">
<span class="rounded bg-zinc-900 px-2 py-0.5 text-xs font-semibold text-zinc-100">
Configurações
</span>
<span class="text-sm text-zinc-600">/ Feed de redes sociais</span>
</div>
<span class="rounded border border-zinc-400 bg-white px-3 py-1 text-xs text-zinc-600">
Draft · Salvar · Cancelar
</span>
</div>
<div class="px-6 py-5">
<h1 class="text-lg font-bold">Feed de redes sociais</h1>
<div class="mt-5 rounded-lg border border-zinc-300 bg-white p-4">
<p class="text-sm font-bold text-zinc-800">Sincronizado · há 2 min · 4 posts</p>
<p class="mt-1 max-w-2xl text-sm text-zinc-500">
Próxima atualização automática em ~5 min. Os 4 posts mais recentes estão no board
"Acompanhe de perto" da home.
</p>
</div>
<div class="mt-6 grid max-w-2xl grid-cols-1 gap-4">
<div>
<label class="text-xs font-semibold text-zinc-700"
>Token de acesso (Instagram Graph API)</label
>
<div
class="mt-1 rounded-md border border-zinc-400 bg-white px-3 py-2 text-sm text-zinc-700"
>
IGAA...abc123
</div>
</div>
<div>
<label class="text-xs font-semibold text-zinc-700"
>ID do usuário (conta Business/Creator)</label
>
<div
class="mt-1 rounded-md border border-zinc-400 bg-white px-3 py-2 text-sm text-zinc-700"
>
17841400000000000
</div>
</div>
<div>
<label class="text-xs font-semibold text-zinc-700">Máximo de posts</label>
<div
class="mt-1 w-24 rounded-md border border-zinc-400 bg-white px-3 py-2 text-sm text-zinc-700"
>
3
</div>
</div>
</div>
<div class="mt-6 max-w-2xl rounded-lg border border-zinc-300 bg-white p-4">
<p class="text-sm font-bold text-zinc-800">Instagram — posts recentes</p>
<p class="mt-1 text-sm text-zinc-500">
Marque "Não exibir" para tirar um post do board (ex.: posts de grade). O board pula
para o próximo elegível.
</p>
<ul class="mt-3 divide-y divide-zinc-200">
<li class="flex items-center gap-3 py-2.5">
<span class="h-10 w-10 shrink-0 rounded bg-zinc-300"></span>
<span class="min-w-0 flex-1">
<span class="block truncate text-sm font-medium text-zinc-800">
Caravana pelo interior: chegada em Vitória da Conquista
</span>
<span class="block text-xs text-zinc-500"
>há 2 h · ID 17841400000000000_001</span
>
</span>
<span class="rounded bg-zinc-200 px-2 py-1 text-xs font-semibold text-zinc-700">
Exibir
</span>
</li>
<li class="flex items-center gap-3 py-2.5">
<span class="h-10 w-10 shrink-0 rounded bg-zinc-300"></span>
<span class="min-w-0 flex-1">
<span class="block truncate text-sm font-medium text-zinc-800">
Foto do plenário: votação da pauta da saúde
</span>
<span class="block text-xs text-zinc-500"
>ontem · ID 17841400000000000_002</span
>
</span>
<span class="rounded bg-zinc-200 px-2 py-1 text-xs font-semibold text-zinc-700">
Exibir
</span>
</li>
<li class="flex items-center gap-3 py-2.5">
<span class="h-10 w-10 shrink-0 rounded bg-zinc-300"></span>
<span class="min-w-0 flex-1">
<span class="block truncate text-sm font-medium text-zinc-800">
Post de grade (sem legenda) — para marcar "Não exibir"
</span>
<span class="block text-xs text-zinc-500"
>ontem · ID 17841400000000000_003</span
>
</span>
<span class="rounded bg-red-100 px-2 py-1 text-xs font-semibold text-red-700">
Não exibir
</span>
</li>
<li class="flex items-center gap-3 py-2.5">
<span class="h-10 w-10 shrink-0 rounded bg-zinc-300"></span>
<span class="min-w-0 flex-1">
<span class="block truncate text-sm font-medium text-zinc-800">
Agenda da semana: agendas abertas em Salvador
</span>
<span class="block text-xs text-zinc-500"
>há 2 dias · ID 17841400000000000_004</span
>
</span>
<span class="rounded bg-zinc-200 px-2 py-1 text-xs font-semibold text-zinc-700">
Exibir
</span>
</li>
</ul>
</div>
<div class="mt-6 flex items-center justify-end gap-2 border-t border-zinc-300 pt-4">
<button
class="rounded-md border border-zinc-400 bg-white px-4 py-1.5 text-sm font-semibold text-zinc-700"
>
Cancelar
</button>
<button class="rounded-md bg-red-600 px-4 py-1.5 text-sm font-semibold text-white">
Salvar
</button>
</div>
</div>
</div>
</section>
<!-- Cena: ainda não configurado (primeiro acesso) -->
<section data-shot="admin-nao-configurado" class="mt-12">
<p class="mb-3 text-sm font-medium text-zinc-300">
Cena 3 — sem token nem ID (primeiro acesso)
</p>
<div class="rounded-xl border border-zinc-700 bg-zinc-100 text-zinc-900">
<div class="flex items-center justify-between border-b border-zinc-300 px-6 py-4">
<div class="flex items-center gap-2">
<span class="rounded bg-zinc-900 px-2 py-0.5 text-xs font-semibold text-zinc-100">
Configurações
</span>
<span class="text-sm text-zinc-600">/ Feed de redes sociais</span>
</div>
<span class="rounded border border-zinc-400 bg-white px-3 py-1 text-xs text-zinc-600">
Draft · Salvar · Cancelar
</span>
</div>
<div class="px-6 py-5">
<h1 class="text-lg font-bold">Feed de redes sociais</h1>
<div class="mt-5 max-w-2xl rounded-lg border border-zinc-300 bg-white p-4">
<p class="text-sm font-bold text-zinc-800">Instagram ainda não configurado</p>
<p class="mt-1 text-sm text-zinc-500">
Informe o token de acesso e o ID da conta para publicar posts do Instagram na home.
Sem configuração, o board funciona com artigos e YouTube.
</p>
</div>
<div class="mt-6 grid max-w-2xl grid-cols-1 gap-4">
<div>
<label class="text-xs font-semibold text-zinc-700"
>Token de acesso (Instagram Graph API)</label
>
<div
class="mt-1 rounded-md border border-zinc-400 bg-white px-3 py-2 text-sm text-zinc-400"
>
(vazio)
</div>
</div>
<div>
<label class="text-xs font-semibold text-zinc-700"
>ID do usuário (conta Business/Creator)</label
>
<div
class="mt-1 rounded-md border border-zinc-400 bg-white px-3 py-2 text-sm text-zinc-400"
>
(vazio)
</div>
</div>
</div>
<div class="mt-6 flex items-center justify-end gap-2 border-t border-zinc-300 pt-4">
<button
class="rounded-md border border-zinc-400 bg-white px-4 py-1.5 text-sm font-semibold text-zinc-700"
>
Cancelar
</button>
<button class="rounded-md bg-red-600 px-4 py-1.5 text-sm font-semibold text-white">
Salvar
</button>
</div>
</div>
</div>
</section>
</main>
</body>
</html>

View file

@ -0,0 +1,93 @@
# Feed do Instagram configurado não aparece na home pública
Status: aprovado
Atualizado em: 2026-08-19
Issue: #115
Priority: P1
Model: cursor-grok-4.5-medium
Impeccable: C — encaixe na global do admin (painel de status de sincronização)
Rascunho UI: docs/plans/feed-instagram-nao-aparece-home-ui-draft.html + PNGs embutidos abaixo
Appetite: ~1 dia eng + diagnóstico da causa em produção
Responsável: —
## Intenção
A assessoria preencheu o token e o ID do Instagram na configuração do feed (global "Feed de redes sociais", entregue na S3) e os posts **não aparecem** na seção "Acompanhe de perto" da home pública — em produção, o board mostra YouTube e artigos, nenhum card do Instagram e nenhum link "Seguir no Instagram →" (verificado em 2026-08-19 em jorgesolla1313.com.br). O desenho da S3 é fail-closed **silencioso**: qualquer falha da API (token recusado, ID inválido, rede) degrada para "sem cards" sem deixar rastro — o único indício hoje é o picker do admin vazio ("Nenhum post recente"), que não diz **por quê**. A assessoria fica num ciclo cego de tentativa-e-erro de credenciais.
## Persona e fluxo
- **Persona / contexto:** assessoria de comunicação, configurando a global no admin do Payload (grupo "Configurações"), sem acesso ao código nem a logs.
- **Job principal:** saber, no mesmo lugar onde configurou o feed, se a sincronização do Instagram funcionou — e, se não, **por quê** e **o que fazer**.
- **Fluxo desejado:**
1. A assessoria preenche token + ID e salva a global.
2. O admin mostra o estado da sincronização: "Sincronizado · há X min · N posts" (e os posts aparecem no picker, prontos para exclusão), ou "Falha na última sincronização" com o motivo e a correção esperada (ex.: token inválido/expirado ou emitido via Facebook Login — gerar pelo Instagram Login).
3. Com sincronização OK, os posts aparecem na home pública em minutos (cache revalidado no salvar).
4. A causa da ocorrência atual fica diagnosticada e documentada num runbook curto para a assessoria não repetir o ciclo.
- **Anti-goals de produto:** nada de wizard OAuth "conectar com Facebook" dentro do admin; nada de dashboard de saúde de feeds; nada de embeds/scripts do Instagram no site público; nada de métricas de engajamento.
### Esboço de fluxo (B/C/D)
```text
[admin: global Feed de redes sociais]
→ salvar token+ID → [status: Sincronizado · N posts] → [home pública: cards IG em minutos]
→ [status: Falha + motivo + correção] → [assessoria corrige credencial e salva] → [status OK]
```
### Rascunho UI (B/C/D)
Painel de status de sincronização por plataforma na global do admin, embutido junto aos campos do Instagram (e reaproveitável para YouTube depois, se a paridade for pedida).
![Rascunho UI — admin, falha de token](feed-instagram-nao-aparece-home-ui-draft-admin-falha-token.png)
![Rascunho UI — admin, sincronizado com picker](feed-instagram-nao-aparece-home-ui-draft-admin-ok.png)
![Rascunho UI — admin, não configurado](feed-instagram-nao-aparece-home-ui-draft-admin-nao-configurado.png)
## Objetivo e aceite
- Com credenciais válidas (token gerado pelo Instagram Login + ID da conta Business/Creator), os posts do Instagram aparecem na seção "Acompanhe de perto" da home em até alguns minutos após salvar a configuração — badge "Instagram", legenda, data relativa, clique abre na plataforma. Sem ação manual além da config.
- Quando a Graph API recusa as credenciais ou a sincronização falha, o admin mostra o motivo em linguagem de produto e a correção esperada (ex.: "token inválido/expirado ou emitido via Facebook Login — gere pelo Instagram Login"). A assessoria deixa de ficar cega.
- A home pública continua fail-closed: API fora ou credenciais inválidas nunca quebram a página nem vazam erro — o board segue com artigos + YouTube (comportamento atual preservado).
- A causa da ocorrência em produção fica diagnosticada e registrada em runbook curto (passos para gerar/validar o token), para o ciclo de tentativa-e-erro não se repetir.
- Guardrails: token nunca exposto fora do admin (read da global admin-only, como hoje); zero embeds/scripts do Instagram no site público (LCP); identificadores em inglês, copy em pt.
## Dados (intenção)
- **Vou apresentar dados?** Não — o painel mostra estado operacional (última sincronização, nº de posts, motivo de erro), não métricas. Nenhuma decisão de análise desbloqueada.
- **Forma:** adiada ao plano de implementação — sem contadores, sem engajamento.
## Direção no codebase (hipótese)
- **Áreas prováveis:** `src/utilities/socialFeed/instagramFeed.ts` (onde o erro é engolido no fail-closed — fonte do "porquê"), `src/globals/SocialFeedSettings.ts` (superfície do status), `src/components/admin/InstagramPostExclusionPicker.tsx` (pickers/superfície admin já existente, mesma família de componente), `src/utilities/revalidateRequest.ts` (tag `social-feed` — o salvar já revalida; não espera-se tocar).
- **Precedente a olhar:** plano/impl da S3 (`docs/plans/secao-conteudos-home-instagram*.md`), irmão `youtubeFeed.ts`, e2e do describe serial da seção (stub IG) e smoke do picker no admin.
- **Risco de acoplamento:** o fetch roda dentro de `unstable_cache` durante render — qualquer persistência de status precisa respeitar a restrição de não chamar `revalidateTag` dentro de cache (precedente documentado da S2/S3). A home pública é ISR; revalidação via tag já cobre o salvar do admin.
## Dependências
- **S3** (pai, `done`/`in-prod` — imutável; este é item **sucessor**).
## Fora de escopo
- Setup OAuth / "conectar com Facebook" dentro do admin (o fluxo Meta fica com a assessoria; runbook documenta).
- Redesenho do board público ou dos cards (superfície aprovada na S3 não muda).
- Paridade de status para o YouTube (pode virar followup se a plataforma pedir; o desenho do painel é por plataforma).
- Backfill retroativo de snapshot.
## Rabbit holes de produto
- **Dashboard de saúde de feeds.** Se alguém "só completar" a ideia de status, vira painel de monitoramento com histórico e alertas. **Corte neste item:** um painel de status por plataforma na própria global, só estado atual + motivo; sem histórico, sem alertas.
- **Wizard OAuth no admin.** Completar o desejo com o fluxo completo da Meta explode o escopo (app Meta, permissões, redirects). **Corte neste item:** credenciais continuam preenchidas manualmente; o valor é diagnóstico e correção, não aquisição de token.
- **Investigação infinita da causa em produção.** A causa pode ser credencial da assessoria (troca fora do nosso controle). **Corte neste item:** diagnóstico com prazo — o que não for descoberto num período curto vira instrução de validação no runbook, não bloqueio do item.
## Questões em aberto (produto)
- **Onde a assessoria vê o "porquê"?** **Opções:** A) painel de status na própria global do feed (ao lado dos campos do Instagram) — o lugar onde a assessoria já está quando configura | B) só logs/exceção no servidor (exige acesso técnico — não resolve para a persona) | C) nada além do picker vazio (status quo). **Recomendação:** A — o fracasso acontece onde a configuração acontece; o sinal pertence a esse mesmo lugar. _(assumido — validar com produto)_
- **O item cobre a ocorrência atual em produção?** **Opções:** A) diagnóstico da causa atual + painel de status (a correção de credencial fica com a assessoria, documentada) | B) só painel de status; a causa atual fica para followup | C) só runbook de credenciais. **Recomendação:** A — sem o diagnóstico, não sabemos se há também um defeito de código por trás do sintoma; sem o painel, o sintoma se repete cegamente. _(assumido — validar com produto)_
- **Paridade de status para YouTube agora?** **Opções:** A) só Instagram nesta fatia (o YouTube funciona em produção; o painel nasce desenhado por plataforma) | B) YouTube e Instagram juntos. **Recomendação:** A — fatia mínima útil; paridade vira followup barato se pedida. _(assumido — validar com produto)_
## Referências
- Issue pai S3 (#20, `done`/`in-prod`) + `docs/plans/secao-conteudos-home-instagram.md` e `-impl.md`
- Rascunho UI (gate): `docs/plans/feed-instagram-nao-aparece-home-ui-draft.html` + PNGs acima
- Primeiras pistas para o executor: `src/utilities/socialFeed/instagramFeed.ts` (caminho de falha silenciosa), `src/globals/SocialFeedSettings.ts`, `src/components/admin/InstagramPostExclusionPicker.tsx`, `tests/e2e/frontend.e2e.spec.ts` (describe serial da seção)
- `docs/AGENT-OPS.md` (serializes: nenhum recurso compartilhado — `serializes: []`)

View file

@ -0,0 +1,130 @@
# Impl: CI/PR do Forgejo Actions de volta para o GitHub Actions — tracker de Issues permanece no Forgejo
Status: aprovado
Atualizado em: 2026-08-19
Issue: #97
Intenção: docs/plans/ops71-ci-github-actions-tracker-forgejo.md
Appetite restante: herdado (~2–3 dias eng; este impl é o item inteiro — OPS72 roda depois com `depends`)
## Leitura da intenção
- **Outcome:** código/PR/CI no GitHub Actions (mesma cascata de verificações,
job único `checks`), tracker de Issues no Forgejo por API, deploy **manual**
(`workflow_dispatch`) com dois jobs (`verify` hosted full → `deploy`
self-hosted no homeserver), rollup `CI (PR) / checks` como required check do
GitHub, workstation livre da suíte.
- **O que NÃO negociar:** tracker (Issues/labels/claims) permanece no Forgejo;
estrutura de job único (OPS62) preservada — nada de matrix; deploy NÃO volta
a ser automático; sem migrar Issues; sem segundo deploy (nada de Vercel);
nunca merge com CI vermelho; `pnpm push` continua sendo o caminho canônico.
- **O que reavaliar:** a intenção sugere "auto-merge nativo do GitHub ou
safety net equivalente" e "`gh workflow run` (ou API GitHub)". Reavaliado:
`gh` **não existe** em nenhuma máquina (OPS50) e a filosofia do repo é
plain-Node stdlib — os helpers novos são scripts Node zero-dep sobre a REST
do GitHub, no mesmo molde de `forgejo-api.mjs`. O auto-merge é o **nativo do
GitHub** (GraphQL `enablePullRequestAutoMerge`, rebase) — o servidor espera o
required check; o OPS64 pin (varrer statuses individuais) era defesa contra o
rollup mentiroso do runner Forgejo, que **não existe** no GitHub (o check-run
`CI (PR) / checks` de um job único é o veredito honesto, e o auto-merge nativo
só mergeia com required checks verdes — garantia do servidor).
## Abordagem recomendada
```mermaid
flowchart LR
W[worktree local] -->|pnpm push -u origin HEAD| GH[github.com/fsolla/teqo]
GH -->|PR Ready base main Closes #N| CI["ci-pr.yml — job único checks (mesma cascata OPS62)"]
CI -->|required check CI (PR) / checks| AM["agent-pr-ready-automerge.yml — auto-merge nativo (GraphQL, rebase)"]
AM -->|merge| MAIN[main no GitHub]
MAIN -->|PR closed merged| FLIP["issue-done / plan-issue-ready — flip no Forgejo via FORGEJO_API_TOKEN"]
MAIN -->|dispatch manual| V[deploy.yml verify hosted full suite]
V -->|needs| D["deploy runs-on self-hosted homeserver — deploy-homeserver.sh local"]
D --> P[(teqo-1313 produção)]
```
**Opções consideradas:**
- **A — origin vira GitHub; Forgejo como remote secundário congelado; TEQO_REPO_URL → GitHub (repo público, sem auth).** `origin/main` é o base dos gates locais (ci-scope/gate-ci) — precisa apontar para o repo que recebe os merges, senão o diff local compara contra um main defasado. O repo GitHub é público (verificado: `private: false`) → o homeserver clona sem credencial.
- **B — auto-merge via GraphQL nativo.** GitHub espera o required check no servidor; nenhum poll; sem semáforo portado. Draft `cursor/*` → Ready via PATCH; draft não-`cursor/*` = veto (OPS57, preservado).
- **C — deploy.yml com `verify` (hosted, suíte full sem skips) → `deploy` (needs, self-hosted homeserver) executando `deploy-homeserver.sh <sha>` localmente (sem SSH).**
- **D — `forgejo-issue-transition.mjs` / `agent-promote-related-on-merge.mjs` ganham `PR_BODY` env** (mecanismo que `agent-promote-related` já usa): o body do PR vem do evento GitHub; o script flipa no Forgejo por API sem ler o PR do Forgejo (PR lá não existe mais).
- **E — `configure-branch-protection.mjs` reescrito para a REST do GitHub** com lib pura `github-branch-protection.mjs` (regra desejada + drift), no mesmo molde de `branch-protection.mjs`; `enforce_admins: true` preserva o invariante "nunca merge com CI vermelho" inclusive para admin (o rule-based do Forgejo valia para todos).
**Recomendação:** A+B+C+D+E — porque cada peça reusa o molde existente (plain Node stdlib, libs puras unit-testadas, drift idempotente) e o aceite da intenção exige exatamente esse desenho; nada disso exige binário novo (`gh`) nem semáforo reimplementado.
**Rejeitadas:**
- **Instalar `gh` e usar `gh pr create/merge/run`** — binário novo em todas as máquinas + workflows, contra a convenção zero-dep do repo; a REST cobre tudo.
- **Poll-and-merge portado (`waitForChecks` do Forgejo) como mecanismo único** — reimplementa o semáforo que a intenção manda cortar; o OPS64 pin era contra o bug do rollup do Forgejo, inexistente no GitHub com job único + auto-merge nativo.
- **Mirror ativo do Forgejo (`pnpm push` para dois remotes / sync pós-merge)** — nada lê o git do Forgejo em runtime (deploy usa TEQO_REPO_URL=GitHub; flips são API); o repo Forgejo congela em main no ponto do cutover (tracker intacto por API, anti-goal "nunca fecha o tracker" preservado).
- **Deletar os arquivos do Forgejo nesta entrega** — decisão do gate (2026-08-19): o sistema GitHub Actions nasce PRIMEIRO e é validado ao vivo (PR real com CI verde → auto-merge → flips → deploy manual); a remoção de `.forgejo/workflows/` + scripts mortos é **entrega sucessora**, só depois de tudo funcionando. O repo Forgejo congelado no main pré-cutover mantém os workflows como rollback (reversível: religar runner + PRs lá); apagar na primeira entrega queimaria a via de volta.
- **Manter `ci-classify-production.mjs` como check informativo no dispatch** — o hosted não alcança o homeserver (sem SSH) e o `verify` roda a suíte full sem skips; a classificação não muda nada → removido só na entrega de remoção.
- **`TEQO_REPO_URL` → Forgejo local com mirror** — dependência extra de sync; o repo GitHub é público, clone direto sem auth.
- **Fork-PR no CI** — repo público: fork PR rodaria a suíte full em minutes hosted de graça; `if:` same-repo no job `checks` (mesma guarda de RCE dos outros workflows).
## Cutover em duas fases (gate 2026-08-19)
O GitHub é **só** o host do Actions; o Forgejo permanece o **tracker principal** (Issues, labels, claims, flips — tudo via API, intacto). A remoção dos arquivos do Forgejo só acontece após validação ao vivo do fluxo GitHub:
1. **Fase 1 (esta entrega — OPS71):** cria todo o sistema GitHub Actions (workflows, scripts, libs, docs). **Nada do Forgejo é apagado** — `.forgejo/workflows/`, `forgejo-pr-automerge.mjs`, `forgejo-dispatch.mjs`, `ci-classify-production.mjs` + specs permanecem no repo (dormentes: o repo Forgejo não recebe mais push).
2. **Cutover manual (humano, nesta ordem — (a)–(c) ANTES do push do OPS71, senão o PR mergearia sem required check):** (a) remotes locais (`origin` → GitHub, `forgejo` secundário); (b) PAT `GITHUB_TOKEN` local + secrets no GitHub (`FORGEJO_API_TOKEN`, `CURSOR_API_KEY`) + `allow_auto_merge: true`; (c) `pnpm configure:branch-protection` (GitHub); (d) **desligar o runner do Forgejo** (reversível — é o que para o schedule do `ci.yml` antigo, que não tem mais sentido e disputaria a workstation; pode ser logo após o primeiro PR GitHub validado); (e) runner self-hosted no homeserver (deploy; não bloqueia PRs — pode ficar para depois); (f) push do OPS71 → PR no GitHub → CI verde → auto-merge → flip da #97 (valida os flips no merge do próprio OPS71) → deploy manual dispatch quando o runner do homeserver estiver pronto.
3. **Fase 2 (entrega sucessora, registrada no fechamento desta Issue):** remove `.forgejo/workflows/*`, scripts mortos e docs residuais do fluxo antigo. Só depois do fluxo GitHub comprovado de ponta a ponta.
### Componentes / mudanças
- **`.github/workflows/ci-pr.yml`** (novo): port do `.forgejo/workflows/ci-pr.yml` — job único `checks`, mesmos steps/guards/services (`postgres-int`/`postgres-build` por nome na rede do job), skips via `ci-scope.mjs` (env `GITHUB_BASE_REF` idêntico). Deltas GitHub: `concurrency: { group: ci-pr-${{ github.head_ref }}, cancel-in-progress: true }` (fail-fast nativo em push novo — o OPS62 não cancelava só porque o Forgejo não tem API; aqui é grátis), `if:` same-repo no job (fork não queima minutes), `permissions: contents: read` (default), e2e selected preservado (OPS72 ajusta a política depois — `depends`).
- **`.github/workflows/deploy.yml`** (novo): `workflow_dispatch` (sem inputs — ref default main). `verify` (ubuntu-latest, timeout 50): `check-test-locations` → lint → format → typecheck → knip → cycles → unit → int (migrate+seed nos services) → build → e2e **full** (4 workers; guardas de contrato de PR ficam de fora — não são verificação de produção). `deploy` (`needs: [verify]`, `runs-on: [self-hosted, homeserver]`, timeout 60, `permissions: contents: read`): checkout → `bash scripts/deploy-homeserver.sh "$GITHUB_SHA"`.
- **`scripts/deploy-homeserver.sh`**: `TEQO_REPO_URL` default → `https://github.com/fsolla/teqo.git`; no workspace existente, `git remote set-url origin "$TEQO_REPO_URL"` idempotente antes do fetch (o clone velho aponta para o Forgejo local); doc de invocação: execução local no runner (não mais pipe via ssh) — o `</dev/null` no `run --rm` do migrate permanece. Fluxo interno intacto (HEAD guard, flock, already-deployed, migrator→migrate→runner→rollout, smoke).
- **`.github/workflows/agent-pr-ready-automerge.yml`** (novo) + **`scripts/github-pr-automerge.mjs`** (novo, plain Node) + **`scripts/lib/github-api.mjs`** (novo, REST+GraphQL zero-dep): lê PR (REST); base != main → skip; draft `cursor/*` → `PATCH draft:false`; draft outro → skip (veto OPS57); senão GraphQL `enablePullRequestAutoMerge(mergeMethod: REBASE)` no `node_id` do PR. Erro do GraphQL → exit 1 (job vermelho, visível). `permissions: { contents: write, pull-requests: write }`.
- **`scripts/github-pr.mjs`** (novo, plain Node): criação de PR para agentes/humano — `GITHUB_TOKEN` env; `--head <branch> --title <t> --body-file <f>` (Ready, base `main` — sem opção draft, regra do repo). Substitui o `forgejo_create_pull_request` do MCP no fluxo de entrega.
- **`scripts/lib/github-branch-protection.mjs`** (novo, puro): `DESIRED_RULE` GitHub (`required_status_checks.checks = [{ context: 'CI (PR) / checks' }]`, strict=false, 0 reviews, `enforce_admins: true`, sem restrictions) + `ruleMatches`/`planBranchProtectionRule` (mesmo contrato do atual).
- **`scripts/configure-branch-protection.mjs`**: reescrito para a REST do GitHub (`PUT /repos/{owner}/{repo}/branches/main/protection`), idempotente (read → plan → apply → verify), `--dry-run` preservado.
- **`scripts/forgejo-issue-transition.mjs`**: aceita `PR_BODY` env (body do PR vem do evento GitHub); sem PR_BODY mantém a leitura via API do Forgejo (fallback — os workflows do Forgejo ainda estão vivos até a Fase 2); comentário do flip atualizado ("deploy manual via dispatch, ver AGENT-OPS").
- **`.github/workflows/issue-done-on-main-merge.yml` / `plan-issue-ready-on-main-merge.yml`** (novos): `pull_request: closed` + `if:` merged && base main && **same-repo** (guarda RCE: checkout de fork + PAT = RCE — preservada); `PR_BODY: ${{ github.event.pull_request.body }}`; `FORGEJO_API_TOKEN` secret; sem `GITHUB_TOKEN` de Issues do GitHub (flip é no Forgejo).
- **`.github/workflows/archive-cursor-agent.yml`** (novo): port direto do `.forgejo/workflows/` (CURSOR_API_KEY).
- **`docs/AGENT-OPS.md`, `.agents/rules/agent-pr-workflow.mdc`, `.agents/skills/work-issue/` (SKILL + execution-pipeline), `.agents/skills/agent-work-issue/SKILL.md`, `.agents/skills/capture-review-debts/SKILL.md` (linha do fechamento), `AGENTS.md`, `docs/ops/teqo-1313-deploy.md`**: fluxo de PR/CI/merge/deploy reescrito para GitHub (canônico); seção de transição nomeando a Fase 2; tabela de secrets (GitHub agora: `FORGEJO_API_TOKEN`, `CURSOR_API_KEY`, local `GITHUB_TOKEN` PAT); passos manuais do cutover na ordem (incl. desligar o runner do Forgejo e por quê — o schedule do `ci.yml` antigo pararia de ter sentido).
- **Preservados nesta entrega (Fase 2 remove):** `.forgejo/workflows/*` (6 arquivos), `scripts/forgejo-pr-automerge.mjs`, `scripts/forgejo-dispatch.mjs`, `scripts/ci-classify-production.mjs`, `scripts/lib/dockerignore.mjs`, `scripts/lib/branch-protection.mjs` + specs (`ciClassifyProduction`, `dockerignoreMatch`, `branchProtection`) — dormentes, documentados como rollback e alvo da entrega de remoção.
- **Testes novos:** `tests/unit/githubApi.unit.spec.ts` (normalização de shapes + endpoints REST/GraphQL, molde do `forgejoApi.unit.spec.ts`), `tests/unit/githubBranchProtection.unit.spec.ts` (regra desejada + drift, molde do `branchProtection.unit.spec.ts`), e pure-functions da política de draft/skip do automerger (`scripts/lib/github-pr-flow.mjs` + spec — o pin OPS57/OPS64 do jeito GitHub).
- **Migration:** sem migration (nenhum schema).
- **Access / Consent:** nenhum.
- **UI:** Impeccable N/A.
## Fases verificáveis
1. **Libs + scripts base** — `github-api.mjs`, `github-pr-flow.mjs` (puro), `github-branch-protection.mjs` (puro), `github-pr.mjs`, `github-pr-automerge.mjs`, `forgejo-issue-transition.mjs` (PR_BODY), `configure-branch-protection.mjs` (GitHub) + specs unit. Gate: `pnpm test:unit`.
2. **Workflows** — `.github/workflows/*` (6). Nada do `.forgejo/workflows/` é tocado nesta fase (Fase 2 do cutover). Gate: `pnpm gate:fast` + `pnpm check:cycles` + knip.
3. **Docs/skills + changelog** — AGENTS.md, AGENT-OPS, agent-pr-workflow.mdc, skills, runbook (com seção de transição), `docs/changelog/2026-08-19-ops71.md` + `pnpm changelog:build`.
4. **Gates finais + entrega** — `pnpm gate:ci` completo (unit+int+build), format, knip, cycles; `pnpm push -u origin HEAD` → PR no GitHub via `node scripts/github-pr.mjs` → CI verde no GitHub → auto-merge → flip da #97. **Passos manuais do cutover listados para o humano** (Fase 1 do cutover: remotes, secrets, branch protection, runner homeserver, shutdown runner Forgejo) — o PR em si não depende deles (CI roda no GitHub com secrets de teste), mas a ordem recomendada é: remotes → secrets → configure → push → PR.
## Fase 2 — entrega sucessora (pós-validação)
Registrada no fechamento desta Issue (capture-review-debts): remove `.forgejo/workflows/*`, `forgejo-pr-automerge.mjs`, `forgejo-dispatch.mjs`, `ci-classify-production.mjs`, `dockerignore.mjs` (+ specs órfãs) e qualquer doc residual do fluxo Forgejo. Critério de entrada: um ciclo completo validado no GitHub (PR → CI → auto-merge → flip → deploy manual).
## Rabbit holes / Não escopo (engenharia)
- Portar `waitForChecks`/statuses para o GitHub — não há; auto-merge nativo.
- Re-engenharia do `deploy-homeserver.sh` (fluxo docker/compose/migrate) — só a origem do clone e o modo de invocação mudam.
- Sincronizar o repo Forgejo com main — congelado (decidido, anti-goal do tracker preservado).
- Migrar Issues/labels/claims — anti-goal explícito.
- OPS72 (e2e local afetado) — item sucessor com `depends: [OPS71]`.
- Pool/`agent-pool*` — dormente (OPS65); o texto `gh pr` em `agent-pool-prompt.mjs` é de feature morta — deixo como está (fora do serializes).
- Secrets de GitHub (`FORGEJO_API_TOKEN`, `CURSOR_API_KEY`, `GITHUB_TOKEN` PAT local) — passo manual documentado, não código.
## Riscos e mitigação
- **Nome do check-run `CI (PR) / checks`:** o workflow `name: CI (PR)` + job `checks` geram exatamente esse check-run no GitHub; o required check usa o mesmo literal — pin no spec da branch protection e verificação ao vivo no primeiro PR (se o nome divergir, o configure aponta o drift e o PR não mergea — fail-closed, nunca merge sem verificação).
- **Auto-merge GraphQL com `GITHUB_TOKEN`:** permissões declaradas (`contents`+`pull-requests: write`) cobrem PATCH draft e a mutation; se o GraphQL recusar (ex.: auto-merge desabilitado no repo), o job fica vermelho com a mensagem — mitigação: passo manual `allow_auto_merge: true` no repo (Settings ou API) listado no cutover.
- **PR com conflito:** auto-merge fica armado e o PR espera — igual ao hoje (waitForChecks lançava "não mergeável"); nenhum merge errado é possível (servidor).
- **Repo público → fork PR:** `if:` same-repo no job `checks` + setting de fork workflows no repo (passo manual) — fork não queima minutes nem acessa secrets.
- **Runner do homeserver ainda não instalado:** o `deploy` fica indisponível até o passo manual; PRs seguem mergeando (CI hosted não depende do runner). O PR deste item não publica nada sozinho — o deploy é dispatch manual.
- **Schedule do `ci.yml` antigo rodando na workstation durante a transição:** com o repo Forgejo congelado, o gate do Forgejo lerá um deployed SHA inexistente no clone → fail-open vermelho → suíte full a cada 30 min na workstation. Mitigado: o cutover desliga o runner do Forgejo logo após o merge (passo (d), reversível) — antes do primeiro deploy via GitHub.
- **`gate:ci` local vs `origin/main`:** o remote muda para GitHub; sem o passo manual dos remotes, o gate local compara contra o Forgejo defasado → risco de diff falso. Mitigado: o passo de remotes é o PRIMEIRO do cutover e o runbook avisa; o `*-impl.md` documenta o comando exato.
- **TEQO_REPO_URL no homeserver:** clone velho com origin Forgejo → `set-url` idempotente no script cobre; repo público → sem credencial.
- **Knip com os scripts do Forgejo preservados:** nada órfão é criado nesta entrega (todos os scripts seguem referenciados pelos YAMLs do `.forgejo/workflows/`, que permanecem) — sem risco de knip flagrar a deleção adiada.
## Aceite de engenharia
- [x] Aceite de produto da intenção ainda coberto (CI/PR GitHub, tracker Forgejo, deploy manual verify→self-hosted, rollup required check)
- [x] Cutover em duas fases (gate 2026-08-19): sistema GitHub criado e validado primeiro; arquivos do Forgejo preservados nesta entrega, remoção em entrega sucessora pós-validação
- [x] Invariantes AGENTS/engineering-standards (sem migration, sem schema; plain Node stdlib nos helpers de CI; zero-dep nos workflows)
- [x] Testes de domínio previstos: github-api (normalização/endpoints), github-branch-protection (drift), github-pr-flow (draft policy/skips — pin OPS57/OPS64 no jeito GitHub)

View file

@ -0,0 +1,83 @@
# Impl: OPS71 Fase 2 — remover arquivos do Forgejo (workflows/scripts) após validação do GitHub Actions
Status: aprovado
Atualizado em: 2026-08-19
Issue: #111
Intenção: docs/plans/ops71-ci-github-actions-tracker-forgejo.md (§"Fase 2 — entrega sucessora", linha 99-101 do impl da Fase 1)
Appetite restante: pequeno — remoção + fixups de consumidores + docs (1 sessão)
## Leitura da intenção
- **Outcome:** o fluxo GitHub Actions (OPS71 Fase 1, validado ao vivo: PRs #742/#743 — CI verde, auto-merge nativo, flips via API) é a única via de CI/PR/deploy no repo; os arquivos do fluxo Forgejo saem do tree; docs residuais atualizados; nada do tracker é tocado.
- **O que NÃO negociar:** tracker no Forgejo (Issues/labels/claims/flips via API) — `forgejo-api.mjs`, `agent-*.mjs`, `issue.mjs`, `forgejo-issue-transition.mjs`, `agent-promote-related-on-merge.mjs` **ficam**; `.forgejo/worktree.env` fica (fora do escopo `workflows/*`, consumido pelo provisionamento de worktree); desligar o runner do Forgejo é passo manual do runbook (fora do código); `ci.yml` de main não volta (deploy = dispatch manual).
- **O que reavaliar:** a hipótese da intenção dizia "grep `forgejo-pr-automerge|forgejo-dispatch|ci-classify|dockerignore` como saída" — a varredura achou **dois consumidores funcionais** além de docs, não listados no escopo literal: `scripts/lib/test-affected-core.mjs` (HIGH_RISK_EXACT com os 2 paths mortos) e `tests/unit/ciSkipInvariants.unit.spec.ts` (lê `.forgejo/workflows/ci.yml`+`ci-pr.yml` e pina o gate OPS65). Ambos **precisam ser editados** — são o contrato que quebraria se os arquivos sumissem. Também achado: `README.md` ainda descreve o fluxo Forgejo pré-Fase 1 (janela de 30 min, deploy automático, linha de CI apontando `.forgejo/workflows/`).
## Abordagem recomendada
```mermaid
flowchart LR
D[14 arquivos deletados] --> C1[test-affected-core: -2 entries HIGH_RISK]
D --> C2[ciSkipInvariants: -2 paths .next-e2e, -2 high-risk, teste OPS65 reescrito para deploy.yml dispatch-only]
D --> C3[gate-ci: comentário .github/workflows/ci-pr.yml]
D --> C4[github-pr-automerge + agent-pr-ready-automerge: comentário sem referência morta]
D --> DOC[AGENTS.md + runbook + README — era GitHub só]
DOC --> CL[changelog ops71-fase2 + changelog:build]
```
**Opções consideradas:**
- **A — Remoção seca + fixups mínimos dos consumidores** (o escopo do body + os 2 consumidores funcionais + 3 docs). Nada de novo é criado; invariantes de CI re-ancorados no `.github/workflows/`.
- **B — Reescrever `ciSkipInvariants` para pinar "`.forgejo/` não existe"** — redundante: a deleção é o próprio diff do PR; e o teste que importa é o invariante _vivo_ (deploy nunca automático), que re-pino no `deploy.yml`.
- **C — Deletar o teste OPS65 inteiro** (remover o teste da janela de 30 min e pronto) — perderia o pin "nunca deploy automático" sem reposição; o GitHub manteve o espírito (dispatch manual), o teste deve continuar existindo apontando para o arquivo certo.
**Recomendação:** A — porque os consumidores são pequenos e bem delimitados, a substituição do pin OPS65 pelo pin equivalente da era GitHub (`deploy.yml` sem trigger `push`/`schedule`; `ci-pr.yml` sem o gate morto) preserva a invariante de deploy manual, e nada além do escopo declarado é tocado.
**Rejeitadas:**
- **Manter os arquivos como rollback inerte** — o propósito da Issue é a remoção; rollback passa a ser git history (o main congelado do Forgejo é uma cópia íntegra da era Forgejo; o PR é um commit reversível).
- **Deletar `forgejo-issue-transition.mjs`/`agent-promote-related-on-merge.mjs`/`forgejo-api.mjs`/`agent-*.mjs`** — anti-goal explícito (tracker vivo).
- **Deletar `.forgejo/worktree.env`** — fora do escopo (`workflows/*`); consumido pelo provisionamento de worktree.
- **Editar `docs/plans/*`, `docs/changelog/*`, `docs/CHANGELOG-AGENTS.md`** — história congelada/insert-only; a narrativa de "preservados até Fase 2" vive nos changelogs antigos e permanece.
- **Tocar `docs/AGENT-OPS.md`** — já é era-GitHub (grep não acha referência a nenhum arquivo removido).
### Componentes / mudanças
- **Deletados (14):** `.forgejo/workflows/` (6: ci-pr.yml, ci.yml, agent-pr-ready-automerge.yml, issue-done-on-main-merge.yml, plan-issue-ready-on-main-merge.yml, archive-cursor-agent.yml); `scripts/forgejo-pr-automerge.mjs`, `scripts/forgejo-dispatch.mjs`, `scripts/ci-classify-production.mjs`, `scripts/lib/dockerignore.mjs`, `scripts/lib/branch-protection.mjs`; specs `tests/unit/ciClassifyProduction.unit.spec.ts`, `tests/unit/dockerignoreMatch.unit.spec.ts`, `tests/unit/branchProtection.unit.spec.ts`. Nenhuma importação viva aponta para eles (grep: só `test-affected-core` + `ciSkipInvariants` + docs).
- **`scripts/lib/test-affected-core.mjs`**: remove `scripts/ci-classify-production.mjs` e `scripts/lib/dockerignore.mjs` de `HIGH_RISK_EXACT` (paths mortos nunca aparecem em diff; listas devem espelhar o tree).
- **`tests/unit/ciSkipInvariants.unit.spec.ts`**: (1) tira `ci.yml`+`ci-pr.yml` do loop `.next-e2e`; (2) tira os 2 paths do pin de high-risk; (3) reescreve o teste "main CI 30-min window (OPS65)" para a era GitHub: lê `.github/workflows/deploy.yml` e pina `workflow_dispatch:` presente, `push:`/`schedule:` ausentes (deploy nunca automático) e `.github/workflows/ci-pr.yml` sem `ci-classify-production.mjs` (gate morto).
- **`scripts/gate-ci.mjs`** (comentário do header): "Local mirror of `.forgejo/workflows/ci-pr.yml`" → `.github/workflows/ci-pr.yml`.
- **`scripts/github-pr-automerge.mjs` + `.github/workflows/agent-pr-ready-automerge.yml`**: comentários com o parêntese "(forgejo-pr-automerge.mjs)" → referência genérica à era Forgejo (arquivo não existe mais).
- **`AGENTS.md`**: frase "The Forgejo-era files (...) are preserved as rollback until the OPS71 Fase 2 removal" → removidos na Fase 2; rollback via git history (main congelado do Forgejo / revert do PR).
- **`docs/ops/teqo-1313-deploy.md`** (§"Rollback do cutover"): "os workflows de `.forgejo/workflows/` seguem no repo até a Fase 2" → arquivos removidos; restauração via git history do main congelado do Forgejo.
- **`README.md`**: bloco de fluxo stale (janela 30 min, deploy automático, linha `CI: .forgejo/workflows/ci-pr.yml + ci.yml`) → fluxo era-GitHub (PR → `CI (PR) / checks` → auto-merge nativo → deploy manual dispatch).
- **Changelog:** `docs/changelog/2026-08-19-ops71-fase2.md` + `pnpm changelog:build`.
- **Migration:** sem migration (nenhum schema).
- **Access / Consent / UI:** N/A.
## Fases verificáveis
1. **Deleção + fixups de código** — `git rm` dos 14 arquivos; edita `test-affected-core.mjs`, `ciSkipInvariants.unit.spec.ts`, comentários de `gate-ci.mjs`/`github-pr-automerge.mjs`/`agent-pr-ready-automerge.yml`. Gate: `pnpm test:unit` (ciSkipInvariants/testAffected/github\*).
2. **Docs + changelog** — AGENTS.md, runbook, README, `docs/changelog/2026-08-19-ops71-fase2.md` + `pnpm changelog:build`.
3. **Gates finais** — `pnpm exec tsc --noEmit`, `pnpm lint`, `pnpm format:check`, `pnpm exec knip`, `pnpm check:cycles`, `pnpm test` (unit+int), `pnpm build`; grep de saneamento (`forgejo-pr-automerge|forgejo-dispatch|ci-classify|dockerignore.mjs|branch-protection.mjs` → só histórico/docs/changelogs); push → PR GitHub → CI (PR) / checks → auto-merge.
## Rabbit holes / Não escopo (engenharia)
- Re-engenharia do `ciSkipInvariants` além do re-pino — não; só o que o arquivo lê muda.
- `pnpm configure:branch-protection`/github libs — intocados (era GitHub).
- Pool/`agent-pool*` dormente (OPS65) — intocado.
- Desligar o runner do Forgejo — passo manual no runbook, não código.
- `.dockerignore` (o arquivo) — **permanece**: o `docker build` do `deploy-homeserver.sh` o consome; só o lib `dockerignore.mjs` (parse de regras, gate OPS65) morre.
## Riscos e mitigação
- **`ciSkipInvariants` quebrar por leitura de arquivo removido** — os 3 pontos são editados na mesma fase da deleção; gate local `pnpm test:unit` antes de qualquer push.
- **Pin perdido do "deploy manual"** — mitigado pelo teste reescrito (dispatch-only, sem `push:`/`schedule:`); se um dia o `ci.yml` voltar com trigger, o teste acende.
- **Referência morta residual fora do grep** — saneamento final com o grep do escopo + knip (dead exports/deps é CI-blocking); docs históricos (plans/changelogs antigos) são allowlist consciente.
- **Knip acusar `branch-protection.mjs`/`dockerignore.mjs` como órfãos na Fase 1→Fase 2** — a deleção é atômica com a remoção das referências (mesma fase); nada fica órfão no diff.
- **PR de plano vs `check-plans-only-pr-closes`** — este PR tem código (não é plans-only); guard não se aplica.
## Aceite de engenharia
- [x] Aceite de produto da intenção ainda coberto (remoção completa + docs; tracker intacto)
- [x] Invariantes AGENTS/engineering-standards (sem migration/schema; nada do tracker; docs históricos intocados; changelog insert-only)
- [x] Testes de domínio previstos: ciSkipInvariants re-ancorado na era GitHub (deploy dispatch-only); unit suíte verde

View file

@ -0,0 +1,97 @@
# Impl: OPS71-FLIP — Flip pós-merge falhou/silencioso no fluxo GitHub — validar e endurecer issue-done-on-main-merge
Status: aprovado
Atualizado em: 2026-08-20
Issue: #119
Intenção: body da Issue (sem plano linkado — o body é a spec)
Appetite restante: herdado (chore P2, sem appetite declarado — escopo = as 4 ações sugeridas + fix de raiz)
## Leitura da intenção
- **Outcome:** todo PR mergeado em `main` com `Closes #N` deve flipar a Issue no Forgejo para `done`+`in-prod` + comentário + **close** — de forma **não silenciosa** (falha visível) e com **caminho de recuperação manual** quando o evento do GitHub não criar run.
- **O que NÃO negociar:** tracker continua no Forgejo (flips via `FORGEJO_API_TOKEN`); fork guard (RCE) preservado; flips nunca mentem (OPS61: job vermelho se qualquer flip falhar); CI green é o único gate de merge.
- **O que reavaliar:** a hipótese da Issue ("tipos de evento / entrega do GitHub") estava certa só pela metade — a causa real é a **anti-recursão do GITHUB_TOKEN** (merge atribuído a `github-actions[bot]` não cria runs); a hipótese "410 é transitório, só pin/retry" é insuficiente — o 410 quebrou o run do #742 **depois** do evento ter criado o run; os dois problemas são independentes.
## Investigação (root cause — evidência ao vivo, GitHub API pública)
Comparação dos dois merges (timeline `issues/{n}/timeline` + `actions/runs`):
| | PR #742 (Fase 1) | PR #746 (Fase 2) |
| ------------------------ | ----------------------------------------------------------------- | ----------------------------------------------------------------------- |
| `auto_rebase_enabled` | `github-actions[bot]` 21:20:38Z | `github-actions[bot]` 00:02:32Z |
| `merged` / `closed` | **`fsolla`** 22:17:20Z | **`github-actions[bot]`** 00:20:11Z |
| Runs criados no `closed` | 32308114529 (falhou 410) + 32308114530 (skipped), 3s após o merge | **0 runs** (nenhum evento, nenhum head — janela 00:00–00:45 verificada) |
**Causa raiz:** o auto-merge nativo armado via `enablePullRequestAutoMerge` (GraphQL) com o **GITHUB_TOKEN** do workflow executa o merge como `github-actions[bot]`. O GitHub **documenta** ("Automatic token authentication", anti-recursão): eventos acionados por ações feitas com o `GITHUB_TOKEN` **não criam workflow runs** (exceção: `workflow_dispatch`, `repository_dispatch`). O `closed` fica no timeline do PR, mas o Actions não gera run nenhum — nem `pull_request`, nem `push` (0 runs na janela do merge). Toda auto-merge por bot vai pular os dois flips pós-merge **em silêncio** (run nem é criado — nunca vai aparecer vermelho).
O 410 é falha **separada**: o run do #742 foi criado normalmente (merge por humano) e o `GET /issues/97/labels` respondeu 410 Gone (nginx) — transitório, endpoint ok às 00:3x. O retry do OPS67 cobre fetch-rejection + 5xx-no-GET; 410 (4xx) nunca retenta; o corpo nginx HTML entra no erro só como slice cru de 400 chars (imprestável para log).
## Abordagem recomendada
```mermaid
flowchart LR
M["PR mergea em main"] -->|"auto-merge armado com PAT real (AUTOMERGE_PAT)"| E["closed event → runs criados<br/>(merge atribuído ao dono do PAT)"]
M -. "legado: bot merge (GITHUB_TOKEN)" .-> X["anti-recursão: 0 runs<br/>(causa raiz #746)"]
E --> F["issue-done / plan-issue-ready flipam no Forgejo"]
F --> C["labels done+in-prod + comentário + close<br/>(forgejo-issue-transition.mjs)"]
H["humano: dispatch manual (recovery)"] --> G["gh pr view → PR_BODY → mesmo script"]
C -->|"410/5xx transitório"| R["retry 410 no forgejo-api<br/>(proxy respondeu — origem intocada)"]
```
**Opções consideradas (fix de raiz):** A | B | C
**Recomendação:** **A + recuperação manual + close + retry 410** — a trinca atende os 4 pontos da Issue e conserta a causa que a torna _silenciosa_.
**Rejeitadas:**
- **B — manter arming com GITHUB_TOKEN e só adicionar dispatch manual:** o flip continua quebrado em todo merge de bot (o "silencioso" do título); o humano vira o flip de todo PR. Rejeitada porque a Issue é exatamente sobre o silêncio.
- **C — poller (schedule) que varre PRs mergeados:** disparo a cada N minutos com API lendo PRs fechados = latência, races, e um "verificador" novo que o OPS71 eliminou de propósito; PAT + evento nativo é mais simples e imediato.
**Opções consideradas (recuperação manual):** A | B
**Recomendação:** **A — `workflow_dispatch` com input `pr`** (o script já aceita `PR_BODY`; o step de dispatch busca body+merged via `gh` e repassa). B (rodar o script local com `PR_BODY`) é o fallback final do runbook, sem YAML novo.
**Rejeitadas:** C — GitHub App só para isso (peso de registrar app; o repo já usa PATs).
**Opções consideradas (410):** A | B
**Recomendação:** **A — 410 entra no retry do `forgejo-api.mjs` para QUALQUER método** (não só GET): 410 vem do proxy (nginx/Cloudflare) — a origem **nunca viu** o request, então retentar escrita não duplica efeito colateral (diferente de 5xx, onde a origem pode ter aplicado o write — esse continua fail-closed). Corpo não-JSON vira marker compacto no erro.
**Rejeitadas:** B — 410 só no GET (o flip faz `removeLabels` GET → `addLabels` POST → `addComment` POST; um 410 no POST continuaria quebrando o flip apesar da classificação "transitório conhecido").
### Componentes / mudanças
- **`.github/workflows/agent-pr-ready-automerge.yml`**: passa `AUTOMERGE_PAT: ${{ secrets.AUTOMERGE_PAT }}` ao step; comentário do porquê (anti-recursão).
- **`scripts/github-pr-automerge.mjs`**: **fail-closed** — se `AUTOMERGE_PAT` ausente, `exit 1` com mensagem nomeando o secret e o motivo (nunca fallback silencioso para GITHUB_TOKEN); `enableAutoMerge` passa a usar `createApi({ token: AUTOMERGE_PAT })` (o `markPullRequestReady` continua com o token do run — PATCH draft:false não precisa gerar evento: nada consome `ready_for_review` além do próprio workflow, e o CI corre no `synchronize` do push do agente).
- **`.github/workflows/issue-done-on-main-merge.yml`** + **`plan-issue-ready-on-main-merge.yml`**: `on: workflow_dispatch` com input `pr` (required); job `if:` = evento (`closed` + merged + main + same-repo) **ou** dispatch com input; permission `pull-requests: read` (gh); step de dispatch: `gh pr view` → valida `merged == true` + `baseRefName == main` + `headRepository.nameWithOwner == github.repository` (mesmo contrato do evento — um dispatch num PR de fork/aberto é skip) → `PR_BODY` via `GITHUB_OUTPUT`; step do node usa `steps.pr.outputs.* || github.event.pull_request.*` (um caminho só, sem override vazio). O script já no-ops sem `Closes`/`Related`.
- **`scripts/forgejo-issue-transition.mjs`**: no loop do flip, após labels+comentário → **`api.closeIssue(number)`** (era-Forgejo o close era nativo do merge do PR; era-GitHub o PR não toca o tracker — sem o PATCH toda Issue mergeada fica `OPEN` com `done`+`in-prod`). Falha de close = `flipFailed` (OPS61: vermelho, nunca mentir). Comentário ganha menção ao close.
- **`scripts/lib/forgejo-api.mjs`**: `RETRYABLE_BEFORE_ORIGIN = new Set([410])` retentado em qualquer método (comentário: proxy respondeu — origem intocada, sem risco de efeito duplicado; 5xx segue GET-only); erro `!response.ok` com corpo HTML → marker compacto `corpo não-JSON (proxy/nginx — HTTP <status>): <120 chars>` em vez do slice cru.
- **`scripts/lib/github-api.mjs`**: **intocado** (o 410 observado é do proxy do Forgejo; github-api não recebe o mesmo tratamento por escopo — política documentada nos headers de ambos).
- **`docs/AGENT-OPS.md`**: tabela de workflows (rows dos dois flips ganham "dispatch manual de recuperação"), tabela de secrets (`AUTOMERGE_PAT`), parágrafo do flip com o porquê bot-merge não cria run + runbook de recuperação (dispatch com número do PR; fallback local `PR_BODY`).
- **`AGENTS.md`**: frase curta no parágrafo do OPS71 (auto-merge armado com `AUTOMERGE_PAT` — merge por bot não cria runs).
- **Migration:** sem migration. **Access/Consent:** n/a. **UI:** n/a.
- **Changelog:** `docs/changelog/2026-08-20-ops71-flip.md` + `pnpm changelog:build`.
## Fases verificáveis
1. **Lib + script do flip** — `forgejo-api.mjs` (410 + closeIssue + marker HTML), `forgejo-issue-transition.mjs` (close). Unit specs novas em `tests/unit/forgejoApi.unit.spec.ts` (410 GET retry, 410 write retry, 410 esgotado → marker HTML, closeIssue PATCH pin). Verificação: `pnpm test` (spec), `pnpm exec tsc --noEmit`, lint/format.
2. **Auto-merge com PAT** — `github-pr-automerge.mjs` fail-closed + workflow env. Verificação: tsc/lint; comportamento real no próprio PR desta entrega (CI verde → auto-merge armado com o PAT).
3. **Workflows de recuperação** — `workflow_dispatch` nos dois flips. Verificação: YAML válido (CI do PR), gate do humano.
4. **Gates + docs + changelog** — `pnpm gate:fast`; `pnpm changelog:build`; AGENT-OPS/AGENTS.
5. **Pós-merge (validação ao vivo, humano):** criar o secret `AUTOMERGE_PAT` **antes** do merge deste PR (senão o auto-merge do próprio PR falha fail-closed); conferir o flip da #119 (run criado pelo `closed` do merge — agora atribuído ao humano) e, como recovery proof, disparar o dispatch manual do `issue-done` uma vez.
## Rabbit holes / Não escopo (engenharia)
- Retry 410 no `github-api.mjs` (simetria): sem evidência de 410 no GitHub API; política documentada, gatilho = ocorrência real.
- GitHub App dedicado para o merge (B do fix de raiz): rejeitado no gate — PAT fine-grained basta.
- `agent-promote-related-on-merge.mjs` (`setLabels` do `agent-forgejo.mjs`): intocado — só o workflow dele ganha o dispatch.
- Poller/`schedule` de verificação de merges (C): rejeitado — o evento nativo + dispatch cobre.
- Drift do `ci-pr.yml` / branch protection: fora de escopo.
## Riscos e mitigação
- **Secret `AUTOMERGE_PAT` ainda não existe no merge deste PR** → o fail-closed derruba o auto-merge de todo PR até o humano criar. Mitigação: passo 5 da fase pós-merge declara criar o secret **primeiro**; sem o secret, merges manuais continuam funcionando (o bot não arma, o humano mergea — `closed` por humano gera runs, comportamento #742).
- **Fine-grained PAT sem permissão de merge** → `enablePullRequestAutoMerge` GraphQL falha → job vermelho (visível, não silencioso). Mitigação: runbook documenta escopos (`pull-requests: write` + `contents: write` no repo fsolla/teqo).
- **Dispatch manual num PR não mergeado / fork** → skip (validação `merged` + `baseRefName` + `headRepository` no step), mesma semântica do evento.
- **`gh` no runner hosted**: pré-instalado no `ubuntu-latest`; `GITHUB_TOKEN` injetado automaticamente; sem dependência nova.
## Aceite de engenharia
- [x] Aceite de produto da intenção ainda coberto (flip sempre acontece; falha é visível; recovery existe)
- [x] Invariantes AGENTS/engineering-standards (fork guard nos dois workflows, OPS61 exit-1, zero-dep plain Node, DRY <3 call sites)
- [x] Testes de domínio previstos (unit: retry 410 GET/write, marker HTML, closeIssue, fail-closed PAT)
- [x] Self-score decision-quality: 5/5 (decisões caras com rejeitadas; abordagem no appetite; rabbit holes nomeados; depth check ok — reusa createApi/PR_BODY; intenção preservada)

View file

@ -0,0 +1,135 @@
# Impl: Runner self-hosted do GitHub no homeserver (deploy do dispatch manual) + CURSOR_API_KEY
Status: aguardando aprovação
Atualizado em: 2026-08-19
Issue: #113
Intenção: o body da Issue #113 (OPS71-INFRA) é a spec — sem plano de intenção separado
Appetite: horas de operação (não é Issue de código — nada de schema/UI/access)
## Leitura da intenção
- **Outcome:** o job `deploy` do `.github/workflows/deploy.yml` (dispatch manual)
roda no **runner self-hosted do GitHub instalado no homeserver** (labels
`self-hosted` + `homeserver`), executando `scripts/deploy-homeserver.sh <sha>`
localmente (sem SSH). O hosted (`verify`) nunca toca o homeserver.
- **O que NÃO negociar:** runner só no `deploy.yml` (nunca no `ci-pr.yml`);
usuário com docker group + leitura de `~/stack/`; outbound-only (funciona
atrás do Cloudflare tunnel); registro do runner em infra-solla `STATE.md`.
- **Já validado na sessão (pre-flight):**
- SSH ao homeserver OK (LAN `192.168.15.142`, user `fsolla`).
- `fsolla` tem `docker` no groups + `sudo`; lê `~/stack/teqo-1313.env` e
`~/stack/.env` (modo 600, dono = fsolla). Arch `x86_64` → tarball linux-x64.
- **Runner ainda NÃO existe** no homeserver (sem `~/actions-runner`, sem unit
systemd do runner; o `runner-watch.*` é outra coisa — WoL/shutdown da
workstation).
- **PAT do OPS71 funciona** (local, `~/.bashrc`): `GET /repos/fsolla/teqo` 200
e `POST …/actions/runners/registration-token` 201 → admin no repo OK.
- Versão estável atual do runner: **v2.336.0** linux-x64, 226 MB,
sha256 `04cf0be1aff4c3ec3554466c39124ca250e3effd8873bb7e8d68535aa9505d5d`
(digest da API de releases do GitHub).
- **Linger do homeserver já ativo** (`loginctl show-user fsolla` →
`Linger=yes`) → systemd **user** funciona sem sudo (o `runner-watch` já é
user service persistente lá — prova do padrão).
- Workspace `~/teqo-deploy` existe mas ainda aponta origin → Forgejo
(`http://localhost:3000/…`): o `set-url` idempotente do
`deploy-homeserver.sh` re-aponta para `TEQO_REPO_URL` (GitHub) no primeiro
deploy — nada a fazer, mas é o que o log do primeiro deploy deve mostrar.
## Abordagem recomendada
Sequência **operacional no homeserver** (nenhum commit no repo teqo — o código
do OPS71 já foi mergeado; o único artefato commitado desta Issue é o registro no
`STATE.md` do infra-solla, repo separado):
1. **Download** (homeserver, `~/actions-runner`): tarball v2.336.0 linux-x64 da
release oficial, **sha256 conferido** contra o digest da API de releases.
2. **Registration token** (workstation, PAT local): `POST
/repos/fsolla/teqo/actions/runners/registration-token` (TTL 1 h — gerar e
configar na sequência, um único passo encadeado).
3. **config.sh** (homeserver, user fsolla):
`./config.sh --url https://github.com/fsolla/teqo --token <token> --labels
self-hosted,homeserver --name teqo-1313-runner --work _work`.
4. **svc.sh como systemd USER** (`./svc.sh install` sem root → unit
`~/.config/systemd/user/actions.runner.fsolla-teqo-1313-runner.service`) +
`systemctl --user enable --now …` (start). Linger já ativo → persiste sem
login; roda como fsolla (docker + `~/stack` envs).
5. **Verificação do runner**: `systemctl --user status …` ativo; runner listado
em `GET /repos/fsolla/teqo/actions/runners` como online.
6. **Registro em infra-solla** (`~/Code/infra-solla/STATE.md`): entrada na
seção do Teqo + nota na tabela de topologia (runner GitHub no homeserver,
labels, dir, systemd user) — e no runbook `docs/ops/teqo-1313-deploy.md` do
teqo (a tabela "Onde roda cada coisa" já o menciona prospectivamente; só
confirmar o texto com o estado real). Changelog `docs/changelog/2026-08-20-ops71-infra.md` + `pnpm changelog:build`.
7. **Aceite (verificação ao vivo)**: dispatch manual do `deploy.yml` (ref main)
→ `verify` full verde → job `deploy` roda no runner do homeserver → log com
o `set-url` do workspace (Forgejo → GitHub) + smoke ok → **segundo dispatch
do mesmo SHA** → guard "already deployed".
**Decisões:**
- **systemd user + linger (já ativo) vs. `svc.sh install` via root:** user — sem
senha sudo interativa, mesmo padrão do `runner-watch` (persistência provada no
próprio homeserver); o unit system-level não adicionaria nada (o unit user
roda com `User=fsolla` de qualquer forma).
- **`~/actions-runner` + workdir `_work`:** convenção oficial; `~/teqo-deploy`
permanece como workspace do deploy (o script seta `TEQO_REPO_URL` nele).
- **Sem mudanças em código/workflows:** `runs-on: [self-hosted, homeserver]`
já está no `deploy.yml`; o `deploy-homeserver.sh` já tem o `set-url`
idempotente. O runner vem do GitHub com o token de registro — sem secrets
novos no repo (o repo é público; as envs de produção ficam no homeserver).
**Rejeitadas:**
- **`svc.sh install` como root** — exigiria sudo interativo; nada ganho sobre o
unit user (mesmo usuário efetivo, linger já garante auto-start no boot).
- **Rodar o runner em container/docker** — complexidade extra; o padrão oficial
do GitHub é o binário com svc; o runner-watch local prova o user service.
- **Instalar agora o helper `archive-cursor-agent` / criar o secret
`CURSOR_API_KEY` no GitHub** — item secundário da Issue, dormente (pool morto,
OPS65): a key não existe no ambiente; fica **registrado como pendência** no
fechamento (pedir a key ao humano quando quiser reativar o helper), sem ação
agora.
## Fases verificáveis
1. Runner baixado + sha256 conferido.
2. Runner configurado (`.runner` + `.credentials` no `~/actions-runner`),
labels corretas, nome `teqo-1313-runner`.
3. Unit systemd user ativo e **persistente** (`systemctl --user is-enabled …`),
runner online na API do GitHub.
4. `STATE.md` (infra-solla) + runbook + changelog registrados.
5. Dispatch manual de validação (verify → deploy → already-deployed no segundo
dispatch) — **aceite final, exige o humano** (dispara no GitHub UI).
## Rabbit holes / Não escopo (engenharia)
- Não re-engenharia do `deploy-homeserver.sh` (fluxo compose/migrate intacto).
- Não mexer no stack/registry/env do homeserver.
- Não criar secret `CURSOR_API_KEY` (dormente — só com a key do usuário).
- Não editar o `ci-pr.yml` nem a branch protection.
## Riscos e mitigação
- **Token de registro expira em 1 h** → o `config.sh` roda na mesma sequência
do POST (script encadeado da workstation → homeserver).
- **`svc.sh install` gerar unit system em vez de user** (se detectar root):
rodar sem sudo garante o user unit; se algo criar system unit, desfazer com
`svc.sh uninstall` e repetir como user.
- **Runner offline no dispatch** → falha conhecida já documentada no runbook
("job deploy nunca roda / runner offline"): checar `systemctl --user status`,
o runner-watch local não cuida deste runner (não é o forgejo-runner).
- **verify full ~50 min hosted antes do deploy** → aceite da Issue é um ciclo
completo; nada do homeserver é tocado até `verify` verde.
- **Rollback**: `~/actions-runner/./svc.sh uninstall` + `rm -rf ~/actions-runner`
(desregistra no GitHub); o deploy volta a ficar indisponível até o runner ser
religado — comportamento conhecido, documentado no runbook.
## Aceite de engenharia
- [ ] Aceite de produto da intenção coberto (deploy self-hosted no homeserver,
verify hosted nunca toca o homeserver)
- [ ] Runner instalado como fsolla (docker + `~/stack`), systemd user com
linger, outbound-only
- [ ] Registrado em infra-solla `STATE.md` + runbook + changelog
- [ ] Validação ao vivo: dispatch manual → deploy no runner → "already
deployed" no segundo dispatch

View file

@ -0,0 +1,76 @@
# Impl: E2E local apenas para os testes afetados — CI de PR sem e2e full
Status: aprovado
Atualizado em: 2026-08-19
Issue: #98
Intenção: docs/plans/ops72-e2e-local-afetado-ci-sem-full.md
Appetite restante: ~0,5–1 dia eng (herdado)
## Leitura da intenção
- **Outcome:** (1) skill `work-issue` (e irmãs do fluxo) exige que o agente rode localmente **os e2e que criou + os da mesma superfície afetada** — **discricionário**: o agente decide quais; `pnpm test:e2e:affected` é a ferramenta, não um gate mecânico; (2) CI de PR roda o **blast-radius** (e2e que o classifier detecta como tocados — modo `selected`, nunca `full`); (3) e2e **full** roda na **main, antes do deploy** (`verify` do deploy manual — rede de segurança pré-publicação); (4) limitação da #72 documentada na skill.
- **O que NÃO negociar:** full no deploy/verify de main; gate local (`gate:push`) sem e2e (passo explícito da skill, não hook); mecânica do classifier/manifest intocada; CI selected (blast radius) mantido como rede — o local é discricionário por design, o CI não.
- **O que reavaliar:** hipótese de "`.github/workflows/ci.yml` (verificador de main)" — **não existe mais**: OPS71 eliminou o `ci.yml`; a suíte full vive só no job `verify` do `deploy.yml` (confirmado). A hipótese do ci-pr.yml já ter os dois steps e2e (`full` + `selected`) dirigidos por `ci-scope.mjs` se confirma — o trabalho é **remover o full** e reorientar o texto.
## Abordagem recomendada
```mermaid
flowchart LR
A[Diff do PR] --> B[ci-scope.mjs / e2e-affected.mjs]
B -->|selected| C[CI PR: E2E blast-radius manifest-selected specs]
B -->|full| D[CI PR: sem e2e - full vive no verify do deploy]
B -->|none| E[CI PR: sem e2e - sem blast radius]
F[Agente antes do push] --> G[e2e local dos specs criados + mesma superfície - discricionário]
G -->|decisão do agente| H[pnpm test:e2e:affected ou specs diretas]
I[main antes do deploy] --> J[verify: e2e full]
```
**Opções consideradas:** A | B | C
**Recomendação:** A — remover o step e2e `full` do `ci-pr.yml`; quando o classifier emitir `full` (diff high-risk: schema/lockfile/harness), o CI de PR simplesmente não roda e2e (o step `selected` tem `if: e2e_mode == 'selected'` e é pulado; build-for-e2e segue rodando). O espelho local (`run-e2e-affected.mjs`) roda **full localmente** nesse mesmo caso — mesma mecânica, sem gap: o único caminho para "full" é diff high-risk, e aí o agente viu o full na máquina dele e o `verify` do deploy roda full antes de publicar. Ciência: um diff high-risk sem e2e no CI é um risco deliberado (rabbit hole "full só no deploy cria gap de regressão" já aceito no gate).
**Rejeitadas:** B — manter o step `full` no CI (status quo): viola o gate "nunca full por PR" e devolve o custo 40+ min por PR de infra. C — mapear `full` → falha/aviso no CI: criaria bloqueio artificial em PRs legítimos de infra/schema (o diff high-risk é exatamente o que o local roda full); o verify do deploy já é o gate honesto.
### Componentes / mudanças
- **`.github/workflows/ci-pr.yml`**: remover o step `E2E tests (full suite, single job, 4 workers)`; atualizar o comentário do header ("e2e policy: selected (affected) specs on PRs — never full; full lives in deploy verify — OPS72"); o step `E2E tests (manifest-selected specs)` fica funcionalmente intacto.
- **`scripts/gate-ci.mjs`**: mensagens informativas do escopo e2e (texto-only, ~linhas 153–162) — "e2e verified in CI" vira "e2e selected no CI (PR); full só no `verify` do deploy; local: `pnpm test:e2e:affected` (passo da skill)". Comportamento intocado (e2e fora do gate — aceite). Mensagens não são pinadas por unit tests (verificado).
- **`.agents/skills/work-issue/execution-pipeline.md`** (corpo compartilhado das duas skills): seção "Executar" item 3 (Gates) ganha o passo **e2e local (discricionário)** antes do `pnpm push`; nova seção curta "E2E local afetado" documentando: o agente roda **os specs e2e que criou + os da mesma superfície afetada**, decidindo quais (discricionário); a ferramenta (`pnpm test:e2e:affected` — espelho do classifier; ou specs diretas via `--no-deps`), e a **limitação da #72** (`--no-deps` + projetos paralelos colidem no `seedTestUser` → `--workers=1` ou a cadeia padrão de projetos; só conveniência local, CI é sequencial por construção).
- **`.agents/skills/work-issue/SKILL.md`**: checklist item 4 e Passo 4 ganham o passo (humano): "rode localmente os e2e que você criou + os da mesma superfície afetada — você decide quais (`pnpm test:e2e:affected`)".
- **`.agents/skills/agent-work-issue/SKILL.md`**: delta pool — mesmo passo **quando o worktree tiver browsers Playwright**; Cursor Cloud sem browser → registra justificativa e segue (CI selected é a rede) — opção B da intenção com registro, não silêncio.
- **`AGENTS.md`**: reescrever o parágrafo "O e2e não roda mais no gate local (OPS59)" → política OPS72: e2e afetado local = passo obrigatório da skill; CI de PR = selected; full = `verify` do deploy manual.
- **`docs/AGENT-OPS.md`**: item "Agente faz sozinho" ganha o passo e2e local afetado; linha do `ci-pr.yml` na tabela CI passa a "e2e selected (nunca full — OPS72)".
- **`.agents/rules/agent-pr-workflow.mdc`**: linha 41 (`pnpm gate:push` descrição) reflete a política nova.
- **Migration:** sem migration. **Access / Consent:** n/a. **UI:** Impeccable A — sem UI.
- **Changelog (OPS44):** `docs/changelog/2026-08-19-ops72.md` + `pnpm changelog:build` + `changelog:check`.
### Dados → forma (se aplicável)
- N/a — não apresenta dados; apenas política/CI/skills.
## Fases verificáveis
1. **CI** — `.github/workflows/ci-pr.yml`: step full removido + comentários; `scripts/gate-ci.mjs`: mensagens (texto-only). Verificação: `pnpm exec prettier --check` nos arquivos; diff limpo.
2. **Skills** — `execution-pipeline.md` (passo + seção #72), `work-issue/SKILL.md`, `agent-work-issue/SKILL.md`.
3. **Docs** — `AGENTS.md`, `AGENT-OPS.md`, `agent-pr-workflow.mdc`.
4. **Changelog** — entrada OPS72 + build + check.
5. **Gates** — `pnpm gate:fast`; `pnpm format:check`; `pnpm test:unit --changed` (nenhum pin quebra — mensagens de console não são pinadas); e2e local afetado: o diff toca `scripts/gate-ci.mjs` (HIGH_RISK) → modo `full` → suíte full rodada (154 passed em dev; 8 flakes de ambiente dev triados — passam em `E2E_PROD=1`, o modo do CI); CI do PR não roda e2e (full por design desta política); `pnpm push`; PR Ready `Closes #98`.
## Rabbit holes / Não escopo (engenharia)
- Não tocar `ci-scope.mjs`, `e2e-affected.mjs`, `e2e-affected-manifest.mjs`, `test-affected-core.mjs`, `run-e2e-affected.mjs` — mecânica funciona; só muda onde/em que modo roda (fora de escopo da intenção).
- Não implementar o fix da #72 (fica na fila; só documentação).
- Não adicionar e2e ao `gate:push`/`gate:ci` (aceite explícito: passo da skill, não hook).
- Não mexer no `deploy.yml` (já roda full no `verify`).
- Não criar script novo — `pnpm test:e2e:affected` já existe.
## Riscos e mitigação
- **Diff high-risk no CI sem e2e** (ex.: PR de schema): o PR não roda e2e no CI. Mitigação: espelho local roda full no mesmo diff; `verify` do deploy roda full antes de publicar; documentado no workflow e no gate-ci.
- **Agente pula o e2e local**: discricionário por design (decisão do usuário) — o agente decide quais specs rodar e pode decidir não rodar. Mitigação: CI selected (blast radius) pega a superfície quando o diff é selecionável; full no `verify` do deploy fecha o ciclo antes de publicar.
- **Mensagem do gate-ci desatualizada vs CI**: texto ajustado na mesma entrega; nada pinado.
- **Serialização**: a intenção serializa `ci-pr.yml` e `.agents/skills/work-issue/`; OPS71 já mergeou — sem concorrência pendente (OPS72 é o primeiro a tocar no ci-pr.yml novo pós-OPS71).
## Aceite de engenharia
- [x] Aceite de produto da intenção ainda coberto (e2e local discricionário documentado na skill; CI/PR = blast-radius selected, nunca full; full na main antes do deploy; #72 documentada; gate local intocado)
- [x] Invariantes AGENTS/engineering-standards (edita o dono — execution-pipeline é o corpo compartilhado; nada duplicado)
- [x] Testes de domínio previstos: nenhum pin quebra (mensagens de console não pinadas); `test:e2e:affected` com modo `none` valida o fluxo novo

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View file

@ -0,0 +1,77 @@
# Impl: Admin do Payload segue em branco em produção — re-home S8/S9 no GitHub + fix importMap + deploy + verificação
Status: entregue (deploy `dba75ff8` em prod + verificação pós-deploy completa)
Atualizado em: 2026-08-20
Issue: #114
Intenção: docs/plans/ops73-deploy-admin-prod.md
Appetite restante: herdado (~0,5 dia); ajuste: +1 PR de re-home (premissa da intenção se provou falsa)
## Leitura da intenção
- **Outcome:** `/admin` de produção renderiza o formulário de login (tela preta some); editor loga e abre uma coleção; log do container sem `getFromImportMap: PayloadComponent not found in importMap` para `@payloadcms/storage-s3/client#S3ClientUploadHandler`.
- **O que NÃO negociar:** nada de schema/migration/Consent/UI nova nesta entrega; não reabrir o diagnóstico OPS69 (#77); não introduzir features novas; o deploy é do que já está aprovado.
- **O que reavaliar (premissa FALSA):** a intenção afirma que "o fix OPS69 está em main desde 7162c65e e produção nunca o recebeu — basta deployar o main". **Verificado ao vivo (2026-08-19 23:56–2026-08-20 00:40), a premissa está errada em dois pontos:**
1. **Produção JÁ recebeu um deploy às 23:09 UTC** (revision `8c734ba`, "docs(S8)", build 23:09:47, container up 23:09:48) — mas essa revision **não tem o handler no importMap**: o commit S9 (`8d2352c0`, captura de novidades na home) regenerou o importMap **sem** as envs `S3_*` e removeu as 2 linhas do fix OPS69. O erro `getFromImportMap` persiste no log desde o start do container e `/admin` continua em branco (0 inputs/botões/links; shell HTML ok, título "Painel de Controle - Payload"). A revision ANTERIOR (`fbfbfb1`) **tinha** o handler (2 matches) — confirma o relato "funcionou e no deploy seguinte parou de funcionar de novo".
2. **O main do GitHub diverge do main do Forgejo (pós-cutover OPS71):** GitHub main = `3ea16243` (OPS69 fix com handler ✓, OPS71 infra ✓, plans S11/OPS73 ✓) mas **NÃO contém as features S8 (seção "Nossa história") e S9 (captura de novidades/newsletter)** que produção roda desde o deploy das 23:09 (árvore de 8c734ba tem `CampaignNewsletterForm.tsx`, `CampaignStorySection.tsx`, migration `20260819_192605_add_campaign_newsletter_capture`, e o GitHub main não — 404 via API; `git diff 8c734ba 3ea16243` mostra os arquivos como `D`). O Forgejo main congelou em `e339ceec` (S8/S9/S10); o workspace do homeserver ainda aponta `origin = http://localhost:3000/fsolla/teqo.git` (o Forgejo local) — por isso sua visão de "main" era o e339ceec. **Deployar o main do GitHub como está REGREDIRIA as features S8/S9 em produção** (some a seção "Nossa história" e o form de newsletter).
## Abordagem recomendada
```mermaid
flowchart LR
A[cherry-pick S9 8d2352c0 + S8 488dcf7d sobre main 3ea16243] --> B[resolver conflito importMap: manter handler]
B --> C[regen importMap com S3_* dummy: diff vazio = prova de consistencia]
C --> D[gates locais: tsc/lint/format/knip/cycles/unit/int/build]
D --> E[PR OPS73 em main com auto-merge]
E --> F[confirmar no main: handler + S8/S9 presentes]
F --> G[deploy via ssh homeserver: deploy-homeserver.sh sha]
G --> H[verificar: revision label + log + /admin renderiza + home S8/S9 + editor loga]
```
**Opções consideradas:** A | B | C
**Recomendação:** **A — PR único OPS73** que re-homa no GitHub main as features S8+S9 já aprovadas e mergeadas no Forgejo (cherry-pick dos commits de feature `8d2352c0` + `488dcf7d`), resolve o conflito do importMap **mantendo o handler** e prova consistência rodando `generate:importmap` com envs `S3_*` dummy (diff deve ser vazio) — porque: (1) o aceite "admin no ar" exige o handler no build; (2) o aceite "não regredir prod" exige S8/S9 no main antes do deploy; (3) o main do GitHub é a fonte da verdade do deploy (guard de HEAD + `TEQO_REPO_URL`); (4) cherry-pick do código já aprovado ≠ feature nova (anti-goal preservado: nada de design/schema novo).
**Rejeitadas:**
- **B — PRs separados (re-home S8, re-home S9, depois fix/deploy):** ownership nominalmente mais limpo, mas 3 PRs em fila, admin fora do ar por mais tempo e o deploy continua dependendo do último; sem ganho de risco.
- **C — deploy do main atual aceitando a regressão S8/S9:** regressão pública (seção "Nossa história" + form de novidades somem) — viola o aceite "o time editorial e de campanha volta a trabalhar" e reverteria features `in-prod` no tracker.
### Componentes / mudanças
- **`src/app/(payload)/admin/importMap.js`** (gerado, commitado): estado final = com `@payloadcms/storage-s3/client#S3ClientUploadHandler` (idêntico ao de 3ea16243 — o conflito do cherry-pick do S9 resolve mantendo o handler; a regen com envs `S3_*` dummy fecha a prova de consistência com diff vazio).
- **S9 (cherry-pick `8d2352c0`):** `CampaignNewsletterSection.tsx`, `submitCampaignNewsletter.ts`, `CampaignNewsletterForm.tsx`, `campaignConsentKeys.ts`, `schemas/campaignNewsletter.ts`, mudanças em `Contact.ts`/`Subscription.ts`/`CampaignHero.tsx`/`CitySelect.tsx`/`StateSelect.tsx`/`page.tsx`/`styles.css` + migration `20260819_192605_add_campaign_newsletter_capture` + docs/changelog. **ImportMap do commit NÃO entra (remover as -2 linhas é o que quebrou; manter o handler).**
- **S8 (cherry-pick `488dcf7d`):** `CampaignStorySection.tsx`, `page.tsx` (+2), e2e `frontend.e2e.spec.ts`/`fixtures/e2eTest.ts`, `.gitignore`, plans/changelog.
- **Migration:** o cherry-pick traz a do S9; o deploy aplica **zero migrations novas** vs prod (8c734ba já a tem aplicada — `20260819_192605` rodou no deploy das 23:09). Sem migration nova neste PR.
- **Access / Consent:** não se aplica (S8/S9 já aprovados; `campaignConsentKeys.ts` é parte do S9 já mergeado no Forgejo).
- **UI:** Impeccable A — nenhuma UI nova; features S8/S9 já aprovadas (copy aprovado nos respectivos PRs).
## Fases verificáveis
1. **Cherry-picks + conflito** — `git cherry-pick 8d2352c0` (resolver importMap: manter o handler) e `488dcf7d` sobre `origin/main` (3ea16243); conferir que o diff final vs 3ea16243 contém S8/S9 completos (sem truncamentos) e que o importMap fica com handler. (Quota: ~30 min)
2. **Prova de consistência do importMap** — `S3_BUCKET=teqo-media S3_ENDPOINT=http://localhost:3900 S3_ACCESS_KEY_ID=test S3_SECRET_ACCESS_KEY=test pnpm generate:importmap` → `git diff` do importMap deve ficar **vazio** (estado já consistente). Se aparecer drift não compreendido: parar e investigar (guard do OPS69).
3. **Gates** — `pnpm exec tsc --noEmit`, `pnpm lint`, `pnpm format:check`, `pnpm exec knip`, `pnpm check:cycles`, `pnpm test` (unit+int), `pnpm build` (DB local). E2e: no CI do PR (frontend S8/S9 specs + admin).
4. **PR → merge** — push da branch, PR para main, auto-merge (required check `CI (PR) / checks`); conferir no main pós-merge: `importMap.js` com handler, `CampaignNewsletterForm.tsx`/`CampaignStorySection.tsx` presentes, migration do S9 presente.
5. **Deploy (caminho B — runner #113 ainda não instalado)** — `ssh homeserver 'bash ~/teqo-deploy/scripts/deploy-homeserver.sh <sha-do-main>'` (o script re-pointa o origin do workspace para o GitHub, guard de HEAD, flock, build migrator → migrate (no-op) → build runner → up → healthcheck → smoke). Executado pelo agente via SSH (acesso validado: `ssh homeserver` ok, docker ok, envs em `~/stack/teqo-1313.env`), com o humano acompanhando; alternativa: humano roda o mesmo comando. ~15–20 min.
6. **Verificação pós-deploy** — (a) `docker inspect` revision label == sha deployado; (b) `docker logs teqo-1313 --since <start>` sem `getFromImportMap` para o handler (aceite #3 da intenção); (c) `/admin` de prod renderiza o formulário de login no browser (Playwright + screenshot → `docs/plans/ops73-deploy-admin-prod-fixed-evidence.png`); (d) home pública mantém seção "Nossa história" (S8) e form de novidades (S9); (e) **editor loga e abre uma coleção — passo manual do humano** (credenciais de prod não ficam com o agente).
7. **Fechamento** — changelog `docs/changelog/2026-08-20-ops73.md` + `pnpm changelog:build`; comentários na Issue #114 com evidência; reforçar no AGENTS.md a lição do S9 (regen sem envs re-órfão o handler) e a necessidade do guard #87; registrar débito da divergência de plataformas (workspace homeserver aponta para Forgejo local; S10 re-home pendente — S10 NÃO está em prod nem no GitHub main, e sua Issue está in-prod no tracker).
## Rabbit holes / Não escopo (engenharia)
- **Arqueologia do rewrite do main (S10 force-push/rebase sobre o Forgejo):** a divergência das plataformas está documentada acima; reconstituir quem-mergeou-onde não muda o plano. Corte: verificação por conteúdo (API + árvore), não por linhagem.
- **Re-home do S10 (pixel):** S10 não está em prod (8c734ba só tem o `facebookPixel.ts` antigo do petition pages, `be1106f8`) nem no GitHub main — é PR próprio, fora deste escopo; registrar como débito.
- **Instalar o runner (#113)/deploy automático:** fora de escopo (a intenção já corta; caminho B é o que desbloqueia hoje).
- **Guard de CI do importMap (#87):** segue pendente; este incidente (regen do S9) é a segunda ocorrência da mesma classe — reforça a prioridade, não muda o escopo.
## Riscos e mitigação
- **Main do GitHub anda durante o trabalho (agentes paralelos S10/S11):** re-verificar `git ls-remote origin main` antes do cherry-pick, antes do push e antes do deploy (o guard do script re-checa). Se o main mudar, rebase e re-valida.
- **Conflito no cherry-pick além do importMap (page.tsx, Contact.ts, etc.):** base do S8/S9 = 693d2e48 (comum aos dois mains); o delta GitHub (OPS69/OPS71/plans) não toca os arquivos do S9 exceto importMap — conflitos esperados só no importMap; resolver mantendo o handler.
- **Deploy falhar no meio:** rollback automático do script (restore do compose + up) + runbook `docs/ops/teqo-1313-deploy.md`; zero migrations novas → janela de rollback limpa.
- **Admin continuar branco pós-deploy (fix insuficiente):** verificação por browser na fase 6 é o gate; se persistir, o erro no log apontará o próximo passo (o importMap está correto no main — cenário improvável, mas o log decide).
- **Screenshot de evidência sem ferramenta de visão:** usar Playwright (DOM: inputs/buttons > 0) + `design-vision` para descrever a imagem.
## Aceite de engenharia
- [ ] Aceite de produto da intenção coberto: `/admin` renderiza login em prod; log sem o erro do handler; editor loga e abre coleção (humano)
- [ ] Sem regressão: S8/S9 presentes no main do GitHub e servidos em prod (história + newsletter na home)
- [ ] Invariantes AGENTS/engineering-standards: nada de schema/Consent/UI novo; importMap é arquivo gerado e commitado; gates verdes
- [ ] Prova de consistência do importMap: regen com envs `S3_*` dummy → diff vazio
- [ ] Changelog + Issue #114 documentada com evidência (antes/depois); débitos registrados (S10 re-home, guard #87 reforçado, workspace homeserver origin)

View file

@ -0,0 +1,74 @@
# Admin do Payload segue em branco em produção — deploy do fix OPS69 + verificação pós-deploy
Status: rascunho
Atualizado em: 2026-08-19
Issue: #114
Priority: P1
Model: composer-2.5
Impeccable: A — sem mudança de UI (deploy do que já está em main)
Rascunho UI: N/A
Appetite: ~0,5 dia — deploy + verificação funcional; um outcome verificável
Responsável: —
## Intenção
O time editorial e de campanha está **sem CMS**: `/admin` em produção (jorgesolla1313.com.br) abre em branco (tela preta) — a página carrega, o shell hidrata, mas o formulário de login nunca renderiza. O bug raiz já foi diagnosticado e corrigido em `main` pelo **OPS69** (Issue #77, fechada): o `importMap` commitado ficou sem a entrada `@payloadcms/storage-s3/client#S3ClientUploadHandler` e o admin não monta as views quando o plugin `s3Storage` está ativo (envs `S3_*` de produção). O fix está em main desde `7162c65e` (2026-08-19).
**Mas produção nunca recebeu o fix.** O container roda um build de 2026-08-18 21:49 (`e6bd1d26`), anterior ao merge: o deploy automático antigo morreu (#85 OPS70 — runs agendados skipped) e o caminho novo (dispatch manual do `deploy.yml` no GitHub Actions) depende do runner self-hosted no homeserver, que ainda não está instalado (#113 OPS71-INFRA). A verificação pós-deploy do OPS69 (aceite do próprio plano) nunca rodou.
Verificado ao vivo em 2026-08-19: `/admin` de produção renderiza o shell vazio — **0 inputs, 0 botões, body sem texto** — mesmo sintoma do diagnóstico OPS69. `main` no GitHub hoje (`f5862c9b`) contém o fix.
## Persona e fluxo
- **Persona / contexto:** editor de conteúdo do mandato/campanha (staff que alimenta notícias e campanhas pelo Painel de Controle), bloqueado há ~1 dia.
- **Job principal:** abrir o admin, logar e voltar a editar publicações — sem depender de infra nova.
- **Fluxo desejado:** abre `jorgesolla1313.com.br/admin` → formulário de login aparece (não mais tela preta) → credenciais → dashboard com as coleções → consegue abrir uma coleção.
- **Anti-goals de produto:** não rediagnosticar o bug (OPS69 já fechou o diagnóstico com evidência); não reabrir a Issue #77; não entregar nenhuma mudança de código nova — só publicar o que já está aprovado em main.
## Objetivo e aceite
- `/admin` em produção renderiza o formulário de login para quem tem credencial (tela preta some).
- O container de produção roda uma revisão que contém o fix do importMap (`7162c65e` ou posterior — HEAD atual `f5862c9b`).
- O log do container de produção não re-emite `getFromImportMap: PayloadComponent not found in importMap` para o handler do storage.
- Um editor loga e abre uma coleção sem editar nada (smoke mínimo de leitura).
- Guardrail: nada de schema/migration/Consent/UI nesta entrega — é o deploy do que já está em `main`.
## Dados (intenção)
- **Vou apresentar dados?** Não — a verificação é funcional (render do login + log do container), nenhum número novo.
## Direção no codebase (hipótese)
- **Áreas prováveis:** `scripts/deploy-homeserver.sh` (o script de deploy — fonte da verdade do fluxo, roda no homeserver), `.github/workflows/deploy.yml` (dispatch manual `verify` → `deploy`), runbook `docs/ops/teqo-1313-deploy.md`, `src/app/(payload)/admin/importMap.js` (o fix já commitado — conferir a revision do container, não o arquivo).
- **Precedente a olhar:** OPS69 (`docs/plans/ops69-admin-branco-importmap-s3*.md`, #77) — aceite de verificação pós-deploy pendente; OPS71 (cutover do deploy) e #113 (runner); OPS66 (ordem migrate→build já resolvida no script).
- **Risco de acoplamento:** o deploy de `main` leva junto outras mudanças já aprovadas (ex.: S7, cards da home) — esperado e desejado (deploy do HEAD); o smoke do script cobre `/`, `/campanha/login`, `/admin`; rollback = restore do compose (runbook).
## Dependências
- Nenhuma dura. #113 (runner self-hosted) é o habilitador do caminho A, não pré-requisito — o caminho B desbloqueia sem ele.
## Fora de escopo
- Rediagnóstico do bug e guard de CI do importMap — #87 OPS70 já na fila (P2).
- Instalação do runner self-hosted do GitHub — #113 (Issue própria).
- Deploy contínuo automático — decisão OPS71 é deploy manual; #85 OPS70 (pipeline antigo) parece obsoleto pós-cutover — sugestão de fechamento à parte, não escopo daqui.
## Rabbit holes de produto
- **"A tela preta voltou → reabrir o diagnóstico."** Se alguém só completar o desejo: regressar ao OPS69. **Corte neste item:** conferir a revision do container antes de qualquer diagnóstico — a evidência (build de 2026-08-18 sem o fix, main com o fix desde 2026-08-19) já explica o sintoma.
- **"Já que vou mexer, instalo o runner/automatizo tudo."** **Corte:** #113 (runner) e #87 (guard) são Issues separadas; este item entrega admin no ar.
## Questões em aberto (produto)
- **Caminho do deploy?** **Opções:** A) dispatch manual do `deploy.yml` no GitHub Actions (verify full ~50 min → deploy no runner do homeserver — exige instalar o runner, #113, antes); B) executar `scripts/deploy-homeserver.sh <sha>` direto no homeserver (mesmo script idempotente que o runner rodaria; smoke + rollback documentados; não espera #113); C) instalar o runner primeiro e depois A. **Recomendação:** **B agora** — admin caído = CMS indisponível; o script é o mesmo e o runbook cobre smoke e rollback; depois, o primeiro dispatch via A valida o pipeline OPS71. _(assumido — validar com produto)_
- **Prioridade?** **Opções:** P0 (mesma dor do incidente original) | P1 (o fix já existe em main; resta entrega + verificação). **Recomendação:** **P1** — claimável já, sem precisar de re-P0 da fila. _(assumido — validar com produto)_
- **Quem toca o homeserver?** O agente não tem acesso ao homeserver nem ao GitHub para dispatch sem ação do operador. **Opções:** o executor prepara os comandos do runbook e o humano roda (2 passos, ~5 min) | o executor dispara via GitHub API (`GITHUB_TOKEN` disponível) quando o runner estiver em pé (#113). **Recomendação:** humana para o caminho B imediato; o agente executa o restante (conferir SHA, verificar `/admin` no browser, checar log) e registra a evidência. _(assumido — validar com produto)_
## Referências
- OPS69: `docs/plans/ops69-admin-branco-importmap-s3.md` + `-impl.md`, Issue #77 (fechada — fix `7162c65e` em main)
- Runbook de deploy: `docs/ops/teqo-1313-deploy.md` (smoke, rollback, falhas conhecidas)
- OPS71 cutover: `docs/plans/ops71-ci-github-actions-tracker-forgejo.md`; runner: #113; guard de importMap: #87; pipeline antigo morto: #85
- Evidência (gate): `docs/plans/ops73-deploy-admin-prod-evidence.png` — `/admin` de produção em 2026-08-19 (shell vazio, sem login)
![Admin em branco em produção — 2026-08-19](ops73-deploy-admin-prod-evidence.png)

View file

@ -0,0 +1,42 @@
# Follow-up pós-OPS73 — re-home S10 no GitHub, origin do workspace homeserver + fix da race de seedUser do e2e
Status: rascunho
Atualizado em: 2026-08-20
Issue: OPS73-FOLLOWUP (depends #114)
Intenção: Origem — débitos colhidos no fechamento do OPS73 (#114)
Appetite: ~0,4 dia (fase 1 dominante)
## Contexto
O OPS73 revelou que o cutover OPS71 **divergiu as plataformas**: o Forgejo main congelou em `e339ceec` (com S8/S9/S10) e o GitHub main recebeu OPS69/OPS71/OPS74 — as features S8/S9 foram re-homadas no OPS73 (PR #751), mas o **S10 (pixel Meta) não**. Além disso, duas pendências de infra/teste apareceram no ciclo.
## Já resolvido (não reabrir)
- Re-home do S8 (488dcf7d) e S9 (8d2352c0) no GitHub main → **PR #751** (merged `624126da`/rebase `dba75ff8`), admin fix + deploy verificado.
- ImportMap com handler S3 no main (regen com envs dummy — diff vazio).
## Fases (mesma família: infra/deploy pós-cutover)
### F1 — Re-home do S10 (pixel Meta) no GitHub main + deploy
O S10 está `done`+`in-prod` no tracker (#94, PR #109 merged Forgejo `e339ceec`), mas o código **não está no GitHub main nem em prod** (a revision deployada `dba75ff8` não tem o pixel; `8c734ba` só tem o `facebookPixel.ts` antigo do petition, `be1106f8`). Cherry-pick do commit S10 (`e339ceec`) sobre o main do GitHub (mesma mecânica do OPS73), revisar a migration `20260819_213947_add_site_settings_facebook_pixel_id` (aditiva), rodar gates + e2e e deployar. Verificar `/campanha` com `SiteSettings.facebookPixelId` configurado → `#meta-pixel-*` presente.
### F2 — Apontar o origin do workspace do homeserver para o GitHub
`~/teqo-deploy` ainda tem `origin = http://localhost:3000/fsolla/teqo.git` (Forgejo local). O `deploy-homeserver.sh` já re-aponta para `TEQO_REPO_URL` (https://github.com/fsolla/teqo.git) a cada deploy (idempotente), mas o estado depois de um deploy devia ficar GitHub. Ação: `git -C ~/teqo-deploy remote set-url origin https://github.com/fsolla/teqo.git`. Sem code change.
### F3 — Race de `seedTestUser` no e2e full do GitHub CI
`tests/helpers/seedUser.ts` faz delete-then-create de `dev@payloadcms.com`. Com a suíte full no CI do GitHub (4 workers paralelos; `frontend`, `admin` e `campaignNewsletter` specs chamam `seedTestUser`), dois workers podem intercalar `delete`/`create` → `duplicate key value violates unique constraint "users_email_idx"` reportado como `ValidationError: email`. Mitigar: `on conflict` na criação ou serializar o seed (blocking lock), ou uma chamada com retry. Impacto: é o 2º sinal do e2e full (novo no pipeline GitHub); o deploy `verify` roda full e pode flakear por isso.
## Rabit holes / Explicitamente fora
- **Não** reabrir o diagnóstico do admin (OPS73 fechou).
- **Não** "re-homar" o re-home do S8/S9 de novo (já em main).
- Guard #87 (importMap sem envs S3\_) é Issue própria (já absorve a lição do S9); não duplicar aqui.
## Aceite
- S10 rodando em prod (pixel presente) e no GitHub main.
- `~/teqo-deploy` com origin = GitHub após a mudança.
- e2e full do GitHub CI estável no `seedTestUser` (sem flake de duplicate email).

View file

@ -6,5 +6,30 @@
"url": "https://design.penpot.app/mcp/stream?userToken={file:.opencode/secrets/penpot-token}",
"enabled": true
}
},
"provider": {
"cheapest-inference": {
"npm": "@ai-sdk/openai-compatible",
"name": "Cheapest Inference",
"options": {
"baseURL": "https://api.cheapestinference.com/v1"
},
"models": {
"deepseek-v4-flash": {
"name": "DeepSeek V4 Flash",
"limit": {
"context": 1000000,
"output": 384000
}
},
"mimo-v2.5": {
"name": "MiMo v2.5",
"limit": {
"context": 1000000,
"output": 128000
}
}
}
}
}
}

View file

@ -1,155 +0,0 @@
#!/usr/bin/env node
/**
* ci-classify-production — OPS65 production-change gate for the main CI
* window. Classifies whether the merge head differs from the deployed
* artifact in anything that would enter the docker build context.
*
* The `.dockerignore` is the source of truth (the Dockerfile does `COPY . .`):
* a changed path is production when it is NOT excluded by the ignore
* patterns. The gate is FAIL-OPEN toward production: dispatch (explicit
* operator intent) and an unknown deployed SHA always run the full suite;
* a classification ERROR (git/.dockerignore unreadable, no input) prints
* production=true AND exits 1, so the workflow run turns red — the suite
* still runs (fail-open) but the deploy never happens silently. A green run
* only ever means "the gate could classify and there is nothing to do".
*
* Plain Node, stdlib only — the gate must stay cheap (no pnpm install).
*
* node scripts/ci-classify-production.mjs --deployed <sha> [--value]
* node scripts/ci-classify-production.mjs --files-from <file> [--value]
*
* Output: JSON { production, reason, changed, kept, skipped } on stdout
* (`--value` prints only `true`/`false`). Exit 0 = classified; exit 1 =
* could not classify (production=true printed, run should go red).
*/
import { execFileSync } from 'node:child_process'
import { readFileSync } from 'node:fs'
import { pathToFileURL } from 'node:url'
import { isDockerignored, parseDockerignore } from './lib/dockerignore.mjs'
const parseArgs = (argv) => {
const flags = {}
for (let index = 2; index < argv.length; index += 1) {
const arg = argv[index]
if (arg.startsWith('--')) {
const name = arg.slice(2).replace(/-([a-z])/g, (_, char) => char.toUpperCase())
const next = argv[index + 1]
flags[name] = typeof next === 'string' && !next.startsWith('--') ? next : true
if (typeof next === 'string' && !next.startsWith('--')) index += 1
}
}
return flags
}
const flags = parseArgs(process.argv)
const deployed = flags.deployed
const filesFrom = flags.filesFrom
const valueOnly = flags.value === true
/**
* @param {string[]} changed - repo-relative changed paths
* @param {import('./lib/dockerignore.mjs').DockerignoreRule[]} patterns -
* .dockerignore rules
* @returns {{ production: boolean, kept: string[], skipped: string[] }}
*/
export function classifyProduction(changed, patterns) {
const kept = []
const skipped = []
for (const path of changed) {
if (path.length === 0) continue
if (isDockerignored(path, patterns)) skipped.push(path)
else kept.push(path)
}
return { production: kept.length > 0, kept, skipped }
}
const report = (production, reason, extra = {}) => {
const result = { production, reason, ...extra }
if (valueOnly) {
console.log(production)
return
}
console.log(JSON.stringify(result))
}
/**
* Could not classify at all (git/.dockerignore unreadable, no input): run
* the full pipeline AND make the run red — a gate that cannot classify must
* never look like "nothing to do".
*/
const failOpenRed = (reason) => {
report(true, reason, { changed: [], kept: [], skipped: [] })
process.exitCode = 1
}
// Import-safe: the module is unit-tested (classifyProduction), so the CLI
// body only runs when executed as a script.
const isCli =
typeof process.argv[1] === 'string' && import.meta.url === pathToFileURL(process.argv[1]).href
if (isCli) {
main()
}
function main() {
if (process.env.GITHUB_EVENT_NAME === 'workflow_dispatch') {
report(true, 'workflow_dispatch (intenção explícita)')
return
}
let changed
try {
if (filesFrom) {
changed = readFileSync(filesFrom, 'utf8').split('\n').filter(Boolean)
} else if (deployed === '') {
// The workflow always passes --deployed (possibly empty: no container
// revision on the homeserver). An empty deployed SHA is a legitimate
// "unknown deployed state" — run the full pipeline, keep the run green.
report(true, 'sem SHA deployado (estado desconhecido)', {
changed: [],
kept: [],
skipped: [],
})
return
} else if (deployed === true) {
failOpenRed('--deployed sem valor')
return
} else if (deployed) {
changed = execFileSync('git', ['diff', '--name-only', deployed, 'HEAD', '--'], {
encoding: 'utf8',
})
.split('\n')
.filter(Boolean)
} else {
failOpenRed('sem SHA deployado nem files-from')
return
}
} catch (error) {
failOpenRed(`erro ao obter o diff (${error instanceof Error ? error.message : error})`)
return
}
if (changed.length === 0) {
report(false, 'diff vazio (nada a fazer)', {
changed: [],
kept: [],
skipped: [],
})
return
}
let rules
try {
rules = parseDockerignore(readFileSync('.dockerignore', 'utf8'))
} catch (error) {
failOpenRed(`.dockerignore ilegível (${error instanceof Error ? error.message : error})`)
return
}
const classified = classifyProduction(changed, rules)
report(
classified.production,
classified.production ? 'mudança de produção desde o último deploy' : 'sem mudança de produção',
{ changed, kept: classified.kept, skipped: classified.skipped },
)
}

View file

@ -1,38 +1,45 @@
/**
* Drift repair for branch protection (main-only) — Forgejo. Idempotent: reads
* the existing rules and creates / updates / no-ops (never duplicates).
* Drift repair for `main` branch protection — GitHub (OPS71). Idempotent:
* reads the existing rule and creates / updates / no-ops (never duplicates).
*
* pnpm configure:branch-protection # apply
* pnpm configure:branch-protection -- --dry-run
* GITHUB_TOKEN=<PAT> pnpm configure:branch-protection # apply
* GITHUB_TOKEN=<PAT> pnpm configure:branch-protection -- --dry-run
*
* main: required status check `CI (PR) / checks*` (the ci-pr cascade rollup —
* OPS61: Forgejo matches the contexts as globs against the real status
* contexts like `CI (PR) / checks (pull_request)`, so the old literal `checks`
* would match nothing), strict=false, 0 reviews. Feature PRs auto-merge when
* that context is green; the server rule is the final defense against merges
* before the cascade settles (the waitForChecks rollup gate is the client
* half).
* (migration-lock removed 2026-08-12 — see ci-pr.yml header.)
* Desired rule: required check-run `CI (PR) / checks` (the ci-pr cascade —
* OPS62), strict=false, 0 reviews, `enforce_admins: true` (nobody, admin
* included, merges with a red required check). Feature PRs auto-merge when
* that check is green; the server rule is the final defense against every
* merge path, manual API merges included.
*/
import { parseArgs } from './lib/agent-forgejo.mjs'
import { DESIRED_RULE, planBranchProtectionRule } from './lib/branch-protection.mjs'
import { createApi } from './lib/forgejo-api.mjs'
import { createApi } from './lib/github-api.mjs'
import { DESIRED_RULE, planBranchProtectionRule } from './lib/github-branch-protection.mjs'
const { flags } = parseArgs(process.argv.slice(2), new Set())
const dryRun = flags['dry-run'] === true
const parseArgs = (argv) => {
const flags = {}
for (let index = 2; index < argv.length; index += 1) {
const arg = argv[index]
if (arg.startsWith('--')) {
const name = arg.slice(2).replace(/-([a-z])/g, (_, char) => char.toUpperCase())
const next = argv[index + 1]
flags[name] = typeof next === 'string' && !next.startsWith('--') ? next : true
if (typeof next === 'string' && !next.startsWith('--')) index += 1
}
}
return flags
}
const flags = parseArgs(process.argv)
const dryRun = flags.dryRun === true
const branch = 'main'
const api = createApi({})
const repo = process.env.FORGEJO_REPOSITORY ?? process.env.GITHUB_REPOSITORY ?? 'fsolla/teqo'
const ruleName = DESIRED_RULE.rule_name
const existingRules = await api.listBranchProtections()
const existing =
existingRules.find((rule) => rule.rule_name === ruleName || rule.branch_name === ruleName) ?? null
const repo = process.env.GITHUB_REPOSITORY ?? 'fsolla/teqo'
const existing = await api.getBranchProtection(branch)
const { action } = planBranchProtectionRule(existing)
console.log(
`[configure:branch-protection] repo ${repo}${dryRun ? ' (dry-run)' : ''} — regra "${ruleName}": ${
`[configure:branch-protection] repo ${repo}${dryRun ? ' (dry-run)' : ''} — regra "${branch}": ${
action === 'noop'
? 'já conforme — nada a fazer'
: action === 'update'
@ -42,34 +49,26 @@ console.log(
)
if (action === 'noop') {
console.log(`[configure:branch-protection] no-op: ${JSON.stringify(DESIRED_RULE)}`)
console.log(`[configure:branch-protection] no-op: ${JSON.stringify(existing)}`)
process.exit(0)
}
const payload = { ...DESIRED_RULE, branch_name: ruleName }
const target = action === 'create' ? '/branch_protections' : `/branch_protections/${ruleName}`
const method = action === 'create' ? 'POST' : 'PATCH'
if (dryRun) {
console.log(`[dry-run] ${method} /repos/${repo}${target} ${JSON.stringify(payload)}`)
console.log(
`[dry-run] PUT /repos/${repo}/branches/${branch}/protection ${JSON.stringify(DESIRED_RULE)}`,
)
process.exit(0)
}
if (action === 'create') {
await api.updateBranchProtection(ruleName, payload)
} else {
await api.editBranchProtection(ruleName, payload)
}
await api.updateBranchProtection(DESIRED_RULE, branch)
const after = await api.listBranchProtections()
const applied =
after.find((rule) => rule.rule_name === ruleName || rule.branch_name === ruleName) ?? null
if (!applied) {
const after = await api.getBranchProtection(branch)
if (!after) {
console.error(
`[configure:branch-protection] FALHOU: ${method} ${target} respondeu mas a regra "${ruleName}" não aparece no servidor.`,
`[configure:branch-protection] FALHOU: PUT respondeu mas a regra "${branch}" não aparece no servidor.`,
)
process.exit(1)
}
console.log(
`[configure:branch-protection] done — regra verificada no servidor: ${JSON.stringify(applied)}`,
`[configure:branch-protection] done — regra verificada no servidor: ${JSON.stringify(after)}`,
)

View file

@ -2,8 +2,11 @@
#
# Deploy teqo-1313 to the homeserver (OPS53).
#
# Runs ON the homeserver, piped by the CI deploy job:
# ssh homeserver "bash -s -- <commit-sha>" < scripts/deploy-homeserver.sh
# OPS71: runs ON the homeserver via the GitHub self-hosted runner (deploy job
# of .github/workflows/deploy.yml):
# bash scripts/deploy-homeserver.sh <commit-sha>
# (The Forgejo-era invocation `ssh homeserver "bash -s -- <sha>" < script`
# is gone — no SSH hop, no workstation involvement.)
#
# Flow: HEAD guard (only the current main HEAD deploys) -> flock
# serialization -> workspace fetch at <sha> -> docker login (local registry)
@ -17,14 +20,16 @@
# the running site untouched and the job red.
#
# Environment defaults assume the homeserver layout: stack under
# $HOME/stack, repo cloned from the local Forgejo. Secrets are sourced from
# the chmod-600 env files and never echoed (no `set -x`, passwords only via
# --password-stdin / build secrets).
# $HOME/stack, repo cloned from github.com/fsolla/teqo (public — no
# credentials; the pre-OPS71 clone pointed at the local Forgejo and is
# re-pointed idempotently below). Secrets are sourced from the chmod-600 env
# files and never echoed (no `set -x`, passwords only via --password-stdin /
# build secrets).
set -euo pipefail
SHA="${1:?usage: deploy-homeserver.sh <commit-sha>}"
TEQO_REPO_URL="${TEQO_REPO_URL:-http://localhost:3000/fsolla/teqo.git}"
TEQO_REPO_URL="${TEQO_REPO_URL:-https://github.com/fsolla/teqo.git}"
STACK_DIR="${STACK_DIR:-$HOME/stack}"
WORKSPACE_DIR="${WORKSPACE_DIR:-$HOME/teqo-deploy}"
DEPLOY_LOCK="${DEPLOY_LOCK:-/tmp/teqo-1313-deploy.lock}"
@ -88,6 +93,14 @@ if [ ! -d "$WORKSPACE_DIR/.git" ]; then
say "cloning $TEQO_REPO_URL into $WORKSPACE_DIR"
git clone "$TEQO_REPO_URL" "$WORKSPACE_DIR"
else
# OPS71: the pre-cutover clone pointed at the local Forgejo — re-point
# idempotently so the HEAD guard and the fetch compare against the repo
# that actually received the merge.
current_url="$(git -C "$WORKSPACE_DIR" remote get-url origin 2>/dev/null || true)"
if [ -n "$current_url" ] && [ "$current_url" != "$TEQO_REPO_URL" ]; then
say "updating workspace origin: $current_url → $TEQO_REPO_URL"
git -C "$WORKSPACE_DIR" remote set-url origin "$TEQO_REPO_URL"
fi
git -C "$WORKSPACE_DIR" fetch origin main
fi
git -C "$WORKSPACE_DIR" checkout --detach "$SHA" || fatal "checkout of $SHA failed"

View file

@ -1,32 +0,0 @@
/**
* Plain-Node CLI (no pnpm) — dispatch a Forgejo workflow via workflow_dispatch.
* Used by CI jobs (requeue) and humans.
*
* node scripts/forgejo-dispatch.mjs <workflow-file> <ref> [key=value …]
*/
import { createApi } from './lib/forgejo-api.mjs'
const [, , workflowFile, ref = 'main', ...inputPairs] = process.argv
if (!workflowFile) {
console.error('Usage: node scripts/forgejo-dispatch.mjs <workflow-file> [ref] [key=value …]')
process.exit(1)
}
const inputs = {}
for (const pair of inputPairs) {
const eq = pair.indexOf('=')
if (eq <= 0) continue
inputs[pair.slice(0, eq)] = pair.slice(eq + 1)
}
const api = createApi({})
try {
await api.workflowDispatch(workflowFile, { ref, inputs })
console.log(
`[forgejo-dispatch] ${workflowFile}@${ref} enviado${Object.keys(inputs).length > 0 ? ` (${Object.keys(inputs).join(', ')})` : ''}`,
)
} catch (error) {
console.error(`[forgejo-dispatch] falhou: ${error instanceof Error ? error.message : error}`)
process.exit(1)
}

View file

@ -2,8 +2,12 @@
* Plain-Node CLI (no pnpm) — on merge to main, flip Issues cited as
* `Closes #N` / `Fixes #N` in the PR body to `done` + `in-prod` and comment.
* Replacement for the `gh issue edit/comment` loop of issue-done-on-main-merge.
* The PR body comes from the API (Forgejo's PR webhook payload is not relied
* on), so the workflow only needs the PR number.
*
* OPS71: the PR now lives on GitHub. The workflow passes the PR body via the
* `PR_BODY` env (from the GitHub event payload) and the script flips Issues
* on the Forgejo tracker by API — no Forgejo PR read when PR_BODY is present.
* Without PR_BODY the script falls back to reading the PR from the Forgejo
* API (the Forgejo-era workflows stay alive until the OPS71 Fase 2 removal).
*
* OPS61: a failed flip exits 1 — the flip is the whole purpose of this
* workflow, and the 403 era proved that a swallowed error ends the job
@ -11,6 +15,7 @@
* (the loop does not abort), but the job goes red so the breakage is visible.
*
* node scripts/forgejo-issue-transition.mjs --pr <N>
* PR_BODY='Closes #97' node scripts/forgejo-issue-transition.mjs --pr <N>
*/
import { createApi } from './lib/forgejo-api.mjs'
@ -37,7 +42,11 @@ if (!Number.isInteger(prNumber) || prNumber <= 0) {
}
const api = createApi({})
const pr = await api.getPullRequest(prNumber)
const bodyFromEnv = process.env.PR_BODY
const pr =
typeof bodyFromEnv === 'string'
? { merged: true, body: bodyFromEnv }
: await api.getPullRequest(prNumber)
if (!pr) {
console.error(`[forgejo-issue-transition] PR #${prNumber} não encontrada`)
process.exit(1)
@ -62,9 +71,12 @@ for (const number of numbers) {
await api.setLabels(number, { add: ['done', 'in-prod'], remove: ['in-progress'] })
await api.addComment(
number,
`Merged em main via PR #${prNumber} — \`done\` + \`in-prod\`. Deploy de produção segue o verificador \`ci.yml\` (gated).`,
`Merged em main via PR #${prNumber} — \`done\` + \`in-prod\` + Issue fechada. Deploy de produção é manual (workflow_dispatch no GitHub Actions) — ver docs/AGENT-OPS.md.`,
)
console.log(`[forgejo-issue-transition] #${number}: done + in-prod`)
// OPS71-FLIP: o PR vive no GitHub e não fecha a Issue no Forgejo como o
// merge nativo fazia na era Forgejo — o close é explícito aqui.
await api.closeIssue(number)
console.log(`[forgejo-issue-transition] #${number}: done + in-prod + fechada`)
} catch (error) {
flipFailed = true
console.error(

View file

@ -1,92 +0,0 @@
/**
* Plain-Node CLI (no pnpm) — PR safety net: wait for checks and merge by
* rebase (the Forgejo equivalent of `gh pr merge --auto --rebase`, which the
* Forgejo API does not offer as a scheduled operation). Idempotent: already
* merged → no-op success. The verdict comes from re-reading the PR (Forgejo's
* merge endpoint answers 200 + empty body on success, so the POST response
* alone cannot prove the merge) — "já mergeado" is only printed for a PR the
* server actually reports as merged; a failed merge exits 1 loudly.
*
* Draft policy (OPS57): only `cursor/*` heads get marked ready. A draft from
* any other branch is the actor's veto — the CLI skips it (exit 0) so the
* workflow never forces a human/plans PR to ready.
*
* node scripts/forgejo-pr-automerge.mjs --pr <N>
*/
import { createApi } from './lib/forgejo-api.mjs'
const parseArgs = (argv) => {
const flags = {}
for (let index = 2; index < argv.length; index += 1) {
const arg = argv[index]
if (arg.startsWith('--')) {
const name = arg.slice(2)
const next = argv[index + 1]
flags[name] = typeof next === 'string' && !next.startsWith('--') ? next : true
if (typeof next === 'string' && !next.startsWith('--')) index += 1
}
}
return flags
}
const flags = parseArgs(process.argv)
const prNumber = Number(flags.pr)
if (!Number.isInteger(prNumber) || prNumber <= 0) {
console.error('Usage: node scripts/forgejo-pr-automerge.mjs --pr <N>')
process.exit(1)
}
const api = createApi({})
try {
const pr = await api.getPullRequest(prNumber)
if (!pr || pr.state !== 'OPEN') {
console.log(
`[forgejo-pr-automerge] PR #${prNumber} não está aberto (${pr?.state ?? 'inexistente'}) — skip`,
)
process.exit(0)
}
if (pr.base.ref !== 'main') {
console.log(`[forgejo-pr-automerge] PR #${prNumber} base é ${pr.base.ref} (não main) — skip`)
process.exit(0)
}
if (pr.isDraft) {
if (!pr.head.ref.startsWith('cursor/')) {
console.log(
`[forgejo-pr-automerge] PR #${prNumber} é draft fora de cursor/* — skip (veto do ator)`,
)
process.exit(0)
}
console.log(`[forgejo-pr-automerge] PR #${prNumber} é draft — marcando Ready`)
await api.markPullRequestReady(prNumber)
} else {
console.log(`[forgejo-pr-automerge] PR #${prNumber} já Ready`)
}
const {
attempted,
merged,
pr: after,
} = await api.autoMerge(prNumber, {
// OPS61: the wait now covers the whole CI (PR) cascade (the `checks`
// rollup only posts after every ci-pr job), so the 30 min default was too
// tight under load — e2e shards + int + build run before the rollup.
timeoutMs: 45 * 60 * 1000,
log: (line) => console.log(`[forgejo-pr-automerge] ${line}`),
})
if (merged) {
console.log(
attempted
? `[forgejo-pr-automerge] PR #${prNumber} mergeado (rebase)`
: `[forgejo-pr-automerge] PR #${prNumber} já mergeado`,
)
} else {
console.log(
after?.state === 'CLOSED'
? `[forgejo-pr-automerge] PR #${prNumber} fechado sem merge — skip`
: `[forgejo-pr-automerge] PR #${prNumber} voltou a draft — skip (veto do ator)`,
)
}
} catch (error) {
console.error(`[forgejo-pr-automerge] falhou: ${error instanceof Error ? error.message : error}`)
process.exit(1)
}

View file

@ -1,9 +1,11 @@
#!/usr/bin/env node
/**
* Local mirror of `.forgejo/workflows/ci-pr.yml` (serial): phase-1 cheap checks,
* Local mirror of `.github/workflows/ci-pr.yml` (serial): phase-1 cheap checks,
* then phase-2 expensive (int/build). e2e is NOT part of the local mirror
* (OPS59): it runs once, in CI (PR job `e2e` → `checks`; main `ci.yml` e2e →
* `checks` → `deploy`). Local optional feedback: `pnpm test:e2e:affected`.
* (OPS59 + OPS72): the PR CI runs only the e2e blast radius (mode `selected`,
* never `full`), the deploy `verify` job runs the full suite before
* publishing, and the local affected run is a skill step (discretionary):
* `pnpm test:e2e:affected`.
* Docs guards (OPS63): the `docs-guards` checks (changelog append-only,
* aggregate sync, conflict markers) also run here — cheap git diffs, same
* scripts the CI job runs (the `changelog-rewrite:` escape stays CI-only,
@ -77,7 +79,7 @@ const main = async () => {
const scope = readJsonScript('scripts/ci-scope.mjs')
console.log(
`[gate:ci] scope base=${scope.base} code=${scope.code.mode} build=${scope.build.mode} test=${scope.test.mode} e2e=${scope.e2e.mode} (e2e runs in CI, not in this gate — OPS59)`,
`[gate:ci] scope base=${scope.base} code=${scope.code.mode} build=${scope.build.mode} test=${scope.test.mode} e2e=${scope.e2e.mode} (e2e not in this gate — OPS72; local affected is a skill step)`,
)
// --- Phase 1 (cheap) ---
@ -152,14 +154,18 @@ const main = async () => {
if (scope.e2e.mode === 'none') {
console.log('\n[gate:ci] ⊘ e2e: CI will skip (no e2e blast radius)')
} else if (scope.e2e.mode === 'selected') {
console.log(
`\n[gate:ci] ▶ e2e: CI runs the blast radius (${scope.e2e.specs.join(', ')}) — not in this gate (OPS72); full lives in deploy verify`,
)
} else {
const specs =
scope.e2e.mode === 'full' ? 'full suite' : `selected: ${scope.e2e.specs.join(', ')}`
console.log(`\n[gate:ci] ▶ e2e: runs in CI (job e2e, ${specs}) — not in this gate (OPS59)`)
console.log(
'\n[gate:ci] ▶ e2e: diff is high-risk (full mode) — no e2e in this PR by design (OPS72); run `pnpm test:e2e:affected` locally; deploy verify runs full before publishing',
)
}
console.log(
'\n[gate:ci] ✓ all checks passed (ci-pr mirror: docs guards local — OPS63; e2e verified in CI)',
'\n[gate:ci] ✓ all checks passed (ci-pr mirror: docs guards local — OPS63; e2e: local affected is a skill step — OPS72)',
)
}

View file

@ -0,0 +1,86 @@
/**
* Plain-Node CLI (no pnpm) — GitHub PR safety net: mark `cursor/*` drafts
* ready and arm GitHub's native auto-merge (rebase). Runs in
* .github/workflows/agent-pr-ready-automerge.yml. The auto-merge arming uses
* the AUTOMERGE_PAT (real user PAT) — never the built-in GITHUB_TOKEN, whose
* actor (github-actions[bot]) would merge without creating workflow runs for
* the `closed` event (OPS71-FLIP; anti-recursion — silent broken flips).
* Mark-ready keeps the run token (nothing consumes its event).
*
* Unlike the Forgejo-era automerger (removed in OPS71 Fase 2), there is NO
* poll loop: `enablePullRequestAutoMerge` is server-side — GitHub only merges
* when every required check (incl. `CI (PR) / checks`) is green and the PR is
* mergeable. The OPS64 "never trust the rollup" pin is structural here: a
* single sequential job posts one honest check-run, and the server enforces
* it. Draft policy (OPS57) preserved: only `cursor/*` heads are marked
* ready — any other draft is the actor's veto (skip, exit 0).
*
* Idempotent: already merged / closed / non-main base → no-op success.
* A GraphQL error (e.g. repo auto-merge disabled, PR in conflict) exits 1 so
* the job goes red and the situation is visible.
*
* node scripts/github-pr-automerge.mjs --pr <N>
*/
import { createApi } from './lib/github-api.mjs'
import { automergeArmingToken, decideAutomergeAction } from './lib/github-pr-flow.mjs'
const parseArgs = (argv) => {
const flags = {}
for (let index = 2; index < argv.length; index += 1) {
const arg = argv[index]
if (arg.startsWith('--')) {
const name = arg.slice(2)
const next = argv[index + 1]
flags[name] = typeof next === 'string' && !next.startsWith('--') ? next : true
if (typeof next === 'string' && !next.startsWith('--')) index += 1
}
}
return flags
}
const flags = parseArgs(process.argv)
const prNumber = Number(flags.pr)
if (!Number.isInteger(prNumber) || prNumber <= 0) {
console.error('Usage: node scripts/github-pr-automerge.mjs --pr <N>')
process.exit(1)
}
const api = createApi({})
// OPS71-FLIP: the native auto-merge MUST be armed with a real user PAT, not
// the built-in GITHUB_TOKEN. A merge performed through the GITHUB_TOKEN actor
// (github-actions[bot]) never creates workflow runs for its events (GitHub
// anti-recursion — the `pull_request` `closed` is recorded in the timeline
// but no run is created), so the post-merge flips would silently never run
// (live finding: PR #746). With a PAT the merge is attributed to its owner (a
// real user) and the `closed` event creates runs again (PR #742 behavior).
// Fail-closed: no AUTOMERGE_PAT → exit 1 — never fall back to GITHUB_TOKEN.
const arming = automergeArmingToken(process.env)
if (!arming.ok) {
console.error(`[github-pr-automerge] ${arming.reason}`)
process.exit(1)
}
const automergeApi = createApi({ token: arming.token })
try {
const pr = await api.getPullRequest(prNumber)
const verdict = decideAutomergeAction(pr)
if (verdict.action === 'skip') {
console.log(`[github-pr-automerge] PR #${prNumber}: skip (${verdict.reason})`)
process.exit(0)
}
if (verdict.action === 'mark-ready') {
console.log(`[github-pr-automerge] PR #${prNumber} é draft cursor/* — marcando Ready`)
await api.markPullRequestReady(prNumber)
}
console.log(`[github-pr-automerge] PR #${prNumber}: armando auto-merge (rebase)`)
await automergeApi.enableAutoMerge(pr.nodeId)
console.log(
`[github-pr-automerge] PR #${prNumber}: auto-merge armado — o GitHub mergea quando o required check "CI (PR) / checks" fica verde`,
)
} catch (error) {
console.error(`[github-pr-automerge] falhou: ${error instanceof Error ? error.message : error}`)
process.exit(1)
}

64
scripts/github-pr.mjs Normal file
View file

@ -0,0 +1,64 @@
/**
* Plain-Node CLI (no pnpm) — create a GitHub PR (Ready, never draft) for
* agent deliveries. Replaces the Forgejo MCP `create_pull_request` in the
* delivery flow (OPS71: PRs live on GitHub; the tracker stays on Forgejo).
*
* GITHUB_TOKEN=<PAT> node scripts/github-pr.mjs \
* --head <branch> --title "<título>" --body-file <path>
* GITHUB_TOKEN=<PAT> node scripts/github-pr.mjs --head <b> --title <t> --body "Closes #N"
*
* The repo rule "PRs are Ready, never draft" is structural: there is no
* `--draft` flag. Base defaults to `main`.
*/
import { createApi } from './lib/github-api.mjs'
const parseArgs = (argv) => {
const flags = {}
for (let index = 2; index < argv.length; index += 1) {
const arg = argv[index]
if (arg.startsWith('--')) {
const name = arg.slice(2)
const next = argv[index + 1]
flags[name] = typeof next === 'string' && !next.startsWith('--') ? next : true
if (typeof next === 'string' && !next.startsWith('--')) index += 1
}
}
return flags
}
const flags = parseArgs(process.argv)
const head = flags.head
const title = flags.title
const base = flags.base ?? 'main'
if (!head || !title) {
console.error(
'Usage: node scripts/github-pr.mjs --head <branch> --title <t> [--base main] --body "<text>" | --body-file <path>',
)
process.exit(1)
}
let body = flags.body
if (flags['body-file']) {
try {
const { readFileSync } = await import('node:fs')
body = readFileSync(flags['body-file'], 'utf8')
} catch (error) {
console.error(`[github-pr] não consegui ler --body-file: ${error.message}`)
process.exit(1)
}
}
if (typeof body !== 'string' || body.length === 0) {
console.error('[github-pr] body vazio — passe --body ou --body-file')
process.exit(1)
}
const api = createApi({})
try {
const pr = await api.createPullRequest({ head, base, title, body })
console.log(`[github-pr] PR #${pr.number} criado (Ready): ${pr.htmlUrl}`)
} catch (error) {
console.error(`[github-pr] falhou: ${error instanceof Error ? error.message : error}`)
process.exit(1)
}

View file

@ -1,60 +0,0 @@
/**
* Pure decision for the main branch protection rule (OPS61).
*
* The desired rule is the repo contract: main requires the CI (PR) cascade
* rollup as the only status check (`strict=false`, 0 reviews). The context is
* a glob on purpose — Forgejo matches `status_check_contexts` with gobwas/glob
* against the real commit-status contexts (`CI (PR) / checks (pull_request)` —
* the ` (event)` suffix is part of the context), so the literal `checks` would
* match nothing and block every merge.
*/
export const DESIRED_RULE = Object.freeze({
rule_name: 'main',
enable_status_check: true,
status_check_contexts: ['CI (PR) / checks*'],
enable_push: false,
required_approvals: 0,
dismiss_stale_approvals: false,
})
const COMPARED_FIELDS = [
'rule_name',
'enable_status_check',
'status_check_contexts',
'enable_push',
'required_approvals',
'dismiss_stale_approvals',
]
const sameStrings = (have, want) =>
have.length === want.length &&
want.every((value) => have.includes(value)) &&
have.every((value) => want.includes(value))
/**
* True when the existing rule (from the API, already normalized) matches the
* desired rule on every compared field — array fields as sets.
*/
export const ruleMatches = (existing, desired = DESIRED_RULE) => {
if (!existing) return false
return COMPARED_FIELDS.every((field) => {
const want = desired[field]
const have = existing[field]
if (Array.isArray(want)) return Array.isArray(have) && sameStrings(have, want)
return have === want
})
}
/**
* What to do with the rule: `create` (no rule), `update` (rule exists with
* drift), `noop` (already compliant). Idempotent re-runs land on `noop`.
*
* @param {object|null} existing - normalized rule from the API, or null
* @param {object} [desired]
* @returns {{ action: 'create' | 'update' | 'noop' }}
*/
export const planBranchProtectionRule = (existing, desired = DESIRED_RULE) => {
if (ruleMatches(existing, desired)) return { action: 'noop' }
return { action: existing ? 'update' : 'create' }
}

View file

@ -1,106 +0,0 @@
/**
* dockerignore match — pure `.dockerignore` semantics (moby patternmatcher
* subset) for the CI production-change gate (OPS65). No git, no fs, no deps.
*
* The gate classifies "production change" by asking: would this path enter the
* docker build context? The `.dockerignore` is the source of truth for the
* artifact (the Dockerfile does `COPY . .`), so a path is production when it
* is NOT excluded by the ignore patterns.
*
* Implemented rules (moby semantics):
* - blank lines and `#` comments are ignored
* - `!` prefixes a negation (re-include); last match wins
* - trailing `/` is a directory pattern (the dir itself and its tree)
* - leading `/` anchors the pattern to the context root
* - a pattern with no other `/` matches the basename at ANY depth
* - any matched path also excludes everything under it (the builder prunes
* at the matched directory, so `docs` excludes `docs/x.md`)
* - `*` matches any run of non-separator chars, `?` a single one
* - `**` matches any number of directories (globstar)
*/
/** A compiled ignore rule: regex against the cleaned repo-relative path. */
export class DockerignoreRule {
/**
* @param {RegExp} regex - compiled matcher
* @param {boolean} negate - `!` prefix: re-include when matched
* @param {string} source - original pattern text (diagnostics)
*/
constructor(regex, negate, source) {
this.regex = regex
this.negate = negate
this.source = source
}
}
/**
* @param {string} pattern - one `.dockerignore` line (no trailing newline)
* @returns {DockerignoreRule}
*/
export function compileDockerignoreRule(pattern) {
const source = pattern
let negate = false
if (pattern.startsWith('!')) {
negate = true
pattern = pattern.slice(1)
}
if (pattern.endsWith('/')) pattern = pattern.slice(0, -1)
const anchored = pattern.startsWith('/')
if (anchored) pattern = pattern.slice(1)
let body = ''
for (let index = 0; index < pattern.length; index += 1) {
const char = pattern[index]
if (char === '*') {
if (pattern[index + 1] === '*') {
index += 1
if (pattern[index + 1] === '/') {
body += '(?:.*/)?'
index += 1
} else {
body += '.*'
}
} else {
body += '[^/]*'
}
} else if (char === '?') {
body += '[^/]'
} else if ('\\^$.|+()[]{}'.includes(char)) {
body += `\\${char}`
} else {
body += char
}
}
const anyDepth = !anchored && !pattern.includes('/') ? '(?:.*/)?' : ''
const subtree = '(?:/.*)?'
const regexSource = body.length > 0 ? `^${anyDepth}${body}${subtree}$` : '$^'
return new DockerignoreRule(new RegExp(regexSource), negate, source)
}
/**
* @param {string} text - full `.dockerignore` content
* @returns {DockerignoreRule[]} rules in order (last match wins)
*/
export function parseDockerignore(text) {
return text
.split('\n')
.map((line) => line.trim())
.filter((line) => line.length > 0 && !line.startsWith('#'))
.map(compileDockerignoreRule)
}
/**
* Last-match-wins exclusion test for one repo-relative path.
*
* @param {string} path - repo-relative, forward slashes (e.g. "src/app/x.ts")
* @param {DockerignoreRule[]} rules - from parseDockerignore
* @returns {boolean} true when the path is excluded from the build context
*/
export function isDockerignored(path, rules) {
let ignored = false
for (const rule of rules) {
if (rule.regex.test(path)) ignored = !rule.negate
}
return ignored
}

View file

@ -12,8 +12,12 @@
* that rejects (network-level `fetch failed`, DNS/TCP/reset) retries on ANY
* method; a 5xx (502/503/504 — Cloudflare blips) retries on GET only, so the
* safety net's long `waitForChecks` poll survives, while write endpoints keep
* failing closed instead of risking duplicated side effects. 4xx never
* retries. Defaults: 3 retries, base 300 ms ×2, ±20% jitter.
* failing closed instead of risking duplicated side effects. 410 Gone
* (OPS71-FLIP): served by the reverse proxy (nginx/Cloudflare) BEFORE the
* request reaches the app — the origin never saw it, so retrying is safe on
* ANY method (unlike a 5xx, where the origin may have applied the write).
* 4xx never retries (410 is the one documented proxy-level exception).
* Defaults: 3 retries, base 300 ms ×2, ±20% jitter.
*
* Shapes are normalized to the GitHub-flavored contract the agent scripts and
* their unit specs already use: `issue.state` ∈ OPEN|CLOSED,
@ -24,6 +28,10 @@ const DEFAULT_BASE_URL = 'https://git.solla.dev/api/v1'
const DEFAULT_REPOSITORY = 'fsolla/teqo'
const USER_AGENT = 'teqo-agent-scripts/1.0 (forgejo)'
const RETRYABLE_STATUSES = new Set([502, 503, 504])
// 410 Gone from the reverse proxy (OPS71-FLIP live finding: nginx answered
// `GET /repos/fsolla/teqo/issues/97/labels` with 410, transient). The origin
// never received the request — retrying is side-effect-free on ANY method.
const RETRYABLE_BEFORE_ORIGIN = new Set([410])
/**
* @typedef {object} ForgejoApiOptions
@ -124,6 +132,11 @@ export const createApi = ({
await retryAfter(attempt, `HTTP ${response.status}`)
continue
}
if (!isLastAttempt && RETRYABLE_BEFORE_ORIGIN.has(response.status)) {
await response.body?.cancel()
await retryAfter(attempt, `HTTP ${response.status} (proxy — origem intocada)`)
continue
}
let text
try {
text = await response.text()
@ -133,7 +146,14 @@ export const createApi = ({
continue
}
if (!response.ok) {
throw new Error(`Forgejo API ${method} ${path} → ${response.status}: ${text.slice(0, 400)}`)
const isProxyHtml = /^\s*</.test(text)
const snippet = isProxyHtml
? `corpo não-JSON (proxy/nginx — HTTP ${response.status}): ${text
.replace(/\s+/g, ' ')
.trim()
.slice(0, 120)}`
: text.slice(0, 400)
throw new Error(`Forgejo API ${method} ${path} → ${response.status}: ${snippet}`)
}
if (!text) return null
try {
@ -214,6 +234,18 @@ export const createApi = ({
body: { body },
}),
/**
* PATCH state=closed — closes the issue on the tracker. Forgejo-era: the
* merge of the PR closed the issue natively. GitHub-era (OPS71): the PR
* lives on GitHub and never touches the Forgejo tracker — the flip must
* close explicitly, else every merged issue stays OPEN with done+in-prod.
*/
closeIssue: (number) =>
request(`/repos/${owner}/${name}/issues/${number}`, {
method: 'PATCH',
body: { state: 'closed' },
}),
listIssueComments: async (number) => {
const comments = await request(`/repos/${owner}/${name}/issues/${number}/comments`)
return (Array.isArray(comments) ? comments : []).map((comment) => ({

264
scripts/lib/github-api.mjs Normal file
View file

@ -0,0 +1,264 @@
/**
* GitHub REST + GraphQL client for the CI/PR scripts (`github-pr*`, branch
* protection). Zero dependencies — plain Node `fetch` — so it also runs
* inside GitHub Actions jobs without `pnpm install`.
*
* Auth: `GITHUB_TOKEN` (PAT locally; the built-in Actions token in
* workflows). Base URL: `GITHUB_API_URL`, else `https://api.github.com`.
*
* Retry (same policy as forgejo-api OPS67): transient failures retry with
* exponential backoff — a fetch that rejects (network-level, DNS/TCP/reset)
* retries on ANY method; a 5xx retries on GET only, so read polls survive
* while write endpoints keep failing closed instead of risking duplicated
* side effects. 4xx never retries. Defaults: 3 retries, base 300 ms ×2,
* ±20% jitter.
*
* Shapes are normalized to the contract the CLI scripts use: `pr.state` ∈
* OPEN|CLOSED, `pr.draft`, `pr.nodeId` (GraphQL auto-merge), branch
* protection normalized for drift comparison.
*/
const DEFAULT_BASE_URL = 'https://api.github.com'
const DEFAULT_GRAPHQL_URL = 'https://api.github.com/graphql'
const DEFAULT_REPOSITORY = 'fsolla/teqo'
const USER_AGENT = 'teqo-agent-scripts/1.0 (github)'
const RETRYABLE_STATUSES = new Set([502, 503, 504])
/**
* @typedef {object} GithubApiOptions
* @property {string} [base]
* @property {string} [graphqlBase]
* @property {string} [token]
* @property {string} [repository]
* @property {(input: string | URL, init?: { method?: string; headers?: Record<string, string>; body?: string }) => Promise<Response>} [fetchImpl]
* @property {number} [retries] Additional attempts after the first (total = retries + 1). Default 3.
* @property {number} [backoffMs] Base exponential delay. Default 300.
* @property {boolean} [jitter] ±20% randomization on each delay. Default true.
* @property {(ms: number) => Promise<void>} [sleepImpl] Test seam for the backoff delay.
*/
/**
* @param {GithubApiOptions} [options]
*/
export const createApi = ({
base,
graphqlBase,
token,
repository,
fetchImpl,
retries = 3,
backoffMs = 300,
jitter = true,
sleepImpl = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
} = {}) => {
const baseUrl = (base ?? process.env.GITHUB_API_URL ?? DEFAULT_BASE_URL).replace(/\/+$/, '')
const graphqlUrl = (graphqlBase ?? process.env.GITHUB_GRAPHQL_URL ?? DEFAULT_GRAPHQL_URL).replace(
/\/+$/,
'',
)
const repo = repository ?? process.env.GITHUB_REPOSITORY ?? DEFAULT_REPOSITORY
const fetcher = fetchImpl ?? fetch
const [owner, name] = repo.split('/')
if (retries < 0 || backoffMs < 0) {
throw new Error(
`Retry inválido: retries=${retries}, backoffMs=${backoffMs} — ambos devem ser >= 0`,
)
}
const attemptCount = retries + 1
const backoffDelay = (attempt) => {
const base = backoffMs * 2 ** (attempt - 1)
return jitter ? Math.round(base * (0.8 + Math.random() * 0.4)) : base
}
const warnRetry = (path, method, reason, attempt, delay) =>
console.warn(
`[github-api] ${method} ${path} falhou (tentativa ${attempt}/${attemptCount}): ${reason} — retry em ${delay}ms`,
)
const headers = (withJson = true) => {
const authToken = token ?? process.env.GITHUB_TOKEN
if (!authToken) {
throw new Error('Sem token para a API do GitHub — defina GITHUB_TOKEN.')
}
return {
Authorization: `Bearer ${authToken}`,
'User-Agent': USER_AGENT,
...(withJson ? { 'Content-Type': 'application/json' } : {}),
}
}
const request = async (path, { method = 'GET', body, query } = {}) => {
const qs = query
? '?' +
Object.entries(query)
.filter(([, value]) => value !== undefined && value !== null)
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`)
.join('&')
: ''
const url = `${baseUrl}${path}${qs}`
const retryAfter = async (attempt, reason) => {
const delay = backoffDelay(attempt)
warnRetry(path, method, reason, attempt, delay)
await sleepImpl(delay)
}
for (let attempt = 1; attempt <= attemptCount; attempt += 1) {
const isLastAttempt = attempt === attemptCount
let response
try {
response = await fetcher(url, {
method,
headers: headers(),
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
})
} catch (error) {
if (isLastAttempt) throw error
await retryAfter(attempt, error.message)
continue
}
if (method === 'GET' && !isLastAttempt && RETRYABLE_STATUSES.has(response.status)) {
await response.body?.cancel()
await retryAfter(attempt, `HTTP ${response.status}`)
continue
}
let text
try {
text = await response.text()
} catch (error) {
if (isLastAttempt || method !== 'GET') throw error
await retryAfter(attempt, error.message)
continue
}
if (response.status === 404 && method === 'GET') return null
if (!response.ok) {
throw new Error(`GitHub API ${method} ${path} → ${response.status}: ${text.slice(0, 400)}`)
}
if (!text) return null
try {
return JSON.parse(text)
} catch {
throw new Error(`GitHub API ${method} ${path}: resposta não-JSON: ${text.slice(0, 80)}`)
}
}
}
const graphql = async (query, variables) => {
let response
try {
response = await fetcher(graphqlUrl, {
method: 'POST',
headers: headers(),
body: JSON.stringify({ query, variables }),
})
} catch (error) {
throw new Error(`GitHub GraphQL falhou (rede): ${error.message}`)
}
const text = await response.text()
if (!response.ok) {
throw new Error(`GitHub GraphQL → ${response.status}: ${text.slice(0, 400)}`)
}
const parsed = JSON.parse(text)
const firstError = parsed?.errors?.[0]
if (firstError) {
throw new Error(`GitHub GraphQL: ${firstError.message}`)
}
return parsed.data ?? null
}
const normalizeState = (state) => (String(state).toLowerCase() === 'closed' ? 'CLOSED' : 'OPEN')
const normalizePullRequest = (pr) => ({
number: pr.number,
title: pr.title,
body: pr.body ?? '',
state: normalizeState(pr.state),
merged: Boolean(pr.merged_at || pr.merged),
draft: Boolean(pr.draft),
mergeable: pr.mergeable ?? null,
nodeId: pr.node_id ?? '',
head: { ref: pr.head?.ref ?? '', sha: pr.head?.sha ?? '' },
base: { ref: pr.base?.ref ?? '' },
})
const api = {
/**
* GET /repos/{owner}/{repo}/pulls/{number} — normalized.
* @param {number} number
*/
getPullRequest: async (number) => {
const pr = await request(`/repos/${owner}/${name}/pulls/${number}`)
return pr ? normalizePullRequest(pr) : null
},
/** PATCH draft:false — marks a draft PR ready for review. */
markPullRequestReady: (number) =>
request(`/repos/${owner}/${name}/pulls/${number}`, {
method: 'PATCH',
body: { draft: false },
}),
/** POST /pulls — creates a PR (never draft). */
createPullRequest: async ({ head, base = 'main', title, body }) => {
const pr = await request(`/repos/${owner}/${name}/pulls`, {
method: 'POST',
body: { head, base, title, body },
})
return { number: pr.number, title: pr.title, htmlUrl: pr.html_url }
},
/**
* Enables GitHub's native auto-merge (GraphQL) with the repo's canonical
* merge style (rebase). The server waits for the required checks — a PR
* with a red `CI (PR) / checks` can never auto-merge.
*/
enableAutoMerge: async (nodeId, mergeMethod = 'REBASE') => {
await graphql(
`
mutation EnablePullRequestAutoMerge($id: ID!, $mergeMethod: PullRequestMergeMethod!) {
enablePullRequestAutoMerge(input: { pullRequestId: $id, mergeMethod: $mergeMethod }) {
pullRequest {
number
}
}
}
`,
{ id: nodeId, mergeMethod },
)
},
/**
* GET /repos/{owner}/{repo}/branches/{branch}/protection — normalized for
* drift comparison; `null` when the branch has no protection rule (404).
*/
getBranchProtection: async (branch = 'main') => {
const rule = await request(`/repos/${owner}/${name}/branches/${branch}/protection`)
if (!rule) return null
return {
required_status_checks: rule.required_status_checks
? {
strict: Boolean(rule.required_status_checks.strict),
contexts: (rule.required_status_checks.checks ?? []).map((entry) => entry.context),
}
: null,
enforce_admins: Boolean(rule.enforce_admins?.enabled ?? rule.enforce_admins),
required_pull_request_reviews: rule.required_pull_request_reviews ?? null,
}
},
/**
* PUT /repos/{owner}/{repo}/branches/{branch}/protection — replaces the
* whole rule (GitHub replace semantics: omitted protections are removed).
*/
updateBranchProtection: (payload, branch = 'main') =>
request(`/repos/${owner}/${name}/branches/${branch}/protection`, {
method: 'PUT',
body: payload,
}),
/** PATCH /repos/{owner}/{repo} — repo settings (e.g. allow_auto_merge). */
updateRepository: (payload) =>
request(`/repos/${owner}/${name}`, { method: 'PATCH', body: payload }),
}
return api
}

View file

@ -0,0 +1,78 @@
/**
* Pure decision for the GitHub `main` branch protection rule (OPS71).
*
* The desired rule is the repo contract: main requires the ci-pr cascade
* check-run (OPS62) as the only status check (strict=false, 0 reviews) and
* `enforce_admins: true` so even repo admins cannot merge with a red required
* check (the rule-based protection of the Forgejo era applied to everyone —
* preserved).
*
* The auto-merge safety net never merges on red CI by construction (GitHub's
* server-side guarantee), but the branch protection rule is the final defense
* against every other merge path (manual API merges included).
*
* Context literal (verified live on PR #742, 2026-08-19): GitHub's
* required-check matching uses the CHECK-RUN NAME — the bare JOB name
* (`checks`), NOT the UI display `<workflow name> / <job name>`
* (`CI (PR) / checks`). The latter never matches and leaves the PR
* mergeable_state "blocked" forever. The UI still shows
* `CI (PR) / checks`; the rule literal is `checks`.
*/
export const REQUIRED_CHECK_CONTEXT = 'checks'
/**
* GitHub PUT /branches/{branch}/protection replace-semantics payload. Only
* `checks` (modern) is sent — `contexts` is legacy-derived and GitHub's API
* rejects the two together (422 anyOf). The GET normalization reads the
* check-run contexts from `checks`, so drift comparison is unaffected.
*/
export const DESIRED_RULE = Object.freeze({
required_status_checks: {
strict: false,
checks: [{ context: REQUIRED_CHECK_CONTEXT }],
},
enforce_admins: true,
required_pull_request_reviews: null,
restrictions: null,
required_linear_history: false,
allow_force_pushes: false,
allow_deletions: false,
block_creations: false,
})
const sameStrings = (have, want) =>
have.length === want.length &&
want.every((value) => have.includes(value)) &&
have.every((value) => want.includes(value))
/**
* True when the existing rule (from `getBranchProtection`, already
* normalized) matches the desired rule on every compared field — array
* fields as sets.
*/
export const ruleMatches = (existing) => {
if (!existing) return false
const wantStatus = DESIRED_RULE.required_status_checks
if (existing.enforce_admins !== DESIRED_RULE.enforce_admins) return false
if (existing.required_pull_request_reviews !== null) return false
const haveStatus = existing.required_status_checks
if (!haveStatus) return false
if (haveStatus.strict !== wantStatus.strict) return false
return sameStrings(
haveStatus.contexts,
wantStatus.checks.map((entry) => entry.context),
)
}
/**
* What to do with the rule: `create` (no rule), `update` (rule exists with
* drift), `noop` (already compliant). Idempotent re-runs land on `noop`.
*
* @param {object|null} existing - normalized rule from the API, or null
* @returns {{ action: 'create' | 'update' | 'noop' }}
*/
export const planBranchProtectionRule = (existing) => {
if (ruleMatches(existing)) return { action: 'noop' }
return { action: existing ? 'update' : 'create' }
}

View file

@ -0,0 +1,52 @@
/**
* Pure decision logic for the GitHub PR auto-merge safety net
* (scripts/github-pr-automerge.mjs). Unit-tested in
* tests/unit/githubPrFlow.unit.spec.ts — the OPS57 (draft veto) / OPS64
* (never merge on red CI) pins ported to the GitHub mechanism.
*
* GitHub's native auto-merge only fires with the required checks green, so
* the safety net's only real decisions are: skip (non-main base, draft veto,
* already merged) or mark-ready (cursor/* draft) before arming auto-merge.
*/
export const CURSOR_HEAD_PREFIX = 'cursor/'
/** OPS57: only `cursor/*` drafts get force-marked ready — any other draft is the actor's veto. */
export const isCursorHead = (ref) => typeof ref === 'string' && ref.startsWith(CURSOR_HEAD_PREFIX)
/**
* OPS71-FLIP: the auto-merge MUST be armed with a real user PAT. Arming with
* the built-in GITHUB_TOKEN makes the merge happen as `github-actions[bot]`,
* and GitHub's anti-recursion never creates workflow runs for events
* triggered by GITHUB_TOKEN actions — the post-merge `closed` flips would
* silently never run (live finding PR #746). Fail-closed: no token → ok:false;
* the CLI exits 1 instead of falling back to the run token.
*
* @param {{ AUTOMERGE_PAT?: string }} [env]
* @returns {{ ok: true, token: string } | { ok: false, reason: string }}
*/
export const automergeArmingToken = (env = process.env) => {
const token = env.AUTOMERGE_PAT
if (typeof token === 'string' && token.length > 0) return { ok: true, token }
return {
ok: false,
reason:
'AUTOMERGE_PAT ausente — sem ele o merge aconteceria como github-actions[bot] e o evento closed não criaria runs (flips pós-merge silenciosos). Defina o secret AUTOMERGE_PAT no GitHub (PAT do fsolla, escopos pull-requests: write + contents: write no repo) — ver docs/AGENT-OPS.md.',
}
}
/**
* @param {object|null} pr - normalized PR from github-api (or null)
* @returns {{ action: 'skip' | 'mark-ready' | 'enable-auto-merge', reason: string }}
*/
export const decideAutomergeAction = (pr) => {
if (!pr) return { action: 'skip', reason: 'pr-inexistente' }
if (pr.merged) return { action: 'skip', reason: 'ja-mergeada' }
if (pr.state !== 'OPEN') return { action: 'skip', reason: 'pr-nao-aberta' }
if (pr.base?.ref !== 'main') return { action: 'skip', reason: 'base-nao-main' }
if (pr.draft) {
if (isCursorHead(pr.head?.ref)) return { action: 'mark-ready', reason: 'draft-cursor' }
return { action: 'skip', reason: 'draft-veto' }
}
return { action: 'enable-auto-merge', reason: 'ready' }
}

View file

@ -29,8 +29,6 @@ export const HIGH_RISK_EXACT = new Set([
'scripts/e2e-affected.mjs',
'scripts/run-e2e-affected.mjs',
'scripts/ci-scope.mjs',
'scripts/ci-classify-production.mjs',
'scripts/lib/dockerignore.mjs',
'scripts/check-test-locations.mjs',
'scripts/check-plans-only-pr-closes.mjs',
'scripts/lib/plansOnlyClosesGuard.mjs',

View file

@ -199,6 +199,7 @@ export default async function CampaignActionWizardPage({
municipalityName={municipality.name}
municipalitySlug={municipality.slug}
returnPath={returnPath}
currentUser={{ id: user.id, name: user.name }}
/>
)
}

View file

@ -5,12 +5,14 @@ import { revalidatePath } from 'next/cache'
import {
attachCampaignDemandReceiptRecord,
setCampaignDemandCost,
setCampaignDemandResponsibles,
transitionCampaignDemand,
updateCampaignDemand,
} from '@/app/(campaign)/campanha/actions/demand'
import {
FormDataBoundaryError,
nullableFormText,
repeatedRelationshipFormValues,
requiredFormText,
requiredRelationshipFormValue,
} from '@/lib/formData'
@ -19,6 +21,7 @@ import {
CAMPAIGN_DEMAND_EDIT_STAFF_MESSAGE,
CAMPAIGN_DEMAND_INVALID_STATUS_MESSAGE,
CAMPAIGN_DEMAND_RECEIPT_SAFE_MESSAGES,
CAMPAIGN_DEMAND_RESPONSIBLES_STAFF_MESSAGE,
CAMPAIGN_DEMAND_TRANSITION_SAFE_MESSAGES,
campaignDemandStatuses,
type CampaignDemandStatus,
@ -100,6 +103,23 @@ export const setDemandCostFormAction = async (
genericMessage: 'Não foi possível registrar o custo. Tente novamente.',
})
export const setDemandResponsiblesFormAction = async (
_state: CampaignFormActionState,
formData: FormData,
): Promise<CampaignFormActionState> =>
runCampaignFormAction({
execute: async () => {
await setCampaignDemandResponsibles({
id: requiredRelationshipFormValue(formData, 'demandId'),
responsibles: repeatedRelationshipFormValues(formData, 'responsibles'),
})
revalidatePath('/campanha/demandas/[slug]', 'page')
return { message: 'Responsáveis atualizados.' }
},
safeMessages: [CAMPAIGN_DEMAND_RESPONSIBLES_STAFF_MESSAGE],
genericMessage: 'Não foi possível atualizar os responsáveis. Tente novamente.',
})
export const attachDemandReceiptFormAction = async (
_state: CampaignFormActionState,
formData: FormData,

View file

@ -5,6 +5,7 @@ import { notFound } from 'next/navigation'
import { getPayload } from 'payload'
import { DemandDescriptionEditor } from '@/components/campaign/demand/DemandDescriptionEditor'
import { DemandResponsiblesCard } from '@/components/campaign/demand/DemandResponsiblesCard'
import { DemandWorkflowCard } from '@/components/campaign/demand/DemandWorkflowCard'
import { SetCampaignPageChrome } from '@/components/campaign/shell/CampaignPageChromeContext'
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
@ -22,6 +23,7 @@ import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
import {
attachDemandReceiptFormAction,
setDemandCostFormAction,
setDemandResponsiblesFormAction,
transitionDemandFormAction,
updateDemandFormAction,
} from './formActions'
@ -118,87 +120,106 @@ export default async function DemandDetailPage({ params }: DemandDetailPageProps
{dateTimeFormatter.format(new Date(demand.createdAt))}
</p>
<DemandDescriptionEditor
demandId={demand.id}
initialDescription={demand.description ?? ''}
formAction={updateDemandFormAction}
/>
<div className="grid items-start gap-4 lg:grid-cols-[minmax(0,2fr)_minmax(0,1fr)]">
<div className="flex min-w-0 flex-col gap-4">
<DemandDescriptionEditor
demandId={demand.id}
initialDescription={demand.description ?? ''}
formAction={updateDemandFormAction}
/>
{demand.decisionNote && (demand.status === 'aprovada' || demand.status === 'rejeitada') ? (
<section aria-label="Decisão" className="rounded-xl border p-4">
<h2 className="text-sm font-medium">Nota da decisão</h2>
<p className="mt-1 whitespace-pre-wrap text-sm text-muted-foreground">
{demand.decisionNote}
</p>
{demand.decidedAt ? (
<p className="mt-1 text-xs text-muted-foreground">
Decidida em {dateTimeFormatter.format(new Date(demand.decidedAt))}
</p>
{demand.decisionNote &&
(demand.status === 'aprovada' || demand.status === 'rejeitada') ? (
<section aria-label="Decisão" className="rounded-xl border p-4">
<h2 className="text-sm font-medium">Nota da decisão</h2>
<p className="mt-1 whitespace-pre-wrap text-sm text-muted-foreground">
{demand.decisionNote}
</p>
{demand.decidedAt ? (
<p className="mt-1 text-xs text-muted-foreground">
Decidida em {dateTimeFormatter.format(new Date(demand.decidedAt))}
</p>
) : null}
</section>
) : null}
</section>
) : null}
<DemandWorkflowCard
demandID={demand.id}
status={demand.status}
canDecideEscalated={isCampaignUnrestricted(user)}
currentCost={demand.cost}
transitionFormAction={transitionDemandFormAction}
costFormAction={setDemandCostFormAction}
receiptFormAction={attachDemandReceiptFormAction}
/>
<DemandWorkflowCard
demandID={demand.id}
status={demand.status}
canDecideEscalated={isCampaignUnrestricted(user)}
currentCost={demand.cost}
transitionFormAction={transitionDemandFormAction}
costFormAction={setDemandCostFormAction}
receiptFormAction={attachDemandReceiptFormAction}
/>
<section
aria-labelledby="demand-internal-title"
className="flex flex-col gap-3 rounded-xl border p-4"
>
<h2 id="demand-internal-title" className="text-base font-medium">
Controle interno
</h2>
<p className="text-sm text-muted-foreground">
Custo: {demand.cost != null ? currencyFormatter.format(demand.cost) : 'não registrado'}
</p>
{demand.receipts.length ? (
<ul className="flex flex-col gap-1">
{demand.receipts.map((receipt) => (
<li key={receipt.id} className="flex items-center gap-2 text-sm">
<PaperclipIcon className="size-4 text-muted-foreground" aria-hidden="true" />
{receipt.url ? (
<a
href={receipt.url}
target="_blank"
rel="noopener noreferrer"
className="text-primary underline-offset-4 hover:underline"
>
{receipt.filename ?? `Comprovante ${receipt.id}`}
</a>
) : (
<span>{receipt.filename ?? `Comprovante ${receipt.id}`}</span>
)}
</li>
))}
</ul>
) : (
<p className="text-sm text-muted-foreground">Nenhum comprovante anexado.</p>
)}
{demand.statusHistory.length ? (
<div className="flex flex-col gap-1">
<h3 className="text-sm font-medium">Histórico</h3>
<ul className="flex flex-col gap-1 text-sm text-muted-foreground">
{demand.statusHistory.map((entry, index) => (
<li key={`${entry.status}-${index}`}>
{campaignDemandStatusLabels[entry.status]}
{entry.authorName ? ` — ${entry.authorName}` : ''}
{entry.createdAt
? ` · ${dateTimeFormatter.format(new Date(entry.createdAt))}`
: ''}
{entry.note ? ` · ${entry.note}` : ''}
</li>
))}
</ul>
</div>
) : null}
</section>
<section
aria-labelledby="demand-internal-title"
className="flex flex-col gap-3 rounded-xl border p-4"
>
<h2 id="demand-internal-title" className="text-base font-medium">
Controle interno
</h2>
<p className="text-sm text-muted-foreground">
Visível para: <span className="font-medium">Responsáveis</span> ·{' '}
<span className="font-medium">Candidato</span> ·{' '}
<span className="font-medium">Coordenador</span>
</p>
<p className="text-sm text-muted-foreground">
Custo:{' '}
{demand.cost != null ? currencyFormatter.format(demand.cost) : 'não registrado'}
</p>
{demand.receipts.length ? (
<ul className="flex flex-col gap-1">
{demand.receipts.map((receipt) => (
<li key={receipt.id} className="flex items-center gap-2 text-sm">
<PaperclipIcon className="size-4 text-muted-foreground" aria-hidden="true" />
{receipt.url ? (
<a
href={receipt.url}
target="_blank"
rel="noopener noreferrer"
className="text-primary underline-offset-4 hover:underline"
>
{receipt.filename ?? `Comprovante ${receipt.id}`}
</a>
) : (
<span>{receipt.filename ?? `Comprovante ${receipt.id}`}</span>
)}
</li>
))}
</ul>
) : (
<p className="text-sm text-muted-foreground">Nenhum comprovante anexado.</p>
)}
{demand.statusHistory.length ? (
<div className="flex flex-col gap-1">
<h3 className="text-sm font-medium">Histórico</h3>
<ul className="flex flex-col gap-1 text-sm text-muted-foreground">
{demand.statusHistory.map((entry, index) => (
<li key={`${entry.status}-${index}`}>
{campaignDemandStatusLabels[entry.status]}
{entry.authorName ? ` — ${entry.authorName}` : ''}
{entry.createdAt
? ` · ${dateTimeFormatter.format(new Date(entry.createdAt))}`
: ''}
{entry.note ? ` · ${entry.note}` : ''}
</li>
))}
</ul>
</div>
) : null}
</section>
</div>
<DemandResponsiblesCard
demandId={demand.id}
municipalityId={demand.municipalityId > 0 ? demand.municipalityId : null}
creatorUserId={demand.createdBy}
initialResponsibles={demand.responsibles}
formAction={setDemandResponsiblesFormAction}
/>
</div>
</CampaignPageShell>
)
}

View file

@ -52,6 +52,7 @@ export default async function NewDemandPage({ searchParams }: NewDemandPageProps
municipalityOptions={municipalityOptions}
initialActivity={initialActivity}
initialMunicipalityId={initialMunicipalityId}
currentUser={{ id: user.id, name: user.name }}
searchActivities={searchDemandActivityOptions}
formAction={createDemandFormAction}
/>

View file

@ -0,0 +1,61 @@
'use server'
import config from '@payload-config'
import { getPayload } from 'payload'
import type { RelationOption } from '@/components/campaign/shared/RelationMultiSelect'
import { eligibleCampaignStaffWhere } from '@/utilities/campaignAccess'
import { getCampaignUser } from '@/utilities/campaignAuth'
import { CAMPAIGN_AUTH_REQUIRED_MESSAGE } from '@/utilities/campaignFormActionError'
import { loadCampaignUserSummaries } from '@/utilities/campaignRelationOptions'
const RESPONSIBLE_SEARCH_LIMIT = 20
/**
* C143 — candidate catalog for the demand responsible picker. Short queries
* (or empty) return the municipality's advisors as suggestions — a fill-in
* shortcut, never an automatic responsibility. Longer queries search staff by
* name. Both paths run access-scoped reads (`overrideAccess: false`), so the
* catalog only ever offers what the actor may already read.
*/
export const searchDemandResponsibleOptions = async (
query: string,
municipalityId: number | null,
): Promise<RelationOption[]> => {
const [payload, user] = await Promise.all([getPayload({ config }), getCampaignUser()])
if (!user) throw new Error(CAMPAIGN_AUTH_REQUIRED_MESSAGE)
if (query.trim().length < 2) {
if (!municipalityId) return []
const municipality = await payload.find({
collection: 'municipality',
where: { id: { equals: municipalityId } },
depth: 0,
limit: 1,
pagination: false,
select: { advisors: true },
user,
overrideAccess: false,
})
const advisors = (municipality.docs[0]?.advisors ?? [])
.map((advisor) => (typeof advisor === 'object' && advisor !== null ? advisor.id : advisor))
.filter((id): id is number => Number.isInteger(id))
return loadCampaignUserSummaries(payload, user, advisors)
}
const result = await payload.find({
collection: 'campaignUser',
where: {
and: [{ name: { contains: query.trim() } }, eligibleCampaignStaffWhere],
},
depth: 0,
limit: RESPONSIBLE_SEARCH_LIMIT,
pagination: false,
sort: 'name',
select: { name: true },
user,
overrideAccess: false,
})
return result.docs.map(({ id, name }) => ({ id, name }))
}

View file

@ -11,15 +11,18 @@ import {
CAMPAIGN_DEMAND_RECEIPT_SIZE_MESSAGE,
CAMPAIGN_DEMAND_RECEIPT_STAFF_MESSAGE,
CAMPAIGN_DEMAND_RECEIPT_TYPE_MESSAGE,
CAMPAIGN_DEMAND_RESPONSIBLES_STAFF_MESSAGE,
CAMPAIGN_DEMAND_TRANSITION_STAFF_MESSAGE,
} from '@/lib/schemas/campaignDemand'
import {
campaignDemandCostSchema,
campaignDemandCreateSchema,
campaignDemandResponsiblesSchema,
campaignDemandTransitionSchema,
campaignDemandUpdateSchema,
type CampaignDemandCostInput,
type CampaignDemandCreateInput,
type CampaignDemandResponsiblesInput,
type CampaignDemandTransitionInput,
type CampaignDemandUpdateInput,
} from '@/lib/schemas/campaignDemandInput'
@ -84,8 +87,9 @@ export const updateCampaignDemandRecord = async (
throw new Error(CAMPAIGN_DEMAND_EDIT_STAFF_MESSAGE)
}
// Access-scoped read (staff see only their municipalities). The AI call
// stays before the advisory lock, so the lock is never held open by it.
// Access-scoped read (C143: responsibles + unrestricted see the demand).
// The AI call stays before the advisory lock, so the lock is never held
// open by it.
const demand = await payload.findByID({
collection: 'campaignDemand',
id,
@ -256,6 +260,47 @@ export const attachCampaignDemandReceiptRecord = async (
)
}
export const setCampaignDemandResponsiblesRecord = async (
payload: Payload,
actor: CampaignUser,
input: CampaignDemandResponsiblesInput,
) => {
const { id, responsibles } = campaignDemandResponsiblesSchema.parse(input)
return withPayloadTransaction(
payload,
async ({ req }) => {
const currentActor = await reloadCampaignActor(payload, actor, req)
if (!isCampaignStaff(currentActor)) {
throw new Error(CAMPAIGN_DEMAND_RESPONSIBLES_STAFF_MESSAGE)
}
await acquireTextAdvisoryLocks(payload, req, [`campaign-demand:${id}`])
// Access-scoped read (responsibles + unrestricted see it) before the
// replace, so an advisor cannot manage a demand they cannot read.
await payload.findByID({
collection: 'campaignDemand',
id,
depth: 0,
select: { title: true },
user: currentActor,
overrideAccess: false,
req,
})
return payload.update({
collection: 'campaignDemand',
id,
data: { responsibles: [...new Set(responsibles)] },
depth: 0,
user: currentActor,
overrideAccess: false,
req,
})
},
{ beginFailureMessage: 'Não foi possível iniciar a atualização dos responsáveis.' },
)
}
export const createCampaignDemand = async (input: CampaignDemandCreateInput) => {
const { payload, actor } = await getCampaignActionContext()
return createCampaignDemandRecord(payload, actor, input)
@ -275,3 +320,8 @@ export const setCampaignDemandCost = async (input: CampaignDemandCostInput) => {
const { payload, actor } = await getCampaignActionContext()
return setCampaignDemandCostRecord(payload, actor, input)
}
export const setCampaignDemandResponsibles = async (input: CampaignDemandResponsiblesInput) => {
const { payload, actor } = await getCampaignActionContext()
return setCampaignDemandResponsiblesRecord(payload, actor, input)
}

View file

@ -0,0 +1,41 @@
import { REVALIDATE_SOCIAL_FEED_TAG } from '@/utilities/revalidateRequest'
import { isSameOriginRequest } from '@/utilities/sameOriginRequest'
import { INSTAGRAM_SYNC_TIMEOUT_MS, syncInstagramFeed } from '@/utilities/socialFeed/instagramSync'
import configPromise from '@payload-config'
import { revalidateTag } from 'next/cache'
import { NextResponse } from 'next/server'
import { getPayload } from 'payload'
/**
* "Tentar sincronizar de novo" — the admin status panel's retry button
* (`InstagramSyncStatusPanel`). Runs one Instagram sync from the persisted
* global settings, writes the outcome status, busts the `social-feed` cache
* so the public board reflects the new snapshot, and returns the status the
* panel renders immediately.
*
* Auth: admin session (the `payload-token` cookie via `payload.auth`, `users`
* collection only — same gate as the global's `read`) plus the same-origin
* CSRF guard every cookie-authenticated JSON route uses. The token and the
* Graph API are never reachable without an admin session.
*/
export async function POST(request: Request): Promise<NextResponse> {
if (!isSameOriginRequest(request)) {
return NextResponse.json({ ok: false, error: 'Forbidden' }, { status: 403 })
}
const payload = await getPayload({ config: configPromise })
const { user } = await payload.auth({ headers: request.headers })
if (!user || user.collection !== 'users') {
return NextResponse.json({ ok: false, error: 'Unauthorized' }, { status: 401 })
}
try {
const outcome = await syncInstagramFeed(payload, {
signal: AbortSignal.timeout(INSTAGRAM_SYNC_TIMEOUT_MS),
})
revalidateTag(REVALIDATE_SOCIAL_FEED_TAG)
return NextResponse.json({ ok: outcome.ok, status: outcome.status })
} catch {
return NextResponse.json({ ok: false, error: 'Falha interna ao sincronizar' }, { status: 500 })
}
}

View file

@ -34,7 +34,9 @@ import { ExportPreview as ExportPreview_cdf7e044479f899a31f804427d568b36 } from
import { ExportSaveButton as ExportSaveButton_cdf7e044479f899a31f804427d568b36 } from '@payloadcms/plugin-import-export/rsc'
import { ImportPreview as ImportPreview_cdf7e044479f899a31f804427d568b36 } from '@payloadcms/plugin-import-export/rsc'
import { ImportSaveButton as ImportSaveButton_cdf7e044479f899a31f804427d568b36 } from '@payloadcms/plugin-import-export/rsc'
import { InstagramSyncStatusPanel as InstagramSyncStatusPanel_90dd38ffecf41d93e389c788f98f48da } from '../../../components/admin/InstagramSyncStatusPanel'
import { InstagramPostExclusionPicker as InstagramPostExclusionPicker_ba03418db242c21470543bbeb06827a0 } from '../../../components/admin/InstagramPostExclusionPicker'
import { S3ClientUploadHandler as S3ClientUploadHandler_f97aa6c64367fa259c5bc0567239ef24 } from '@payloadcms/storage-s3/client'
import { ImportExportProvider as ImportExportProvider_cdf7e044479f899a31f804427d568b36 } from '@payloadcms/plugin-import-export/rsc'
import { CollectionCards as CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } from '@payloadcms/next/rsc'
@ -75,7 +77,9 @@ export const importMap = {
"@payloadcms/plugin-import-export/rsc#ExportSaveButton": ExportSaveButton_cdf7e044479f899a31f804427d568b36,
"@payloadcms/plugin-import-export/rsc#ImportPreview": ImportPreview_cdf7e044479f899a31f804427d568b36,
"@payloadcms/plugin-import-export/rsc#ImportSaveButton": ImportSaveButton_cdf7e044479f899a31f804427d568b36,
"./components/admin/InstagramSyncStatusPanel#InstagramSyncStatusPanel": InstagramSyncStatusPanel_90dd38ffecf41d93e389c788f98f48da,
"./components/admin/InstagramPostExclusionPicker#InstagramPostExclusionPicker": InstagramPostExclusionPicker_ba03418db242c21470543bbeb06827a0,
"@payloadcms/storage-s3/client#S3ClientUploadHandler": S3ClientUploadHandler_f97aa6c64367fa259c5bc0567239ef24,
"@payloadcms/plugin-import-export/rsc#ImportExportProvider": ImportExportProvider_cdf7e044479f899a31f804427d568b36,
"@payloadcms/next/rsc#CollectionCards": CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1
}

View file

@ -26,6 +26,7 @@ import {
canReadCampaignStaffField,
canSetCampaignSystemField,
canUpdateCampaignDemand,
eligibleCampaignStaffWhere,
isCampaignUnrestricted,
} from '@/utilities/campaignAccess'
import { systemStampedActorField } from '@/utilities/campaignAuditFields'
@ -41,6 +42,40 @@ const DEMAND_STATUS_OPTIONS = campaignDemandStatuses.map((value) => ({
label: campaignDemandStatusLabels[value],
}))
/**
* C143 — responsibles are always eligible staff (coordinator/advisor/candidate).
* The picker catalog already filters, but the invariant belongs in the data
* path, fail-closed: forged/non-eligible ids (a leader, a deleted account) are
* dropped from any create/update. Intentional admin bypass — the hook must SEE
* the non-eligible rows to drop them (same precedent as the slug probe).
*/
const restrictResponsiblesToStaff: CollectionBeforeValidateHook = async ({ data, req }) => {
if (!data) return data
const rawResponsibles = data.responsibles
if (!Array.isArray(rawResponsibles) || rawResponsibles.length === 0) return data
const ids = rawResponsibles
.map((entry) => relationshipId(entry))
.filter((id): id is number => id !== null)
if (ids.length === 0) return data
// Bypass (documented in the hook's comment): the eligibility probe must see
// the non-eligible rows (leaders, deleted accounts) to drop them.
const eligible = await req.payload.find({
collection: 'campaignUser',
where: { and: [{ id: { in: ids } }, eligibleCampaignStaffWhere] },
depth: 0,
limit: 0,
pagination: false,
select: { name: true },
overrideAccess: true,
req,
})
const eligibleIDs = new Set(eligible.docs.map((doc) => doc.id))
data.responsibles = [...new Set(ids.filter((id) => eligibleIDs.has(id)))]
return data
}
const setCanonicalDemandSlug: CollectionBeforeValidateHook = ({ data, operation, originalDoc }) => {
if (!data) return data
const title = trimmedText(data.title ?? originalDoc?.title)
@ -127,7 +162,19 @@ const enforceDemandWorkflow: CollectionBeforeChangeHook = async ({
const actor = req.user?.collection === 'campaignUser' ? req.user : null
if (operation === 'create') {
if (actor) data.createdBy = actor.id
if (actor) {
data.createdBy = actor.id
// C143 — the creator is always a responsible (explicit-responsible
// visibility): union with any client-provided list so a create can
// never leave its author outside the demand's own visibility.
const existingResponsibles = Array.isArray(data.responsibles)
? data.responsibles
.map((entry) => relationshipId(entry))
.filter((id): id is number => id !== null)
: []
data.responsibles = [...new Set([...existingResponsibles, actor.id])]
}
const initialStatus = isDemandStatus(data.status) ? data.status : 'aberta'
data.status = initialStatus
@ -255,6 +302,7 @@ export const CampaignDemand: CollectionConfig = {
},
hooks: {
beforeValidate: [
restrictResponsiblesToStaff,
setCanonicalDemandSlug,
ensureUniqueDemandSlug,
validateDemandActivityMunicipality,
@ -322,6 +370,19 @@ export const CampaignDemand: CollectionConfig = {
label: 'Liderança solicitante',
index: true,
},
{
name: 'responsibles',
type: 'relationship',
relationTo: 'campaignUser',
label: 'Responsáveis',
hasMany: true,
index: true,
filterOptions: eligibleCampaignStaffWhere,
admin: {
description:
'Só responsáveis, candidato e coordenador veem a demanda. O criador entra automaticamente.',
},
},
{
name: 'status',
type: 'select',

View file

@ -7,7 +7,8 @@ import {
getPostCanonicalPath,
getVisiblePosts,
} from '@/utilities/posts'
import { getInstagramFeed, type InstagramPost } from '@/utilities/socialFeed/instagramFeed'
import type { InstagramPost } from '@/utilities/socialFeed/instagramFeed'
import { getInstagramFeed } from '@/utilities/socialFeed/instagramFeedView'
import {
formatYouTubeViews,
getYouTubeFeed,

View file

@ -67,8 +67,8 @@ export const InstagramPostExclusionPicker: FieldClientComponent = () => {
if (posts.length === 0) {
return (
<p className="text-sm text-zinc-500">
Nenhum post recente ainda. Com o token e o ID do usuário configurados, os posts aparecem
aqui após a primeira sincronização (a cada 5 minutos).
Nenhum post sincronizado. Após uma sincronização bem-sucedida, os últimos posts aparecem
aqui para marcar os que não devem ir para a home.
</p>
)
}

View file

@ -0,0 +1,186 @@
'use client'
import { useAllFormFields } from '@payloadcms/ui'
import type { FieldClientComponent } from 'payload'
import { useEffect, useState } from 'react'
type InstagramSyncStatusValue = {
lastSyncAt?: string
postCount?: number
error?: string
errorAt?: string
}
type SyncResponse = {
ok: boolean
status?: InstagramSyncStatusValue
error?: string
}
const isSyncStatus = (value: unknown): value is InstagramSyncStatusValue =>
typeof value === 'object' && value !== null
const relativeTimeFormatter = new Intl.RelativeTimeFormat('pt-BR', { numeric: 'auto' })
const formatSyncTime = (iso?: string): string => {
if (!iso) return ''
const time = new Date(iso).getTime()
if (Number.isNaN(time)) return ''
const seconds = Math.floor((time - Date.now()) / 1000)
if (seconds > -60) return 'agora'
const absSeconds = -seconds
if (absSeconds < 3_600) {
return relativeTimeFormatter.format(-Math.floor(absSeconds / 60), 'minute')
}
if (absSeconds < 86_400) {
return relativeTimeFormatter.format(-Math.floor(absSeconds / 3_600), 'hour')
}
return relativeTimeFormatter.format(-Math.floor(absSeconds / 86_400), 'day')
}
const postCountLabel = (count?: number): string =>
count === undefined ? '' : `${count} ${count === 1 ? 'post' : 'posts'}`
/**
* Sync-status panel of the Instagram feed (S11) — the S3 fail-closed silence
* made the assessoria blind: a wrong token left no trace beyond an empty
* picker. This panel shows, in the same global where the credentials live,
* the state of the last sync — "Sincronizado · há X min · N posts",
* "Falha na última sincronização" with the product-language reason, or
* "Instagram ainda não configurado" — with a "Tentar sincronizar de novo"
* button that runs `POST /api/social-feed/sync` (admin session) and renders
* the outcome. The status itself is persisted server-side by the render
* path, the global's `afterChange` and the sync route; this component only
* reads it from the form and keeps the in-flight result locally.
*/
export const InstagramSyncStatusPanel: FieldClientComponent = () => {
const [fields] = useAllFormFields()
const [outcome, setOutcome] = useState<SyncResponse | null>(null)
const [pending, setPending] = useState(false)
const [requestError, setRequestError] = useState<string | null>(null)
const enabled = fields.enabled?.value !== false && fields.instagramEnabled?.value !== false
const accessToken = fields.instagramAccessToken?.value
const userId = fields.instagramUserId?.value
const stored = fields.instagramSyncStatus?.value
const persisted = isSyncStatus(stored) ? stored : {}
// The panel shows the SERVER-persisted state; the transient retry result in
// `outcome` must not outlive an edit of the credentials it was produced with
// (otherwise it keeps showing the failure of the old token pre-save).
const credentialKey = typeof accessToken === 'string' ? accessToken : ''
const userIdKey = typeof userId === 'string' ? userId : ''
useEffect(() => {
setOutcome(null)
setRequestError(null)
}, [credentialKey, userIdKey])
const configured =
typeof accessToken === 'string' &&
accessToken !== '' &&
typeof userId === 'string' &&
userId !== ''
const status = outcome?.status && isSyncStatus(outcome.status) ? outcome.status : persisted
const failureMessage = status.error ?? null
const lastSyncAt = status.lastSyncAt
const postCount = status.postCount
const retrySync = async () => {
setPending(true)
setRequestError(null)
try {
const response = await fetch('/api/social-feed/sync', { method: 'POST' })
const body = (await response.json()) as SyncResponse
if (response.status === 401) {
setRequestError(
'Sessão do admin expirada. Salve a página e recarregue o admin para reautenticar.',
)
return
}
if (!response.ok || body.error) {
throw new Error(body.error ?? 'Falha na sincronização')
}
setOutcome(body)
} catch {
setRequestError('Não foi possível sincronizar agora. Tente novamente em instantes.')
} finally {
setPending(false)
}
}
const retryButton = (label: string = 'Tentar sincronizar de novo') => (
<button
type="button"
onClick={retrySync}
disabled={pending}
aria-busy={pending}
className="mt-3 rounded-md bg-red-600 px-3 py-1.5 text-sm font-semibold text-white transition-colors hover:bg-red-700 disabled:cursor-not-allowed disabled:opacity-60"
>
{pending ? 'Sincronizando…' : label}
</button>
)
if (!enabled) {
return (
<div className="rounded-lg border border-zinc-300 bg-white p-4">
<p className="text-sm font-bold text-zinc-800">Instagram desativado</p>
<p className="mt-1 max-w-2xl text-sm text-zinc-500">
Os posts do Instagram não aparecem no board enquanto o feed estiver desativado.
</p>
</div>
)
}
if (!configured) {
return (
<div className="rounded-lg border border-zinc-300 bg-white p-4">
<p className="text-sm font-bold text-zinc-800">Instagram ainda não configurado</p>
<p className="mt-1 max-w-2xl text-sm text-zinc-500">
Informe o token de acesso e o ID da conta para publicar posts do Instagram na home. Sem
configuração, o board funciona com artigos e YouTube.
</p>
</div>
)
}
if (failureMessage) {
return (
<div className="rounded-lg border border-red-300 bg-red-50 p-4" aria-live="polite">
<p className="text-sm font-bold text-red-800">Falha na última sincronização</p>
<p className="mt-1 max-w-2xl text-sm text-red-700">{failureMessage}</p>
{requestError ? <p className="mt-2 text-sm text-red-700">{requestError}</p> : null}
{retryButton()}
</div>
)
}
if (lastSyncAt) {
return (
<div className="rounded-lg border border-zinc-300 bg-white p-4">
<p className="text-sm font-bold text-zinc-800">
Sincronizado · {formatSyncTime(lastSyncAt)}
{postCount !== undefined ? ` · ${postCountLabel(postCount)}` : ''}
</p>
<p className="mt-1 max-w-2xl text-sm text-zinc-500">
Próxima atualização automática em ~5 min.{' '}
{postCount !== undefined
? `Os ${postCountLabel(postCount)} mais recentes estão no board "Acompanhe de perto" da home.`
: 'Os posts mais recentes estão no board "Acompanhe de perto" da home.'}
</p>
</div>
)
}
return (
<div className="rounded-lg border border-zinc-300 bg-white p-4" aria-live="polite">
<p className="text-sm font-bold text-zinc-800">Aguardando a primeira sincronização</p>
<p className="mt-1 max-w-2xl text-sm text-zinc-500">
Clique em &quot;Tentar sincronizar&quot; para buscar os posts do Instagram agora, ou aguarde
a próxima atualização automática.
</p>
{requestError ? <p className="mt-2 text-sm text-red-700">{requestError}</p> : null}
{retryButton('Tentar sincronizar')}
</div>
)
}

View file

@ -1,5 +1,9 @@
'use client'
import {
DemandResponsibleMultiSelect,
type DemandResponsibleOption,
} from '@/components/campaign/demand/DemandResponsibleMultiSelect'
import { AsyncSearchCombobox } from '@/components/campaign/shared/AsyncSearchCombobox'
import type { RelationOption } from '@/components/campaign/shared/RelationMultiSelect'
import { Button } from '@/components/ui/button'
@ -16,6 +20,8 @@ import {
CAMPAIGN_DEMAND_BODY_MAX_LENGTH,
CAMPAIGN_DEMAND_BODY_PLACEHOLDER,
CAMPAIGN_DEMAND_KIND_LABEL,
CAMPAIGN_DEMAND_RESPONSIBLES_DESCRIPTION,
CAMPAIGN_DEMAND_RESPONSIBLES_LABEL,
campaignDemandKindLabels,
campaignDemandKinds,
} from '@/lib/schemas/campaignDemand'
@ -38,6 +44,12 @@ type DemandFieldsProps = {
value: string
onValueChange: (value: string) => void
}
/** C143 — explicit-responsible picker; `aside` splits a right column (nova). */
responsibles?: {
currentUser: { id: number; name: string } | null
search: (query: string) => Promise<RelationOption[]>
layout: 'aside' | 'stacked'
}
}
/**
@ -53,6 +65,7 @@ export const DemandFields = ({
onActivityChange,
searchActivities,
municipality,
responsibles,
}: DemandFieldsProps) => {
const municipalityReady = municipality ? municipality.value !== '' : true
@ -114,7 +127,7 @@ export const DemandFields = ({
</Field>
)
return (
const coreFields = (
<>
{municipality ? (
<div className="grid gap-4 sm:grid-cols-2">
@ -172,4 +185,47 @@ export const DemandFields = ({
</Field>
</>
)
if (!responsibles) return coreFields
const creatorValue: DemandResponsibleOption[] = responsibles.currentUser
? [
{
id: responsibles.currentUser.id,
name: responsibles.currentUser.name,
},
]
: []
const responsiblesField = (
<Field>
<FieldLabel>{CAMPAIGN_DEMAND_RESPONSIBLES_LABEL}</FieldLabel>
<DemandResponsibleMultiSelect
name="responsibles"
value={creatorValue}
creatorUserId={responsibles.currentUser?.id ?? null}
search={responsibles.search}
triggerPlaceholder="Buscar assessor…"
triggerAriaLabel={CAMPAIGN_DEMAND_RESPONSIBLES_LABEL}
disabled={disabled}
/>
<FieldDescription>{CAMPAIGN_DEMAND_RESPONSIBLES_DESCRIPTION}</FieldDescription>
</Field>
)
if (responsibles.layout === 'aside') {
return (
<div className="grid items-start gap-4 lg:grid-cols-[minmax(0,2fr)_minmax(0,1fr)]">
<div className="flex flex-col gap-4">{coreFields}</div>
{responsiblesField}
</div>
)
}
return (
<>
{coreFields}
{responsiblesField}
</>
)
}

View file

@ -2,6 +2,7 @@
import { useActionState, useCallback, useState } from 'react'
import { searchDemandResponsibleOptions } from '@/app/(campaign)/campanha/(app)/demandas/responsibleSearchActions'
import { DemandFields, type DemandActivityValue } from '@/components/campaign/demand/DemandFields'
import { CampaignFormActionMessage } from '@/components/campaign/shared/CampaignFormActionMessage'
import type { RelationOption } from '@/components/campaign/shared/RelationMultiSelect'
@ -15,6 +16,7 @@ type DemandFormProps = {
municipalityOptions: RelationOption[]
initialActivity?: ActivityRelationOption | null
initialMunicipalityId?: number
currentUser?: { id: number; name: string } | null
searchActivities: (
query: string,
municipalityId: number | null,
@ -29,6 +31,7 @@ export const DemandForm = ({
municipalityOptions,
initialActivity = null,
initialMunicipalityId,
currentUser = null,
searchActivities,
formAction,
}: DemandFormProps) => {
@ -45,9 +48,13 @@ export const DemandForm = ({
(query: string) => searchActivities(query, parsedMunicipalityId),
[parsedMunicipalityId, searchActivities],
)
const searchResponsibles = useCallback(
(query: string) => searchDemandResponsibleOptions(query, parsedMunicipalityId),
[parsedMunicipalityId],
)
return (
<form action={submitAction} className="flex max-w-2xl flex-col gap-4">
<form action={submitAction} className="flex max-w-3xl flex-col gap-4">
<DemandFields
idPrefix="demand"
state={state}
@ -59,6 +66,11 @@ export const DemandForm = ({
value: municipalityId,
onValueChange: setMunicipalityId,
}}
responsibles={{
currentUser,
search: searchResponsibles,
layout: 'aside',
}}
/>
{state.status !== 'success' ? <CampaignFormActionMessage state={state} /> : null}
<Button type="submit" disabled={isPending} className="min-h-11 self-start">

View file

@ -0,0 +1,216 @@
'use client'
import { ChevronsUpDownIcon, XIcon } from 'lucide-react'
import { useEffect, useRef, useState } from 'react'
import type { RelationOption } from '@/components/campaign/shared/RelationMultiSelect'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/Avatar'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/button'
import {
Command,
CommandDialog,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/Command'
import { Spinner } from '@/components/ui/Spinner'
import { campaignUserInitials } from '@/utilities/campaignUserProfile'
export type DemandResponsibleOption = RelationOption & { avatarUrl?: string | null }
type DemandResponsibleMultiSelectProps = {
/** FormData field name — repeated hidden inputs, one per selected id. */
name: string
value: DemandResponsibleOption[]
/** When set, this id renders as "(criador)" (informational marker). */
creatorUserId?: number | null
/** Create flow: the creator chip cannot be removed (the hook re-adds it). */
lockCreator?: boolean
/** Server action — short queries return municipality-advisor suggestions. */
search: (query: string) => Promise<RelationOption[]>
/** Trigger text while empty, e.g. "Buscar assessor…". */
triggerPlaceholder: string
/** Accessible name of the add trigger (the list renders its own sr-only names). */
triggerAriaLabel: string
disabled?: boolean
/** Fires on every change with the full selected id list (dirty tracking). */
onChange?: (ids: number[]) => void
}
/**
* C143 — multi-value responsible picker for campaign demands (staff only).
* The chips carry the selected list as repeated hidden inputs; the search
* catalog is the `searchDemandResponsibleOptions` action, whose short-query
* branch returns the municipality's advisors as suggestions (a fill-in
* shortcut — visibility comes only from the saved list).
*/
export const DemandResponsibleMultiSelect = ({
name,
value,
creatorUserId = null,
lockCreator = true,
search,
triggerPlaceholder,
triggerAriaLabel,
disabled,
onChange,
}: DemandResponsibleMultiSelectProps) => {
const [selected, setSelected] = useState<DemandResponsibleOption[]>(value)
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
const [options, setOptions] = useState<RelationOption[]>([])
const [loading, setLoading] = useState(false)
const [failed, setFailed] = useState(false)
const requestId = useRef(0)
const searchRef = useRef(search)
searchRef.current = search
useEffect(() => {
if (!open) return
const currentRequestId = ++requestId.current
const timeout = window.setTimeout(() => {
setLoading(true)
setFailed(false)
void searchRef
.current(query.trim())
.then((nextOptions) => {
if (requestId.current !== currentRequestId) return
setOptions(nextOptions)
})
.catch(() => {
if (requestId.current !== currentRequestId) return
setFailed(true)
})
.finally(() => {
if (requestId.current === currentRequestId) setLoading(false)
})
}, 250)
return () => window.clearTimeout(timeout)
}, [open, query])
const isCreator = (id: number) => creatorUserId != null && creatorUserId === id
const selectedIds = new Set(selected.map((option) => option.id))
// Discrete user events re-render between calls, so reading `selected` here
// is safe (same shape as `RelationMultiSelect`); an updater side effect
// would double-fire onChange under StrictMode.
const add = (option: RelationOption) => {
if (selectedIds.has(option.id)) return
const next = [...selected, { ...option, avatarUrl: undefined }]
setSelected(next)
onChange?.(next.map((entry) => entry.id))
}
const remove = (option: DemandResponsibleOption) => {
if (isCreator(option.id) && lockCreator) return
const next = selected.filter((entry) => entry.id !== option.id)
setSelected(next)
onChange?.(next.map((entry) => entry.id))
}
const visibleOptions = options.filter((option) => !selectedIds.has(option.id))
const shortQuery = query.trim().length < 2
const selectedLabel =
selected.length === 0
? triggerPlaceholder
: `${selected.length} ${selected.length === 1 ? 'responsável' : 'responsáveis'}`
return (
<>
{selected.length ? (
<ul className="flex flex-wrap gap-1.5">
{selected.map((option) => (
<li key={option.id}>
<Badge variant="secondary" className="gap-1.5 pr-1">
{option.avatarUrl ? (
<Avatar size="sm" className="shrink-0">
<AvatarImage src={option.avatarUrl} alt="" />
<AvatarFallback>{campaignUserInitials(option.name)}</AvatarFallback>
</Avatar>
) : null}
<input type="hidden" name={name} value={option.id} />
<span className="font-normal">{option.name}</span>
{isCreator(option.id) ? (
<span className="text-muted-foreground">(criador)</span>
) : null}
{!isCreator(option.id) ? (
<button
type="button"
aria-label={`Remover ${option.name}`}
className="inline-flex size-6 items-center justify-center rounded-full hover:bg-foreground/10"
disabled={disabled}
onClick={() => remove(option)}
>
<XIcon className="size-3.5" aria-hidden="true" />
</button>
) : null}
</Badge>
</li>
))}
</ul>
) : null}
<Button
type="button"
variant="outline"
className="min-h-11 w-full justify-between font-normal"
aria-label={`${triggerAriaLabel}: ${selectedLabel}`}
disabled={disabled}
onClick={() => setOpen(true)}
>
<span className="truncate">{triggerPlaceholder}</span>
<ChevronsUpDownIcon aria-hidden="true" />
</Button>
<CommandDialog
open={open}
onOpenChange={setOpen}
title={triggerAriaLabel}
description="Busque por nome e escolha um ou mais responsáveis."
showCloseButton
>
<Command shouldFilter={false} label={triggerAriaLabel}>
<CommandInput
value={query}
onValueChange={setQuery}
placeholder="Buscar assessor…"
aria-label={triggerAriaLabel}
/>
<CommandList aria-busy={loading}>
{loading ? (
<div className="flex items-center justify-center gap-2 py-6 text-sm text-muted-foreground">
<Spinner aria-hidden="true" />
Buscando…
</div>
) : failed ? (
<p className="py-6 text-center text-sm text-destructive">
Não foi possível concluir a busca.
</p>
) : visibleOptions.length > 0 ? (
<CommandGroup heading={shortQuery ? 'Sugestões do município' : 'Assessores'}>
{visibleOptions.map((option) => (
<CommandItem
key={option.id}
value={`${option.name}-${option.id}`}
onSelect={() => add(option)}
>
<span className="truncate">{option.name}</span>
</CommandItem>
))}
</CommandGroup>
) : (
<p className="py-6 text-center text-sm text-muted-foreground">
{shortQuery
? 'Sem assessores sugeridos para este município.'
: 'Nenhum resultado encontrado.'}
</p>
)}
</CommandList>
</Command>
</CommandDialog>
</>
)
}

View file

@ -0,0 +1,90 @@
'use client'
import { useActionState, useEffect, useState } from 'react'
import { searchDemandResponsibleOptions } from '@/app/(campaign)/campanha/(app)/demandas/responsibleSearchActions'
import { CampaignFormActionMessage } from '@/components/campaign/shared/CampaignFormActionMessage'
import { Button } from '@/components/ui/button'
import { Spinner } from '@/components/ui/Spinner'
import { CAMPAIGN_DEMAND_RESPONSIBLES_LABEL } from '@/lib/schemas/campaignDemand'
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
import {
DemandResponsibleMultiSelect,
type DemandResponsibleOption,
} from './DemandResponsibleMultiSelect'
type DemandResponsiblesCardProps = {
demandId: number
municipalityId: number | null
/** The creator id, shown as "(criador)" on the chip (removal stays allowed — hand-off). */
creatorUserId: number | null
initialResponsibles: DemandResponsibleOption[]
formAction: (
state: CampaignFormActionState,
formData: FormData,
) => Promise<CampaignFormActionState>
}
/**
* C143 — responsible management on the demand detail. Everyone who can read
* the demand can update it (same row scope), so the card's edit controls are
* available to whoever opens the page; the save submits the full list, the
* action revalidates the page and the local state keeps the saved chips
* (the success message is the confirmation, so no remount key is used).
*/
export const DemandResponsiblesCard = ({
demandId,
municipalityId,
creatorUserId,
initialResponsibles,
formAction,
}: DemandResponsiblesCardProps) => {
const [state, submitAction, isPending] = useActionState(formAction, {})
const [dirty, setDirty] = useState(false)
useEffect(() => {
if (state.status === 'success') setDirty(false)
}, [state.status])
const search = (query: string) => searchDemandResponsibleOptions(query, municipalityId)
return (
<form action={submitAction} className="flex flex-col gap-3 rounded-xl border p-4">
<div className="flex flex-col gap-1">
<h2 className="text-base font-medium">{CAMPAIGN_DEMAND_RESPONSIBLES_LABEL}</h2>
<p className="text-sm text-muted-foreground">Só quem é responsável vê esta demanda.</p>
</div>
<DemandResponsibleMultiSelect
name="responsibles"
value={initialResponsibles}
creatorUserId={creatorUserId}
lockCreator={false}
search={search}
triggerPlaceholder="Adicionar responsável…"
triggerAriaLabel={CAMPAIGN_DEMAND_RESPONSIBLES_LABEL}
disabled={isPending}
onChange={() => setDirty(true)}
/>
<p className="text-xs text-muted-foreground">
O criador já entra como responsável automaticamente. Assessores do município aparecem como
sugestão, mas só quem for marcado enxerga.
</p>
<CampaignFormActionMessage state={state} />
<Button
type="submit"
variant="outline"
className="min-h-9 self-start"
disabled={isPending || !dirty}
>
{isPending ? <Spinner data-icon="inline-start" aria-hidden="true" /> : null}
Salvar responsáveis
</Button>
<input type="hidden" name="demandId" value={demandId} />
</form>
)
}

View file

@ -5,6 +5,7 @@ import { useActionState, useCallback, useState } from 'react'
import { createWizardDemandFormAction } from '@/app/(campaign)/campanha/(app)/acoes/formActions'
import { searchDemandActivityOptions } from '@/app/(campaign)/campanha/(app)/demandas/activitySearchActions'
import { searchDemandResponsibleOptions } from '@/app/(campaign)/campanha/(app)/demandas/responsibleSearchActions'
import { DemandFields, type DemandActivityValue } from '@/components/campaign/demand/DemandFields'
import { CampaignFormActionMessage } from '@/components/campaign/shared/CampaignFormActionMessage'
import { CampaignWizardShell } from '@/components/campaign/shared/CampaignWizardShell'
@ -21,6 +22,7 @@ type WizardRegisterDemandStepProps = {
municipalityName: string
municipalitySlug: string
returnPath?: string
currentUser?: { id: number; name: string } | null
}
/**
@ -34,6 +36,7 @@ export const WizardRegisterDemandStep = ({
municipalityName,
municipalitySlug,
returnPath,
currentUser = null,
}: WizardRegisterDemandStepProps) => {
const router = useRouter()
const [state, submitAction, isPending] = useActionState(createWizardDemandFormAction, {})
@ -43,6 +46,10 @@ export const WizardRegisterDemandStep = ({
(query: string) => searchDemandActivityOptions(query, municipalityId),
[municipalityId],
)
const searchResponsibles = useCallback(
(query: string) => searchDemandResponsibleOptions(query, municipalityId),
[municipalityId],
)
useCampaignFormSuccessToast(state, () => {
recordLastActedMunicipality(municipalitySlug)
@ -79,6 +86,11 @@ export const WizardRegisterDemandStep = ({
activity={activity}
onActivityChange={setActivity}
searchActivities={searchActivities}
responsibles={{
currentUser,
search: searchResponsibles,
layout: 'stacked',
}}
/>
{state.status !== 'success' ? <CampaignFormActionMessage state={state} /> : null}

View file

@ -1,7 +1,12 @@
import { payloadAdminOnly } from '@/utilities/campaignAccess'
import { REVALIDATE_SOCIAL_FEED_TAG } from '@/utilities/revalidateRequest'
import {
INSTAGRAM_SYNC_TIMEOUT_MS,
instagramCredentialsChanged,
syncInstagramFeed,
} from '@/utilities/socialFeed/instagramSync'
import { revalidateTag } from 'next/cache'
import type { GlobalConfig } from 'payload'
import type { GlobalAfterChangeHook, GlobalConfig } from 'payload'
const slug = 'social-feed-settings'
@ -9,6 +14,33 @@ const YOUTUBE_VIDEO_ID_PATTERN = /^[A-Za-z0-9_-]{11}$/
const revalidateFeed = async () => revalidateTag(REVALIDATE_SOCIAL_FEED_TAG)
/**
* Re-syncs Instagram when a save changes the IG credentials (the primary
* flow: the assessoria fills token + ID, saves, and the reload of the global
* already shows the sync state WITH the new credentials — the status panel
* would otherwise keep showing the last failure of the old token). Awaited
* with a bounded deadline so a hanging Graph API never stalls the save; any
* failure is swallowed — the sync's own status IS the result, and a broken
* sync must never break the save.
*
* The sync modules must not import `@payload-config` (they do not), so the
* static import here closes no cycle: `payload.config → this global →
* instagramSync → instagramFeed` ends at `instagramFeed`, which is pure.
*/
const syncInstagramAfterChange: GlobalAfterChangeHook = async ({ doc, previousDoc, req }) => {
if (!instagramCredentialsChanged(doc, previousDoc)) return
try {
// `req` lets the sync write inside this very transaction (same row lock
// held by the save) instead of blocking a second pool connection.
await syncInstagramFeed(req.payload, {
signal: AbortSignal.timeout(INSTAGRAM_SYNC_TIMEOUT_MS),
req,
})
} catch {
// the status panel reflects the failure; the save itself stays green
}
}
/**
* Configuration of the campaign home content board's external feeds (plano-site
* §4.2): YouTube now, Instagram in S3. The board's read path is the public home
@ -29,7 +61,7 @@ export const SocialFeedSettings: GlobalConfig = {
update: payloadAdminOnly,
},
hooks: {
afterChange: [revalidateFeed],
afterChange: [revalidateFeed, syncInstagramAfterChange],
},
fields: [
{
@ -88,6 +120,16 @@ export const SocialFeedSettings: GlobalConfig = {
description: 'Desliga só os cards de post do Instagram.',
},
},
{
name: 'instagramSyncStatusPanel',
type: 'ui',
label: 'Instagram — estado da sincronização',
admin: {
components: {
Field: './components/admin/InstagramSyncStatusPanel#InstagramSyncStatusPanel',
},
},
},
{
name: 'instagramAccessToken',
type: 'text',
@ -185,5 +227,12 @@ export const SocialFeedSettings: GlobalConfig = {
hidden: true,
},
},
{
name: 'instagramSyncStatus',
type: 'json',
admin: {
hidden: true,
},
},
],
}

View file

@ -93,6 +93,11 @@ export const CAMPAIGN_DEMAND_COST_STAFF_MESSAGE =
'Somente a coordenação, a assessoria e o candidato registram custos.'
export const CAMPAIGN_DEMAND_EDIT_STAFF_MESSAGE =
'Somente a coordenação, a assessoria e o candidato editam demandas.'
export const CAMPAIGN_DEMAND_RESPONSIBLES_STAFF_MESSAGE =
'Somente quem vê a demanda pode gerenciar os responsáveis.'
export const CAMPAIGN_DEMAND_RESPONSIBLES_LABEL = 'Responsáveis pela demanda'
export const CAMPAIGN_DEMAND_RESPONSIBLES_DESCRIPTION =
'Sugestões: assessores do município. Se ninguém for marcado além de você, só você, candidato e coordenador verão a demanda.'
export const CAMPAIGN_DEMAND_RECEIPT_STAFF_MESSAGE =
'Somente a coordenação e a assessoria anexam comprovantes.'
export const CAMPAIGN_DEMAND_RECEIPT_TYPE_MESSAGE = 'Envie uma imagem (JPEG, PNG, WebP) ou PDF.'

View file

@ -18,6 +18,8 @@ export const campaignDemandCreateSchema = z.object({
activity: positiveRelationshipId.optional(),
/** Staff may record on behalf of a leadership; leaders get their own. */
leadership: positiveRelationshipId.optional(),
/** C143 — explicit responsibles; the creator is unioned in by the collection hook. */
responsibles: z.array(positiveRelationshipId).default([]),
})
export const campaignDemandUpdateSchema = z.object({
@ -36,7 +38,14 @@ export const campaignDemandCostSchema = z.object({
cost: z.number().min(0).max(100_000_000).nullable(),
})
/** C143 — full-replace of the responsible list (advisory-locked by the action). */
export const campaignDemandResponsiblesSchema = z.object({
id: positiveRelationshipId,
responsibles: z.array(positiveRelationshipId).default([]),
})
export type CampaignDemandCreateInput = z.input<typeof campaignDemandCreateSchema>
export type CampaignDemandUpdateInput = z.input<typeof campaignDemandUpdateSchema>
export type CampaignDemandTransitionInput = z.input<typeof campaignDemandTransitionSchema>
export type CampaignDemandCostInput = z.input<typeof campaignDemandCostSchema>
export type CampaignDemandResponsiblesInput = z.input<typeof campaignDemandResponsiblesSchema>

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,29 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "campaign_demand_rels" ADD COLUMN "campaign_user_id" integer;
ALTER TABLE "campaign_demand_rels" ADD CONSTRAINT "campaign_demand_rels_campaign_user_fk" FOREIGN KEY ("campaign_user_id") REFERENCES "public"."campaign_user"("id") ON DELETE cascade ON UPDATE no action;
CREATE INDEX "campaign_demand_rels_campaign_user_id_idx" ON "campaign_demand_rels" USING btree ("campaign_user_id");
-- C143 backfill: pre-existing demands keep their creator as responsible, so
-- the explicit-responsible rule does not hide a demand from its own author.
DO $$
DECLARE backfilled integer;
BEGIN
INSERT INTO "campaign_demand_rels" ("order", "parent_id", "path", "campaign_user_id")
SELECT 0, "id", 'responsibles', "created_by_id"
FROM "campaign_demand"
WHERE "created_by_id" IS NOT NULL;
GET DIAGNOSTICS backfilled = ROW_COUNT;
RAISE NOTICE 'add_campaign_demand_responsibles: backfilling % demanda(s) com o criador como responsável', backfilled;
END $$;`)
}
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "campaign_demand_rels" DROP CONSTRAINT "campaign_demand_rels_campaign_user_fk";
DROP INDEX "campaign_demand_rels_campaign_user_id_idx";
ALTER TABLE "campaign_demand_rels" DROP COLUMN "campaign_user_id";`)
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,11 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "social_feed_settings" ADD COLUMN "instagram_sync_status" jsonb;`)
}
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "social_feed_settings" DROP COLUMN "instagram_sync_status";`)
}

View file

@ -59,6 +59,8 @@ import * as migration_20260818_192948_add_instagram_social_feed_fields from './2
import * as migration_20260819_192605_add_campaign_newsletter_capture from './20260819_192605_add_campaign_newsletter_capture';
import * as migration_20260819_213947_add_site_settings_facebook_pixel_id from './20260819_213947_add_site_settings_facebook_pixel_id';
import * as migration_20260819_214755_add_campaign_user_advisor_permission_profile from './20260819_214755_add_campaign_user_advisor_permission_profile';
import * as migration_20260819_233503_add_campaign_demand_responsibles from './20260819_233503_add_campaign_demand_responsibles';
import * as migration_20260820_003426_add_instagram_sync_status from './20260820_003426_add_instagram_sync_status';
export const migrations = [
{
@ -366,4 +368,14 @@ export const migrations = [
down: migration_20260819_214755_add_campaign_user_advisor_permission_profile.down,
name: '20260819_214755_add_campaign_user_advisor_permission_profile',
},
{
up: migration_20260819_233503_add_campaign_demand_responsibles.up,
down: migration_20260819_233503_add_campaign_demand_responsibles.down,
name: '20260819_233503_add_campaign_demand_responsibles',
},
{
up: migration_20260820_003426_add_instagram_sync_status.up,
down: migration_20260820_003426_add_instagram_sync_status.down,
name: '20260820_003426_add_instagram_sync_status',
},
];

View file

@ -725,6 +725,10 @@ export interface CampaignDemand {
municipality: number | Municipality;
activity?: (number | null) | Activity;
leadership?: (number | null) | Leadership;
/**
* Só responsáveis, candidato e coordenador veem a demanda. O criador entra automaticamente.
*/
responsibles?: (number | CampaignUser)[] | null;
status: 'aberta' | 'em_analise' | 'escalada' | 'aprovada' | 'rejeitada';
decisionNote?: string | null;
decidedBy?: (number | null) | CampaignUser;
@ -1791,6 +1795,7 @@ export interface CampaignDemandSelect<T extends boolean = true> {
municipality?: T;
activity?: T;
leadership?: T;
responsibles?: T;
status?: T;
decisionNote?: T;
decidedBy?: T;
@ -2461,6 +2466,15 @@ export interface SocialFeedSetting {
| number
| boolean
| null;
instagramSyncStatus?:
| {
[k: string]: unknown;
}
| unknown[]
| string
| number
| boolean
| null;
updatedAt?: string | null;
createdAt?: string | null;
}
@ -2578,6 +2592,7 @@ export interface SocialFeedSettingsSelect<T extends boolean = true> {
};
youtubeFeedSnapshot?: T;
instagramFeedSnapshot?: T;
instagramSyncStatus?: T;
updatedAt?: T;
createdAt?: T;
globalType?: T;

View file

@ -2,7 +2,7 @@
// Campaign demands
// ---------------------------------------------------------------------------
import type { Access } from 'payload'
import type { Access, Where } from 'payload'
import { relationshipId } from '@/lib/relationship'
import { getAccessibleMunicipalityIds } from '@/utilities/access/municipalities'
@ -12,7 +12,6 @@ import {
isCampaignLeader,
isCampaignUnrestricted,
isPayloadAdmin,
resolveActorScopedRead,
} from '@/utilities/access/shared'
export const canCreateCampaignDemand: Access = async ({ data, req }) => {
@ -34,8 +33,24 @@ export const canCreateCampaignDemand: Access = async ({ data, req }) => {
return municipalityIDs?.includes(municipalityID) ?? false
}
export const canReadCampaignDemand: Access = ({ req }) =>
resolveActorScopedRead(req, 'municipality', getAccessibleMunicipalityIds)
/**
* C143 — explicit-responsible visibility, fail-closed. An advisor reads ONLY
* demands where they are a listed responsible (the creator is auto-added on
* create); a municipality-related advisor who is not responsible sees nothing
* — not the list, not the URL, not the search (all reads run the collection
* access). Candidate and coordinator (unrestricted) always see everything.
*/
export const canReadCampaignDemand: Access = async ({ req }): Promise<boolean | Where> => {
if (isPayloadAdmin(req.user)) return true
const currentUser = await getFreshCampaignUser(req)
if (!currentUser || isCampaignLeader(currentUser)) return false
if (isCampaignUnrestricted(currentUser)) return true
if (currentUser.role !== 'advisor') return false
return { responsibles: { contains: currentUser.id } }
}
/**
* C141 — demands stay out of the profile axes (C143 replaces this rule with
@ -49,7 +64,7 @@ export const canUpdateCampaignDemand: Access = async ({ req }) => {
if (isCampaignUnrestricted(currentUser)) return true
if (advisorEditingAccess(currentUser) === 'none') return false
return resolveActorScopedRead(req, 'municipality', getAccessibleMunicipalityIds)
return canReadCampaignDemand({ req })
}
export const canDeleteCampaignDemand: Access = ({ req }) => isPayloadAdmin(req.user)

View file

@ -13,6 +13,7 @@ import {
type DemandListState,
} from '@/utilities/demand/demandListUrl'
import {
loadCampaignUserDisplayByIds,
loadCampaignUserNamesByIds,
loadLeadershipContactNamesByIds,
loadMunicipalityLabelsByIds,
@ -148,6 +149,10 @@ export type DemandDetailViewModel = DemandRowViewModel & {
authorName: string | null
createdAt: string | null
}>
/** C143 — explicit responsibles (creator included by the collection hook). */
responsibles: Array<{ id: number; name: string; avatarUrl: string | null }>
/** C143 — the creator id, for the "(criador)" chip marker on the detail. */
createdBy: number | null
canLeaderEdit: boolean
activity: { title: string; slug: string } | null
}
@ -183,11 +188,21 @@ export const loadDemandDetail = async (
]
const authorNameById = await loadCampaignUserNamesByIds(payload, authorIDs)
const responsibleIDs = (demand.responsibles ?? [])
.map((responsible) => relationshipId(responsible))
.filter((id): id is number => id !== null)
const responsibleById = await loadCampaignUserDisplayByIds(payload, responsibleIDs)
return {
...row,
description: demand.description ?? null,
decisionNote: demand.decisionNote ?? null,
decidedAt: demand.decidedAt ?? null,
createdBy: relationshipId(demand.createdBy),
responsibles: responsibleIDs.flatMap((id) => {
const display = responsibleById.get(id)
return display ? [{ id, ...display }] : []
}),
cost: isCampaignStaff(user) ? (demand.cost ?? null) : null,
receipts: isCampaignStaff(user)
? (demand.receipts ?? []).flatMap((receipt) =>

View file

@ -1,5 +1,6 @@
import {
nullableRelationshipFormValue,
repeatedRelationshipFormValues,
requiredFormText,
requiredRelationshipFormValue,
} from '@/lib/formData'
@ -22,5 +23,6 @@ export const parseCampaignDemandCreateFormData = (
description: requiredFormText(formData, 'description'),
municipality: requiredRelationshipFormValue(formData, 'municipalityId'),
activity: nullableRelationshipFormValue(formData, 'activityId') ?? undefined,
responsibles: repeatedRelationshipFormValues(formData, 'responsibles'),
}
}

View file

@ -1,11 +1,8 @@
import 'server-only'
import type { SocialFeedSetting } from '@/payload-types'
import { REVALIDATE_SOCIAL_FEED_TAG } from '@/utilities/revalidateRequest'
import configPromise from '@payload-config'
import { sql } from '@payloadcms/db-postgres'
import { unstable_cache } from 'next/cache'
import { getPayload, type Payload } from 'payload'
import type { Payload } from 'payload'
const INSTAGRAM_API_BASE_URL = process.env.INSTAGRAM_API_BASE_URL ?? 'https://graph.instagram.com'
@ -33,6 +30,8 @@ export type LoadInstagramFeedArgs = {
maxResults: number
fetchImpl?: (input: string, init?: RequestInit) => Promise<Response>
baseUrl?: string
/** Deadline for the Graph API calls (hook-triggered sync must not hang a save). */
signal?: AbortSignal
}
export type LoadInstagramFeedResult = InstagramFeedResult & {
@ -40,6 +39,57 @@ export type LoadInstagramFeedResult = InstagramFeedResult & {
refreshedAccessToken?: string
}
/**
* API-level failure of the Instagram Graph API: carries the HTTP status and
* the parsed `{ error: { message, type } }` body when present, so
* `describeInstagramError` can turn it into product language (invalid token
* vs wrong user id vs API down) for the admin sync-status panel.
*/
export class InstagramApiError extends Error {
readonly status: number | undefined
readonly apiMessage: string | null
readonly apiType: string | null
constructor(
status: number | undefined,
apiMessage: string | null,
apiType: string | null,
message: string,
) {
super(message)
this.name = 'InstagramApiError'
this.status = status
this.apiMessage = apiMessage
this.apiType = apiType
}
}
const parseApiErrorBody = async (
response: Response,
): Promise<{ message: string | null; type: string | null }> => {
try {
const body = (await response.json()) as { error?: { message?: unknown; type?: unknown } } | null
const error = body?.error
if (typeof error !== 'object' || error === null) return { message: null, type: null }
return {
message: typeof error.message === 'string' && error.message ? error.message : null,
type: typeof error.type === 'string' && error.type ? error.type : null,
}
} catch {
return { message: null, type: null }
}
}
const apiErrorFrom = async (response: Response): Promise<InstagramApiError> => {
const { message, type } = await parseApiErrorBody(response)
return new InstagramApiError(
response.status,
message,
type,
`Instagram API falhou com status ${response.status}`,
)
}
const MEDIA_FIELDS = [
'id',
'caption',
@ -156,12 +206,13 @@ export const loadInstagramFeed = async ({
maxResults,
fetchImpl = fetch,
baseUrl = INSTAGRAM_API_BASE_URL,
signal,
}: LoadInstagramFeedArgs): Promise<LoadInstagramFeedResult> => {
const attempt = async (token: string): Promise<InstagramFeedResult> => {
const userParams = new URLSearchParams({ fields: 'username', access_token: token })
const userResponse = await fetchImpl(`${baseUrl}/${userId}?${userParams}`)
const userResponse = await fetchImpl(`${baseUrl}/${userId}?${userParams}`, { signal })
if (!userResponse.ok) {
throw new Error(`Instagram user falhou com status ${userResponse.status}`)
throw await apiErrorFrom(userResponse)
}
const userJson = (await jsonFrom(userResponse)) as { username?: unknown }
const username = typeof userJson.username === 'string' ? userJson.username : null
@ -171,9 +222,9 @@ export const loadInstagramFeed = async ({
limit: String(Math.min(Math.max(maxResults, 1), INSTAGRAM_MAX_RESULTS_CAP)),
access_token: token,
})
const mediaResponse = await fetchImpl(`${baseUrl}/${userId}/media?${mediaParams}`)
const mediaResponse = await fetchImpl(`${baseUrl}/${userId}/media?${mediaParams}`, { signal })
if (!mediaResponse.ok) {
throw new Error(`Instagram media falhou com status ${mediaResponse.status}`)
throw await apiErrorFrom(mediaResponse)
}
return { username, posts: parseInstagramMediaResponse(await jsonFrom(mediaResponse)) }
@ -186,9 +237,11 @@ export const loadInstagramFeed = async ({
grant_type: 'ig_refresh_token',
access_token: accessToken,
})
const refreshResponse = await fetchImpl(`${baseUrl}/refresh_access_token?${refreshParams}`)
const refreshResponse = await fetchImpl(`${baseUrl}/refresh_access_token?${refreshParams}`, {
signal,
})
if (!refreshResponse.ok) {
throw new Error(`Instagram token refresh falhou com status ${refreshResponse.status}`)
throw await apiErrorFrom(refreshResponse)
}
const refreshJson = (await jsonFrom(refreshResponse)) as { access_token?: unknown }
if (typeof refreshJson.access_token !== 'string' || !refreshJson.access_token) {
@ -201,42 +254,153 @@ export const loadInstagramFeed = async ({
}
}
const excludedInstagramIds = (settings: SocialFeedSetting): string[] =>
(settings.excludedItems ?? [])
.filter((item) => item.platform === 'instagram')
.map((item) => item.itemId)
.filter(Boolean)
/**
* Operational state of the Instagram sync, persisted in the global's
* `instagram_sync_status` column and shown by the admin status panel:
* `lastSyncAt`/`postCount` are the last SUCCESSFUL sync; `error`/`errorAt`
* the last failure. A failure overwrites the success fields on purpose — the
* panel shows either state, never both (draft cenas 1/2).
*/
export type InstagramSyncStatus = {
lastSyncAt?: string
postCount?: number
error?: string
errorAt?: string
}
const isInstagramSnapshot = (value: unknown): value is { username?: unknown; posts?: unknown } =>
typeof value === 'object' && value !== null
export const successInstagramSyncStatus = (postCount: number): InstagramSyncStatus => ({
lastSyncAt: new Date().toISOString(),
postCount,
})
export const failedInstagramSyncStatus = (error: string): InstagramSyncStatus => ({
error,
errorAt: new Date().toISOString(),
})
const isTokenError = (error: InstagramApiError): boolean =>
error.apiType === 'OAuthException' ||
/(expired|invalid|revoked|token|session)/i.test(error.apiMessage ?? '')
/**
* True when the API message points at a bad user id rather than a bad token.
* Requires "user" AND "id" together (or an explicit "object with id ... does
* not exist") — a single "user" alone is NOT enough, because real token
* rejections read "the user must be logged in" / "the user has changed their
* password", and those must stay on the token correction, not the ID one.
*/
const isInvalidUserIdMessage = (message: string): boolean =>
/(?=.*\buser\b)(?=.*\bid\b)/i.test(message) ||
/\bid\b[\s\S]*\b(?:does not exist|cannot find)\b/i.test(message)
const isAbortError = (cause: unknown): boolean =>
cause instanceof Error && cause.name === 'AbortError'
/**
* Turns a sync failure into product language for the admin status panel: the
* assessoria must know WHY the board has no Instagram cards and what to do
* (the S3 fail-closed silence is the bug this exists to fix). Token errors
* carry the correction — regenerate via Instagram Login, never Facebook
* Login (the Graph API refresh endpoint rejects FB-issued page tokens).
*/
export const describeInstagramError = (cause: unknown): string => {
if (isAbortError(cause)) {
return 'A sincronização demorou demais para responder (a API do Instagram não respondeu a tempo). Tente novamente.'
}
if (cause instanceof InstagramApiError) {
if (cause.status === 400 && isInvalidUserIdMessage(cause.apiMessage ?? '')) {
return 'O ID do usuário não foi reconhecido pela Graph API. Confira se é o ID numérico da conta Business/Creator.'
}
if (typeof cause.status === 'number' && cause.status >= 400 && cause.status < 500) {
if (isTokenError(cause)) {
return 'O token do Instagram foi recusado pela Graph API — está inválido/expirado ou foi emitido via Facebook Login (a Graph API só aceita tokens gerados pelo Instagram Login). Gere um novo token de longa duração pelo Instagram Login e atualize o campo acima.'
}
return 'A Graph API recusou a solicitação. Confira o token e o ID do usuário configurados.'
}
return 'A Graph API está indisponível no momento. Tente novamente em alguns minutos.'
}
if (cause instanceof TypeError) {
return 'Não foi possível falar com a API do Instagram (rede indisponível).'
}
return 'Erro inesperado ao sincronizar o Instagram.'
}
/**
* Database seam for the raw-SQL persists. The default is the pool
* (`payload.db.drizzle`); the hook-triggered sync passes the transaction-bound
* database (`getPostgresTransactionDatabase`, S11) so the write lands inside
* the global save's transaction — writing the same row from a second pool
* connection while the save holds its row lock deadlocks on that lock.
*/
export type InstagramPersistDatabase = {
execute: (query: ReturnType<typeof sql>) => Promise<unknown>
}
const countAffected = (result: unknown): number | null | undefined =>
(result as { rowCount?: number | null } | undefined)?.rowCount
/**
* Persists the sync status the same way as the snapshot — deliberately raw
* SQL instead of `payload.updateGlobal`: this runs INSIDE `unstable_cache`
* during a page render, and Next 15.4 throws when `revalidateTag` (the
* global's `afterChange` hook) fires inside a cached function. Same
* precondition as the snapshot: at most one `social_feed_settings` row
* exists; the INSERT only fires when none does.
*/
export const persistInstagramSyncStatus = async (
payload: Payload,
status: InstagramSyncStatus,
database: InstagramPersistDatabase | null = null,
): Promise<void> => {
const db = database ?? payload.db.drizzle
const json = JSON.stringify(status)
const updated = await db.execute(sql`
UPDATE "social_feed_settings"
SET "instagram_sync_status" = ${json}::jsonb, "updated_at" = now()
WHERE "id" = (SELECT "id" FROM "social_feed_settings" ORDER BY "id" LIMIT 1)
`)
if (!countAffected(updated)) {
await db.execute(sql`
INSERT INTO "social_feed_settings" ("instagram_sync_status", "created_at", "updated_at")
VALUES (${json}::jsonb, now(), now())
`)
}
}
/** True when the Instagram feed is armed to call the Graph API. */
export const isInstagramFeedConfigured = (settings: SocialFeedSetting): boolean =>
settings.enabled !== false &&
settings.instagramEnabled !== false &&
Boolean(settings.instagramAccessToken) &&
Boolean(settings.instagramUserId)
/**
* Persists the raw feed as the global's snapshot column. Deliberately raw SQL
* (`payload.db.drizzle`) instead of `payload.updateGlobal`: this runs INSIDE
* `unstable_cache` during a page render, and Next 15.4 throws when
* `revalidateTag` (the global's `afterChange` hook) fires inside a cached
* function. Raw SQL bypasses hooks; the row is created on first write when the
* admin has not saved the global yet (a serial id is assigned, so no id
* assumption is made).
* instead of `payload.updateGlobal`: this runs INSIDE `unstable_cache` during
* a page render, and Next 15.4 throws when `revalidateTag` (the global's
* `afterChange` hook) fires inside a cached function. Raw SQL bypasses hooks;
* the row is created on first write when the admin has not saved the global
* yet (a serial id is assigned, so no id assumption is made).
*
* Precondition: at most one `social_feed_settings` row exists. The UPDATE
* targets the single row and the INSERT only fires when none exists; a
* concurrent double-insert would need two writers racing the very first write
* (the row is created by the first admin save or feed render) — accepted.
*/
const persistInstagramSnapshot = async (
export const persistInstagramSnapshot = async (
payload: Payload,
snapshot: InstagramFeedResult,
database: InstagramPersistDatabase | null = null,
): Promise<void> => {
const database = payload.db.drizzle
const db = database ?? payload.db.drizzle
const json = JSON.stringify(snapshot)
const updated = await database.execute(sql`
const updated = await db.execute(sql`
UPDATE "social_feed_settings"
SET "instagram_feed_snapshot" = ${json}::jsonb, "updated_at" = now()
WHERE "id" = (SELECT "id" FROM "social_feed_settings" ORDER BY "id" LIMIT 1)
`)
if (!updated?.rowCount) {
await database.execute(sql`
if (!countAffected(updated)) {
await db.execute(sql`
INSERT INTO "social_feed_settings" ("instagram_feed_snapshot", "created_at", "updated_at")
VALUES (${json}::jsonb, now(), now())
`)
@ -244,69 +408,15 @@ const persistInstagramSnapshot = async (
}
/** Persists a refreshed token the same way as the snapshot (raw SQL, no hooks). */
const persistInstagramAccessToken = async (payload: Payload, token: string): Promise<void> => {
await payload.db.drizzle.execute(sql`
export const persistInstagramAccessToken = async (
payload: Payload,
token: string,
database: InstagramPersistDatabase | null = null,
): Promise<void> => {
const db = database ?? payload.db.drizzle
await db.execute(sql`
UPDATE "social_feed_settings"
SET "instagram_access_token" = ${token}, "updated_at" = now()
WHERE "id" = (SELECT "id" FROM "social_feed_settings" ORDER BY "id" LIMIT 1)
`)
}
/**
* Cached Instagram feed for the campaign home content board: `null` when the
* feed is off (kill switch, platform toggle or missing credentials), otherwise
* the latest eligible posts plus the profile username. A successful fetch
* persists the RAW list (pre-exclusion) as the global snapshot; a failed fetch
* falls back to that snapshot with the current exclusions applied — so a new
* exclusion works even while the API is down, and the page never breaks.
* Busted by the global's `afterChange` (tag `social-feed`); the per-entry
* `revalidate` keeps new uploads appearing without an admin edit.
*/
export const getInstagramFeed = unstable_cache(
async (): Promise<InstagramFeedResult | null> => {
const payload = await getPayload({ config: configPromise })
const settings = await payload.findGlobal({
slug: 'social-feed-settings',
depth: 0,
})
if (
settings.enabled === false ||
settings.instagramEnabled === false ||
!settings.instagramAccessToken ||
!settings.instagramUserId
) {
return null
}
const maxItems = Math.min(Math.max(settings.instagramMaxItems ?? 3, 1), 5)
const excludedIds = excludedInstagramIds(settings)
try {
const result = await loadInstagramFeed({
accessToken: settings.instagramAccessToken,
userId: settings.instagramUserId,
maxResults: Math.min(maxItems + 10, INSTAGRAM_MAX_RESULTS_CAP),
})
await persistInstagramSnapshot(payload, { username: result.username, posts: result.posts })
if (result.refreshedAccessToken) {
await persistInstagramAccessToken(payload, result.refreshedAccessToken)
}
return {
username: result.username,
posts: eligibleInstagramPosts(result.posts, excludedIds, maxItems),
}
} catch {
const snapshot = settings.instagramFeedSnapshot as unknown
if (!isInstagramSnapshot(snapshot) || !Array.isArray(snapshot.posts)) {
return { username: null, posts: [] }
}
return {
username: typeof snapshot.username === 'string' ? snapshot.username : null,
posts: eligibleInstagramPosts(snapshot.posts, excludedIds, maxItems),
}
}
},
['instagram-feed'],
{ tags: [REVALIDATE_SOCIAL_FEED_TAG], revalidate: 300 },
)

View file

@ -0,0 +1,97 @@
import 'server-only'
import type { SocialFeedSetting } from '@/payload-types'
import { REVALIDATE_SOCIAL_FEED_TAG } from '@/utilities/revalidateRequest'
import configPromise from '@payload-config'
import { unstable_cache } from 'next/cache'
import { getPayload } from 'payload'
import {
describeInstagramError,
eligibleInstagramPosts,
failedInstagramSyncStatus,
INSTAGRAM_MAX_RESULTS_CAP,
type InstagramFeedResult,
isInstagramFeedConfigured,
loadInstagramFeed,
persistInstagramAccessToken,
persistInstagramSnapshot,
persistInstagramSyncStatus,
successInstagramSyncStatus,
} from '@/utilities/socialFeed/instagramFeed'
const excludedInstagramIds = (settings: SocialFeedSetting): string[] =>
(settings.excludedItems ?? [])
.filter((item) => item.platform === 'instagram')
.map((item) => item.itemId)
.filter(Boolean)
const isInstagramSnapshot = (value: unknown): value is { username?: unknown; posts?: unknown } =>
typeof value === 'object' && value !== null
/**
* Cached Instagram feed for the campaign home content board: `null` when the
* feed is off (kill switch, platform toggle or missing credentials), otherwise
* the latest eligible posts plus the profile username. A successful fetch
* persists the RAW list (pre-exclusion) as the global snapshot AND the sync
* status (so the admin panel shows "Sincronizado · há X min · N posts"); a
* failed fetch persists the failure status with the product-language reason
* and falls back to the snapshot with the current exclusions applied — so a
* new exclusion works even while the API is down, and the page never breaks.
* Busted by the global's `afterChange` (tag `social-feed`); the per-entry
* `revalidate` keeps new uploads appearing without an admin edit.
*
* Lives in `instagramFeedView.ts` (NOT `instagramFeed.ts`) on purpose: this
* module is the only one that imports `@payload-config`, while
* `instagramFeed.ts` is reachable from `payload.config.ts` via the global's
* sync hook — importing the config back from the feed module would close a
* cycle the `check:cycles` gate rejects.
*/
export const getInstagramFeed = unstable_cache(
async (): Promise<InstagramFeedResult | null> => {
const payload = await getPayload({ config: configPromise })
const settings = await payload.findGlobal({
slug: 'social-feed-settings',
depth: 0,
})
if (!isInstagramFeedConfigured(settings)) {
return null
}
const maxItems = Math.min(Math.max(settings.instagramMaxItems ?? 3, 1), 5)
const excludedIds = excludedInstagramIds(settings)
try {
const result = await loadInstagramFeed({
accessToken: settings.instagramAccessToken as string,
userId: settings.instagramUserId as string,
maxResults: Math.min(maxItems + 10, INSTAGRAM_MAX_RESULTS_CAP),
})
await persistInstagramSnapshot(payload, { username: result.username, posts: result.posts })
if (result.refreshedAccessToken) {
await persistInstagramAccessToken(payload, result.refreshedAccessToken)
}
await persistInstagramSyncStatus(payload, successInstagramSyncStatus(result.posts.length))
return {
username: result.username,
posts: eligibleInstagramPosts(result.posts, excludedIds, maxItems),
}
} catch (cause) {
await persistInstagramSyncStatus(
payload,
failedInstagramSyncStatus(describeInstagramError(cause)),
)
const snapshot = settings.instagramFeedSnapshot as unknown
if (!isInstagramSnapshot(snapshot) || !Array.isArray(snapshot.posts)) {
return { username: null, posts: [] }
}
return {
username: typeof snapshot.username === 'string' ? snapshot.username : null,
posts: eligibleInstagramPosts(snapshot.posts, excludedIds, maxItems),
}
}
},
['instagram-feed'],
{ tags: [REVALIDATE_SOCIAL_FEED_TAG], revalidate: 300 },
)

View file

@ -0,0 +1,117 @@
import 'server-only'
import type { SocialFeedSetting } from '@/payload-types'
import { getPostgresTransactionDatabase } from '@/utilities/postgresTransactionLocks'
import {
describeInstagramError,
failedInstagramSyncStatus,
INSTAGRAM_MAX_RESULTS_CAP,
isInstagramFeedConfigured,
loadInstagramFeed,
persistInstagramAccessToken,
persistInstagramSnapshot,
persistInstagramSyncStatus,
successInstagramSyncStatus,
type InstagramPersistDatabase,
type InstagramSyncStatus,
} from '@/utilities/socialFeed/instagramFeed'
import type { Payload, PayloadRequest } from 'payload'
/** Deadline for a sync triggered by the admin (hook or button) — a hanging
* Graph API must never stall a global save. */
export const INSTAGRAM_SYNC_TIMEOUT_MS = 10_000
export type InstagramSyncOutcome = {
ok: boolean
status: InstagramSyncStatus
}
/**
* True when a save of the global must re-sync Instagram: the credentials
* changed (the primary flow — "fill token + ID, save, see the state") or the
* feed was re-enabled. Exclusion-only saves skip the sync on purpose: they
* change no credential and would burn an API call for nothing.
*/
export const instagramCredentialsChanged = (
doc: SocialFeedSetting,
previousDoc: SocialFeedSetting | null | undefined,
): boolean => {
if (!previousDoc) return true
const credentialsChanged =
(doc.instagramAccessToken ?? '') !== (previousDoc.instagramAccessToken ?? '') ||
(doc.instagramUserId ?? '') !== (previousDoc.instagramUserId ?? '')
const reenabled = doc.instagramEnabled === true && previousDoc.instagramEnabled !== true
return credentialsChanged || reenabled
}
/**
* The afterChange hook runs INSIDE the global save's transaction (the row lock
* is held until it returns), so a write to the same row from a second pool
* connection would block on that lock. When a `req` with a `transactionID` is
* present (hook path), the persists go through the transaction-bound database
* instead — the status/snapshot land atomically with the save, no deadlock.
* The render path and the sync route have no open transaction and keep the
* pool (`payload.db.drizzle`).
*/
const resolvePersistDatabase = async (
payload: Payload,
req?: PayloadRequest,
): Promise<InstagramPersistDatabase | null> => {
if (!req?.transactionID) return null
try {
return await getPostgresTransactionDatabase(payload, req)
} catch {
// No transaction adapter available (e.g. non-postgres in tests) — pool.
return null
}
}
/**
* Runs one Instagram sync from the persisted settings: fetches the Graph API,
* persists the raw snapshot + a refreshed token when one was minted, and
* writes the outcome status. Never throws — the status IS the result (a
* failure writes the product-language reason the admin panel shows). Shared
* by the global's `afterChange` hook (on credential change) and the
* `POST /api/social-feed/sync` route (the "Tentar sincronizar de novo"
* button) — one implementation, two call sites.
*/
export const syncInstagramFeed = async (
payload: Payload,
options: { signal?: AbortSignal; req?: PayloadRequest } = {},
): Promise<InstagramSyncOutcome> => {
const settings = await payload.findGlobal({
slug: 'social-feed-settings',
depth: 0,
})
if (!isInstagramFeedConfigured(settings)) {
return { ok: false, status: {} }
}
const maxItems = Math.min(Math.max(settings.instagramMaxItems ?? 3, 1), 5)
const database = await resolvePersistDatabase(payload, options.req)
try {
const result = await loadInstagramFeed({
accessToken: settings.instagramAccessToken as string,
userId: settings.instagramUserId as string,
maxResults: Math.min(maxItems + 10, INSTAGRAM_MAX_RESULTS_CAP),
signal: options.signal,
})
await persistInstagramSnapshot(
payload,
{ username: result.username, posts: result.posts },
database,
)
if (result.refreshedAccessToken) {
await persistInstagramAccessToken(payload, result.refreshedAccessToken, database)
}
const status = successInstagramSyncStatus(result.posts.length)
await persistInstagramSyncStatus(payload, status, database)
return { ok: true, status }
} catch (cause) {
const status = failedInstagramSyncStatus(describeInstagramError(cause))
await persistInstagramSyncStatus(payload, status, database)
return { ok: false, status }
}
}

View file

@ -1,5 +1,6 @@
import { login } from '../helpers/login'
import { cleanupTestUser, seedTestUser, testUser } from '../helpers/seedUser'
import { instagramStubUrlFor } from '../helpers/socialStub'
import { expect, test } from './fixtures/e2eTest'
const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? 'http://localhost:3000'
@ -127,6 +128,140 @@ test.describe('Admin Panel', () => {
excludedItems: [],
youtubeFeedSnapshot: null,
instagramFeedSnapshot: null,
instagramSyncStatus: null,
},
})
.catch(() => undefined)
}
})
test('shows the Instagram sync status and retries the sync from the panel', async ({
page,
request,
}) => {
// The seed POST fires the global's afterChange hook, whose sync hits the
// stub — put it in a known state first and let the hook persist the
// status so the panel has a real one to render.
const stubUrl = instagramStubUrlFor(baseURL)
const setStub = async (state: 'ok' | 'fail' | 'invalid-token') => {
const response = await request.post(`${stubUrl}/__stub/state`, { data: { state } })
expect(response.ok()).toBeTruthy()
}
await setStub('ok')
const login = await request.post(`${baseURL}/api/users/login`, {
data: { email: testUser.email, password: testUser.password },
})
expect(login.ok()).toBeTruthy()
const { token } = await login.json()
const headers = { cookie: `payload-token=${token}` }
const seed = await request.post(`${baseURL}/api/globals/social-feed-settings`, {
headers,
data: {
enabled: true,
instagramEnabled: true,
instagramAccessToken: 'e2e-ig-token',
instagramUserId: '17841400000000000',
instagramMaxItems: 3,
excludedItems: [],
instagramFeedSnapshot: null,
},
})
expect(seed.ok()).toBeTruthy()
const seedToken = async (accessToken: string) => {
const response = await request.post(`${baseURL}/api/globals/social-feed-settings`, {
headers,
data: {
enabled: true,
instagramEnabled: true,
instagramAccessToken: accessToken,
instagramUserId: '17841400000000000',
instagramMaxItems: 3,
excludedItems: [],
instagramFeedSnapshot: null,
},
})
expect(response.ok()).toBeTruthy()
}
try {
await page.goto(`${baseURL}/admin/globals/social-feed-settings`)
await expect(page.getByText(/Sincronizado · .* · 5 posts/)).toBeVisible()
// Credential change + save triggers the hook sync — with the API now
// rejecting the token (OAuthException), the save persists a failure
// status and the reload shows it with the retry button.
await setStub('invalid-token')
await seedToken('e2e-ig-token-2')
await page.goto(`${baseURL}/admin/globals/social-feed-settings`)
await expect(page.getByText('Falha na última sincronização')).toBeVisible()
await expect(page.getByText(/token do Instagram foi recusado/)).toBeVisible()
// Retry after fixing: the button runs the sync route → synchronized.
await setStub('ok')
await page.getByRole('button', { name: 'Tentar sincronizar de novo' }).click()
await expect(page.getByText(/Sincronizado · .* · 5 posts/)).toBeVisible()
const read = await request.get(`${baseURL}/api/globals/social-feed-settings`, { headers })
expect(read.ok()).toBeTruthy()
const { instagramSyncStatus } = await read.json()
expect(instagramSyncStatus).toEqual(
expect.objectContaining({
lastSyncAt: expect.any(String),
postCount: 5,
}),
)
expect(instagramSyncStatus.error).toBeUndefined()
// Fail-500 scene: a server-side API failure maps to the generic notice.
await setStub('fail')
await seedToken('e2e-ig-token-3')
await page.goto(`${baseURL}/admin/globals/social-feed-settings`)
await expect(page.getByText('Falha na última sincronização')).toBeVisible()
await expect(page.getByText(/indisponível/)).toBeVisible()
// Disabled scene: the master kill switch off renders the disabled box.
await setStub('ok')
await request.post(`${baseURL}/api/globals/social-feed-settings`, {
headers,
data: {
enabled: false,
instagramEnabled: true,
instagramAccessToken: 'e2e-ig-token',
instagramUserId: '17841400000000000',
instagramMaxItems: 3,
excludedItems: [],
instagramFeedSnapshot: null,
},
})
await page.goto(`${baseURL}/admin/globals/social-feed-settings`)
await expect(page.getByText('Instagram desativado')).toBeVisible()
// Unconfigured state: no credentials (feed enabled) → the panel says so.
await seedToken('')
await page.goto(`${baseURL}/admin/globals/social-feed-settings`)
await expect(page.getByText('Instagram ainda não configurado')).toBeVisible()
} finally {
await setStub('ok').catch(() => undefined)
await request
.post(`${baseURL}/api/globals/social-feed-settings`, {
headers,
data: {
enabled: true,
youtubeEnabled: true,
youtubeApiKey: '',
youtubeChannelId: '',
youtubeMaxItems: 3,
instagramEnabled: true,
instagramAccessToken: '',
instagramUserId: '',
instagramMaxItems: 3,
excludedItems: [],
youtubeFeedSnapshot: null,
instagramFeedSnapshot: null,
instagramSyncStatus: null,
},
})
.catch(() => undefined)

View file

@ -0,0 +1,102 @@
import {
campaignPageChrome,
expect,
test,
type CampaignE2EFixture,
} from './fixtures/campaignE2EFixtures.js'
test.describe('C143 — demand visibility (explicit responsibles)', () => {
const setupDemand = async (
campaign: CampaignE2EFixture,
fixtures: CampaignE2EFixture['fixtures'],
) => {
const municipality = await fixtures.claimMunicipality()
const coordinator = await fixtures.createCampaignUser('coordinator', {
name: fixtures.value('Coordenadora C143'),
})
const responsible = await fixtures.createCampaignUser('advisor', {
name: fixtures.value('Assessora Responsável'),
})
const peer = await fixtures.createCampaignUser('advisor', {
name: fixtures.value('Assessor Colega'),
})
await campaign.payload.update({
collection: 'municipality',
id: municipality.id,
data: { advisors: [responsible.id, peer.id] },
depth: 0,
})
const demand = await fixtures.createCampaignDemand({
municipality: municipality.id,
createdBy: coordinator.id,
responsibles: [responsible.id],
title: fixtures.value('Demanda de acesso restrito'),
})
return { municipality, coordinator, responsible, peer, demand }
}
test('hides the demand from a same-municipality advisor who is not responsible', async ({
campaign,
context,
page,
}) => {
const { fixtures } = campaign
const { peer, demand } = await setupDemand(campaign, fixtures)
await campaign.sessionFor(context, peer)
await page.goto(`${campaign.baseURL}/campanha/demandas`)
// The list rendered for him: the empty state proves the page settled
// (and that the demand is absent — the link assertion below is the
// fail-closed complement).
await expect(page.getByText('Nenhuma demanda por aqui')).toBeVisible()
await expect(page.getByRole('link', { name: demand.title })).toHaveCount(0)
// The URL does not open either — the demand simply does not exist for him
// (the default 404 boundary is the root one, no campaign-specific page).
await page.goto(`${campaign.baseURL}/campanha/demandas/${demand.slug}`)
await expect(page.getByText('This page could not be found.')).toBeVisible()
})
test('lets a responsible hand access to a peer and revoke it again', async ({
campaign,
context,
page,
}) => {
const { fixtures } = campaign
const { peer, responsible, demand } = await setupDemand(campaign, fixtures)
const demandURL = `${campaign.baseURL}/campanha/demandas/${demand.slug}`
// The responsible manages the list: add the peer through the search dialog.
await campaign.sessionFor(context, responsible)
await page.goto(demandURL)
await expect(page.getByText('Só quem é responsável vê esta demanda.')).toBeVisible()
// The trigger announces the current selection (e.g. "…: 1 responsável").
await page.getByRole('button', { name: /Responsáveis pela demanda/ }).click()
await page.getByPlaceholder('Buscar assessor…').fill(peer.name)
await page.getByRole('option', { name: peer.name }).click()
// The multi-add dialog stays open (modal) — close it before saving.
await page.keyboard.press('Escape')
await page.getByRole('button', { name: 'Salvar responsáveis' }).click()
await expect(page.getByText('Responsáveis atualizados.')).toBeVisible()
await expect(page.getByRole('button', { name: `Remover ${peer.name}` })).toBeVisible()
// The peer now opens the demand.
await campaign.sessionFor(context, peer)
await page.goto(demandURL)
await expect(campaignPageChrome(page, demand.title)).toBeVisible()
// Back as the responsible, remove the peer and revoke the access again.
await campaign.sessionFor(context, responsible)
await page.goto(demandURL)
await page.getByRole('button', { name: `Remover ${peer.name}` }).click()
await page.getByRole('button', { name: 'Salvar responsáveis' }).click()
await expect(page.getByRole('button', { name: `Remover ${peer.name}` })).toHaveCount(0)
await campaign.sessionFor(context, peer)
await page.goto(demandURL)
await expect(page.getByText('This page could not be found.')).toBeVisible()
})
})

View file

@ -220,6 +220,32 @@ export class CampaignE2EOwnership {
return { contactName, contactId: contact.id, leadershipId: leadership.id }
}
/**
* C143 — demand factory with explicit ownership: the row is registered for
* cleanup and the municipality touched (mirror of the int fixture helper).
*/
async createCampaignDemand(input: {
municipality: number
title: string
createdBy?: number
responsibles?: number[]
}): Promise<{ id: number; slug: string; title: string }> {
const demand = await this.payload.create({
collection: 'campaignDemand',
data: {
kind: 'material',
slug: this.value('demanda'),
status: 'aberta',
...input,
},
depth: 0,
draft: false,
})
this.own('campaignDemand', demand.id)
this.touchMunicipality(input.municipality)
return demand
}
private isOwnedCollection(collection: CollectionSlug): collection is OwnedCollection {
return this.owned.has(collection as OwnedCollection)
}

Some files were not shown because too many files have changed in this diff Show more