From 28d8e6746bd255f0e7acdfc4b7a354c4ee6765f6 Mon Sep 17 00:00:00 2001 From: Cedric Lamalle Date: Sun, 11 Oct 2026 14:52:14 +0000 Subject: [PATCH 1/2] Enriquecer a Central com Herdr, forjas e Taiga e publicar o estado pelo orq daemon orq #4 --- AGENTS.md | 6 +- README.md | 19 +- docs/snapshot.md | 106 +++- internal/central/cache.go | 469 ++++++++++++++++++ internal/central/cache_test.go | 117 +++++ internal/central/central_test.go | 14 +- .../migrations/0002_enriquecimento.sql | 32 ++ internal/central/mr.go | 32 +- internal/central/snapshot.go | 23 +- internal/central/us.go | 10 + internal/cli/daemon.go | 143 ++++++ internal/cli/daemon_test.go | 64 +++ internal/cli/root.go | 2 +- internal/cli/texto.go | 12 +- internal/enriquecer/daemon.go | 304 ++++++++++++ internal/enriquecer/enriquecedor.go | 296 +++++++++++ internal/enriquecer/enriquecer_test.go | 369 ++++++++++++++ internal/enriquecer/falsos_test.go | 205 ++++++++ internal/enriquecer/ferramentas.go | 163 ++++++ internal/enriquecer/fontes.go | 271 ++++++++++ internal/enriquecer/socket.go | 134 +++++ internal/enriquecer/socket_test.go | 220 ++++++++ internal/enriquecer/tokens.go | 288 +++++++++++ internal/enriquecer/trava_flock.go | 47 ++ internal/enriquecer/trava_outros.go | 10 + 25 files changed, 3321 insertions(+), 35 deletions(-) create mode 100644 internal/central/cache.go create mode 100644 internal/central/cache_test.go create mode 100644 internal/central/migrations/0002_enriquecimento.sql create mode 100644 internal/cli/daemon.go create mode 100644 internal/cli/daemon_test.go create mode 100644 internal/enriquecer/daemon.go create mode 100644 internal/enriquecer/enriquecedor.go create mode 100644 internal/enriquecer/enriquecer_test.go create mode 100644 internal/enriquecer/falsos_test.go create mode 100644 internal/enriquecer/ferramentas.go create mode 100644 internal/enriquecer/fontes.go create mode 100644 internal/enriquecer/socket.go create mode 100644 internal/enriquecer/socket_test.go create mode 100644 internal/enriquecer/tokens.go create mode 100644 internal/enriquecer/trava_flock.go create mode 100644 internal/enriquecer/trava_outros.go diff --git a/AGENTS.md b/AGENTS.md index 4b19bec..48d3fe7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,7 +17,8 @@ O CI (`.github/workflows/ci.yml`) roda os três. O teste de concorrência (`inte - `cmd/orq`: `main`. - `internal/cli`: comandos cobra e saída em texto. -- `internal/central`: store SQLite, migrations embutidas (`migrations/NNNN_*.sql`, aplicadas por `PRAGMA user_version`) e regras (status, onda, próxima US, pendências). +- `internal/central`: store SQLite, migrations embutidas (`migrations/NNNN_*.sql`, aplicadas por `PRAGMA user_version`) e regras (status, onda, próxima US, pendências), mais o cache dos enriquecedores (`cache.go`). +- `internal/enriquecer`: consultas ao Herdr, às forjas e ao Taiga, tokens de sidebar, cliente do socket do Herdr e o laço do `orq daemon`. - `internal/refs`: parse de `projeto/312`, `TG-312`, `TG-312#P2` e URL de MR. - `internal/output`: envelope de erro `{"error":{"code","cause","recovery"}}` e códigos de saída. - `docs/snapshot.md`: contrato do `orq snapshot`. @@ -25,7 +26,8 @@ O CI (`.github/workflows/ci.yml`) roda os três. O teste de concorrência (`inte ## Convenções - Mensagens, docs e códigos de erro em português; saída JSON fora de terminal, texto num terminal (`--output` força). -- Toda escrita no banco é uma transação curta (`BEGIN IMMEDIATE` via `_txlock`) e grava o seu evento no diário na mesma transação. +- Toda escrita no banco é uma transação curta (`BEGIN IMMEDIATE` via `_txlock`) e grava o seu evento no diário na mesma transação. A exceção é o que não é estado da Central: cache, suspensão e tokens publicados dos enriquecedores. A mudança de estado de uma MR é estado e gera `mr.estado`. +- Ferramenta externa (`herdr`, `glab`, `gh`, `taiga`) é chamada por caminho absoluto (`enriquecer.Ferramentas`), nunca pelo PATH, e só para ler — o daemon escreve no Herdr apenas os tokens `--source orq`. Falha de subprocesso vira `Erro` no cache; não derruba o processo. - A abertura do banco (criação, WAL, migrations) fica dentro do `flock` de `central.db.init.lock`: a troca para WAL num banco novo devolve `SQLITE_BUSY` na hora, sem passar pelo `busy_timeout`. - Migration nova é arquivo novo; nunca edite uma já publicada. - Mudança que remove ou muda o sentido de um campo do snapshot sobe `versao` e atualiza `docs/snapshot.md`. diff --git a/README.md b/README.md index 88d928e..b643a79 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Central de US para o orquestrador multiagente: CLI `orq` e plugin do [Herdr](htt Desenho e entregas: projeto `orq` no Taiga (https://agile.basis.com.br/project/orq). -Esta versão traz o estado da Central num SQLite e os comandos de registro. A consulta ao Herdr, ao GitLab e ao Taiga, o daemon, o TUI e o plugin chegam nas próximas entregas. +Esta versão traz o estado da Central num SQLite, os comandos de registro e o daemon que consulta o Herdr, as forjas (GitLab e GitHub) e o Taiga e publica o resumo na sidebar do Herdr. O TUI e o plugin chegam nas próximas entregas. ## Instalação @@ -56,8 +56,25 @@ Em `onda list` e `pend list`, `--coord` filtra pelo coordenador dono da onda; em | `orq pend list [--abertas] [--coord X] [--onda N]` | Lista as pendências. | | `orq diario [--us ] [--onda N] [--coord X] [--limite 50]` | Eventos gravados, do mais antigo ao mais novo (toda escrita gera um). | | `orq snapshot [--incluir-encerradas] [--json]` | Estado consolidado em JSON versionado (`--json` é aceito, mas a saída já é sempre JSON); contrato em [`docs/snapshot.md`](docs/snapshot.md). | +| `orq atualizar [--fonte herdr\|mr\|taiga] [--forcar]` | Consulta as fontes uma vez (todas, sem `--fonte`; repetível ou `mr,taiga`), grava o cache e, com o Herdr entre elas, confere os tokens de sidebar. Cada chave respeita o TTL; `--forcar` ignora o TTL e a suspensão por autenticação. | +| `orq daemon [--tick 5s] [--intervalo-remoto 15s]` | Mantém o cache e os tokens vivos, em primeiro plano, até SIGTERM/SIGINT. Um por máquina. | | `orq version` | Versão. | +### Daemon e enriquecedores + +`orq daemon` roda em primeiro plano (o plugin do Herdr o inicia; o log vai para o stderr) e só existe um por diretório de estado: ele segura um `flock` em `daemon.lock`, e o segundo sai com `daemon_ativo` (exit 4) dizendo o pid do primeiro. A cada tick ele lê o Herdr (`herdr api snapshot`) e confere os tokens; MR e Taiga são olhados a cada `--intervalo-remoto`, cada chave com o seu TTL (MR 60 s, Taiga 5 min). Ele também assina `pane.agent_status_changed` dos panes das ondas abertas no socket do Herdr (`$HERDR_SOCKET_PATH`, a sessão de `$HERDR_SESSION` ou `~/.config/herdr/herdr.sock`) e republica na hora; em `events_lost`, reassina e reconcilia com uma leitura completa. Ao sair, limpa os tokens que publicou. + +Só leituras nas forjas e no Taiga. Falha de uma consulta (binário ausente, autenticação, rede) fica no cache com o código e o snapshot mostra o último valor bom com o `erro`; nenhuma derruba o processo. Erro de autenticação suspende a ferramenta por 30 min: o orq não tenta login nem define token. + +As ferramentas são chamadas por caminho absoluto, porque num pane ou numa ação de plugin o PATH não tem `~/.local/bin`. A ordem: `ORQ_HERDR_BIN`, `ORQ_GLAB_BIN`, `ORQ_GH_BIN` e `ORQ_TAIGA_BIN`; para o herdr, `$HERDR_BIN_PATH`; e então `~/.local/bin`, `~/go/bin`, `~/bin`, `/home/linuxbrew/.linuxbrew/bin`, `/opt/homebrew/bin`, `/usr/local/bin`, `/usr/bin` e `/bin`. O PATH do processo não entra. + +Tokens de sidebar (`herdr pane|workspace report-metadata --source orq`), conferidos a cada tick contra o que o Herdr mostra — os tokens somem quando o servidor do Herdr reinicia, e o daemon republica: + +- `orq`, no pane de cada coordenador: as US das ondas abertas por status e as pendências abertas, ex. `2 Exec·1 Rev·1 pend`, com `⚠ ` na frente quando algum pane de US está `blocked`. Até 80 caracteres. +- `mrs_prontas`, no workspace do coordenador (o registrado ou o do pane dele): as MRs `pronta`, `!32` no GitLab e `skills#29` no GitHub; o que não cabe em 80 caracteres vira `+N`. + +Só pane e workspace que existem recebem token, e o que deixou de valer é limpo (`--clear-token`). + ### Ondas e a próxima US A onda declara até onde vai (`--ate`) e a ordem. `done` numa US quer dizer que ela chegou a esse limite: plano aprovado (`plano`), MR pronta para revisão humana, sem merge (`mr-pronta`), ou MR mesclada (`merge`). `orq onda proxima` devolve a autorização e o texto do limite junto com a US, para quem despacha saber até onde ir. diff --git a/docs/snapshot.md b/docs/snapshot.md index 023ae8f..4195a6f 100644 --- a/docs/snapshot.md +++ b/docs/snapshot.md @@ -1,6 +1,6 @@ # Contrato do `orq snapshot` (versão 1) -`orq snapshot` imprime, sempre em JSON, o estado consolidado da Central: coordenadores → ondas → US (com MRs e pendências), mais a lista das pendências abertas. É o que o TUI e o daemon consomem; o resto da CLI pode mudar de forma, este documento não sem subir a versão. +`orq snapshot` imprime, sempre em JSON, o estado consolidado da Central: coordenadores → ondas → US (com MRs e pendências), mais a lista das pendências abertas e o estado vivo que os enriquecedores trazem do Herdr, das forjas e do Taiga. É o que o TUI e o daemon consomem; o resto da CLI pode mudar de forma, este documento não sem subir a versão. - Por padrão, só as ondas abertas. `--incluir-encerradas` traz todas. - `orq snapshot --json`, a forma do desenho, também vale: a flag é aceita e não muda nada. `--output text` é ignorado. @@ -13,6 +13,25 @@ - Datas em RFC 3339, UTC, com segundos (`2026-10-11T14:03:40Z`). - Ordem: coordenadores por nome; ondas por id; US pela posição na onda; MRs pela ordem em que foram ligadas; pendências pela abertura. +### Histórico + +- Versão 1, US #3: o esqueleto. +- Ainda versão 1, US #4: os campos `herdr` (coordenador e US), `taiga` (US), `atualizado_em` e `erro` (MR) e `fontes` (raiz) entraram, e `MR.estado` passou a ser preenchido. Tudo aditivo: `estado` já estava declarado como `draft|aberta|pronta|mesclada|fechada`, e quem lia a versão 1 continua lendo. + +## Estado vivo (US #4) + +O snapshot não consulta nada: ele lê o cache que `orq daemon` (em laço) e `orq atualizar` (uma vez) gravam. Cada fonte guarda o último valor bom e o erro da última consulta, então um valor pode estar velho e com `erro` ao lado; quem desenha decide pelo `atualizado_em`. + +| Fonte | O que lê | Cadência no daemon | +|---|---|---| +| `herdr` | `herdr api snapshot`: status do agente, nome e workspace de cada pane, tokens | a cada tick (5 s) e a cada `pane.agent_status_changed` | +| `mr` | `glab api` (GitLab) ou `gh api` (GitHub), só leitura | TTL de 60 s por MR | +| `taiga` | `taiga story get --output json`, só leitura | TTL de 5 min por US | + +Estado da MR: `mesclada`, `fechada`, `draft`, `pronta` (aberta, não draft e mesclável: `detailed_merge_status` `mergeable` no GitLab, `mergeable_state` `clean` ou `has_hooks` no GitHub) ou `aberta` (o resto: conflito, pipeline, revisão obrigatória, ainda não calculado). Mudança de estado gera `mr.estado` no diário. + +Erro de autenticação de `glab`, `gh` ou `taiga` suspende a ferramenta por 30 min (ou até `orq atualizar --forcar`): o orq não tenta login nem define token. A suspensão aparece em `fontes..erro`. + ## Esquema ```text @@ -20,7 +39,31 @@ "versao": 1, "gerado_em": data, "coordenadores": [Coordenador], - "pendencias_abertas": [Pendencia] # todas as abertas, de todos os coordenadores + "pendencias_abertas": [Pendencia], # todas as abertas, de todos os coordenadores + "fontes": {"herdr": Fonte, "mr": Fonte, "taiga": Fonte} +} + +Fonte { + "consultado_em": data|null, # última consulta, boa ou não + "atualizado_em": data|null, # último valor bom + "erro": Erro|null # o que vale para a fonte inteira: falha do Herdr ou ferramenta suspensa +} + +Erro { + "codigo": "binario_ausente"|"autenticacao"|"nao_encontrado"|"tempo_esgotado"|"falha_comando"|"resposta_invalida", + "mensagem": string, # da ferramenta, cortada + "em": data # na fonte, o início da suspensão +} + +Herdr { # estado vivo de um pane + "status": "idle"|"working"|"blocked"|"done"|"unknown"|"ausente"|null, + # unknown: pane sem agente; ausente: o Herdr respondeu e o pane não existe; + # null: o Herdr nunca respondeu + "agente": string|null, # "claude", "codex" + "nome": string|null, # nome do agente no Herdr + "workspace_id": string|null, + "atualizado_em": data|null, + "erro": Erro|null # a última consulta ao Herdr falhou: o resto é o último valor bom } Coordenador { @@ -29,6 +72,7 @@ Coordenador { "workspace_id": string|null, "projeto_padrao": string|null, # slug do Taiga usado em TG- sem projeto "registrado_em": data, # último registro + "herdr": Herdr|null, # nulo sem pane_id "ondas": [Onda] } @@ -57,7 +101,15 @@ US { "rodada": int|null, "atualizado_em": data, "mrs": [MR], - "pendencias": [Pendencia] # abertas e resolvidas + "pendencias": [Pendencia], # abertas e resolvidas + "herdr": Herdr|null, # nulo sem pane_id + "taiga": { + "titulo": string|null, + "status": string|null, # nome do status no Taiga, "In progress" + "url": string|null, + "atualizado_em": data|null, + "erro": Erro|null + } } MR { @@ -65,8 +117,10 @@ MR { "projeto_path": string, # "basis/iac/plataforma" ou "BasisTI/orq" "iid": int, "iac": bool, # projeto sob basis/iac/ - "estado": string|null, # nulo até os enriquecedores (US #4); depois draft|aberta|pronta|mesclada|fechada - "adicionada_em": data + "estado": "draft"|"aberta"|"pronta"|"mesclada"|"fechada"|null, # nulo até a primeira consulta boa + "adicionada_em": data, + "atualizado_em": data|null, # última consulta boa à forja + "erro": Erro|null # última consulta, se falhou } Pendencia { @@ -100,6 +154,14 @@ Pendencia { "workspace_id": null, "projeto_padrao": "colaboradados", "registrado_em": "2026-10-11T14:00:00Z", + "herdr": { + "status": "working", + "agente": "claude", + "nome": null, + "workspace_id": "wB", + "atualizado_em": "2026-10-11T14:09:58Z", + "erro": null + }, "ondas": [ { "id": 1, @@ -129,8 +191,10 @@ Pendencia { "projeto_path": "basis/colaboradados/api", "iid": 41, "iac": false, - "estado": null, - "adicionada_em": "2026-10-11T14:05:00Z" + "estado": "pronta", + "adicionada_em": "2026-10-11T14:05:00Z", + "atualizado_em": "2026-10-11T14:09:30Z", + "erro": null } ], "pendencias": [ @@ -148,7 +212,22 @@ Pendencia { "onda_id": 1, "coordenador": "colaboradados" } - ] + ], + "herdr": { + "status": "blocked", + "agente": "claude", + "nome": "tg215-exec", + "workspace_id": "wB", + "atualizado_em": "2026-10-11T14:09:58Z", + "erro": null + }, + "taiga": { + "titulo": "Exportar a folha em CSV", + "status": "In progress", + "url": "https://agile.basis.com.br/project/colaboradados/us/215", + "atualizado_em": "2026-10-11T14:06:00Z", + "erro": null + } } ] } @@ -170,6 +249,15 @@ Pendencia { "onda_id": 1, "coordenador": "colaboradados" } - ] + ], + "fontes": { + "herdr": {"consultado_em": "2026-10-11T14:09:58Z", "atualizado_em": "2026-10-11T14:09:58Z", "erro": null}, + "mr": {"consultado_em": "2026-10-11T14:09:30Z", "atualizado_em": "2026-10-11T14:09:30Z", "erro": null}, + "taiga": { + "consultado_em": "2026-10-11T14:06:00Z", + "atualizado_em": "2026-10-11T14:06:00Z", + "erro": {"codigo": "autenticacao", "mensagem": "taiga suspenso: taiga: session_expired", "em": "2026-10-11T14:08:00Z"} + } + } } ``` diff --git a/internal/central/cache.go b/internal/central/cache.go new file mode 100644 index 0000000..abb8cc1 --- /dev/null +++ b/internal/central/cache.go @@ -0,0 +1,469 @@ +package central + +import ( + "context" + "database/sql" + "encoding/json" + "sort" + "strings" +) + +// Fontes dos enriquecedores, na ordem em que aparecem no snapshot. +const ( + FonteHerdr = "herdr" + FonteMR = "mr" + FonteTaiga = "taiga" +) + +// Fontes lista as fontes conhecidas. +var Fontes = []string{FonteHerdr, FonteMR, FonteTaiga} + +// Erro é a última falha de uma consulta: código estável, mensagem da ferramenta e quando foi. +type Erro struct { + Codigo string `json:"codigo"` + Mensagem string `json:"mensagem"` + Em string `json:"em"` +} + +// Cache é uma linha do cache de uma fonte: o último valor bom (Valor nil se nunca houve) e, +// quando a última consulta falhou, o erro dela. +type Cache struct { + Chave string + Valor json.RawMessage + ValorEm *string + ConsultadoEm string + Erro *Erro +} + +// SessaoHerdr é o valor em cache do Herdr: os panes e workspaces que existiam na consulta. +type SessaoHerdr struct { + Panes map[string]PaneHerdr `json:"panes"` + Workspaces map[string]WorkspaceHerdr `json:"workspaces"` +} + +// PaneHerdr é o que o orq guarda de um pane. Status é o agent_status do Herdr (unknown num +// pane sem agente). +type PaneHerdr struct { + Status string `json:"status"` + Agente string `json:"agente,omitempty"` + Nome string `json:"nome,omitempty"` + WorkspaceID string `json:"workspace_id"` + Tokens map[string]string `json:"tokens,omitempty"` +} + +// WorkspaceHerdr é o que o orq guarda de um workspace. +type WorkspaceHerdr struct { + Tokens map[string]string `json:"tokens,omitempty"` +} + +// ValorMR é o valor em cache de uma MR. +type ValorMR struct { + Estado string `json:"estado"` + WebURL string `json:"web_url,omitempty"` +} + +// ValorTaiga é o valor em cache de uma US no Taiga. +type ValorTaiga struct { + Titulo string `json:"titulo"` + Status string `json:"status"` + URL string `json:"url"` +} + +// LerCache devolve as linhas de cache da fonte, por chave. +func (s *Store) LerCache(ctx context.Context, fonte string) (map[string]Cache, error) { + out := map[string]Cache{} + err := s.ler(ctx, func(tx *sql.Tx) error { + var err error + out, err = lerCache(ctx, tx, fonte) + return err + }) + return out, err +} + +func lerCache(ctx context.Context, q querier, fonte string) (map[string]Cache, error) { + rows, err := q.QueryContext(ctx, `SELECT chave, valor, valor_em, consultado_em, erro_codigo, erro_mensagem, erro_em FROM cache WHERE fonte = ?`, fonte) + if err != nil { + return nil, err + } + defer rows.Close() + out := map[string]Cache{} + for rows.Next() { + var c Cache + var valor, codigo, mensagem, em *string + if err := rows.Scan(&c.Chave, &valor, &c.ValorEm, &c.ConsultadoEm, &codigo, &mensagem, &em); err != nil { + return nil, err + } + if valor != nil { + c.Valor = json.RawMessage(*valor) + } + c.Erro = erroDe(codigo, mensagem, em) + out[c.Chave] = c + } + return out, rows.Err() +} + +func erroDe(codigo, mensagem, em *string) *Erro { + if codigo == nil { + return nil + } + e := &Erro{Codigo: *codigo} + if mensagem != nil { + e.Mensagem = *mensagem + } + if em != nil { + e.Em = *em + } + return e +} + +// GravarCache registra uma consulta. Com erro, o valor anterior fica (é o último valor bom); +// sem erro, valor substitui o anterior e o erro some. Cache não é estado da Central e não gera +// evento. +func (s *Store) GravarCache(ctx context.Context, fonte, chave string, valor any, erro *Erro) error { + return s.escrever(ctx, func(tx *sql.Tx) error { return s.gravarCache(ctx, tx, fonte, chave, valor, erro) }) +} + +func (s *Store) gravarCache(ctx context.Context, tx *sql.Tx, fonte, chave string, valor any, erro *Erro) error { + agora := s.agora() + if erro != nil { + _, err := tx.ExecContext(ctx, `INSERT INTO cache (fonte, chave, consultado_em, erro_codigo, erro_mensagem, erro_em) VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT (fonte, chave) DO UPDATE SET consultado_em = excluded.consultado_em, + erro_codigo = excluded.erro_codigo, erro_mensagem = excluded.erro_mensagem, erro_em = excluded.erro_em`, + fonte, chave, agora, erro.Codigo, limitar(erro.Mensagem, 500), agora) + return err + } + b, err := json.Marshal(valor) + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, `INSERT INTO cache (fonte, chave, valor, valor_em, consultado_em) VALUES (?, ?, ?, ?, ?) + ON CONFLICT (fonte, chave) DO UPDATE SET valor = excluded.valor, valor_em = excluded.valor_em, + consultado_em = excluded.consultado_em, erro_codigo = NULL, erro_mensagem = NULL, erro_em = NULL`, + fonte, chave, string(b), agora, agora) + return err +} + +// GravarMR registra a consulta de uma MR e, sem erro, passa o estado para as linhas da MR. A +// mudança de estado é da Central: cada US com a MR ganha um evento mr.estado. +func (s *Store) GravarMR(ctx context.Context, url string, valor ValorMR, erro *Erro) error { + return s.escrever(ctx, func(tx *sql.Tx) error { + if erro != nil { + return s.gravarCache(ctx, tx, FonteMR, url, nil, erro) + } + if err := s.gravarCache(ctx, tx, FonteMR, url, valor, nil); err != nil { + return err + } + rows, err := tx.QueryContext(ctx, `SELECT mr.id, mr.projeto, mr.ref, mr.estado, onda.id, onda.coordenador FROM mr + JOIN us ON us.projeto = mr.projeto AND us.ref = mr.ref JOIN onda ON onda.id = us.onda_id + WHERE mr.url = ? AND (mr.estado IS NULL OR mr.estado <> ?)`, url, valor.Estado) + if err != nil { + return err + } + type mudanca struct { + id int64 + projeto string + ref int + de *string + onda int64 + coord string + } + var ms []mudanca + for rows.Next() { + var m mudanca + if err := rows.Scan(&m.id, &m.projeto, &m.ref, &m.de, &m.onda, &m.coord); err != nil { + rows.Close() + return err + } + ms = append(ms, m) + } + if err := rows.Close(); err != nil { + return err + } + for _, m := range ms { + if _, err := tx.ExecContext(ctx, `UPDATE mr SET estado = ? WHERE id = ?`, valor.Estado, m.id); err != nil { + return err + } + if err := s.registrar(ctx, tx, evento{coordenador: m.coord, ondaID: m.onda, projeto: m.projeto, ref: m.ref, tipo: "mr.estado", + detalhe: map[string]any{"url": url, "de": m.de, "para": valor.Estado}}); err != nil { + return err + } + } + return nil + }) +} + +// Suspender para as chamadas a uma ferramenta depois de erro de autenticação. +func (s *Store) Suspender(ctx context.Context, ferramenta string, e Erro) error { + return s.escrever(ctx, func(tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, `INSERT INTO suspensao (ferramenta, codigo, mensagem, desde) VALUES (?, ?, ?, ?) + ON CONFLICT (ferramenta) DO UPDATE SET codigo = excluded.codigo, mensagem = excluded.mensagem, desde = excluded.desde`, + ferramenta, e.Codigo, limitar(e.Mensagem, 500), s.agora()) + return err + }) +} + +// Retomar tira a suspensão da ferramenta. +func (s *Store) Retomar(ctx context.Context, ferramenta string) error { + return s.escrever(ctx, func(tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, `DELETE FROM suspensao WHERE ferramenta = ?`, ferramenta) + return err + }) +} + +// Suspensoes devolve as ferramentas suspensas; Erro.Em é o início da suspensão. +func (s *Store) Suspensoes(ctx context.Context) (map[string]Erro, error) { + var out map[string]Erro + err := s.ler(ctx, func(tx *sql.Tx) error { + var err error + out, err = suspensoes(ctx, tx) + return err + }) + return out, err +} + +func suspensoes(ctx context.Context, q querier) (map[string]Erro, error) { + rows, err := q.QueryContext(ctx, `SELECT ferramenta, codigo, mensagem, desde FROM suspensao ORDER BY ferramenta`) + if err != nil { + return nil, err + } + defer rows.Close() + out := map[string]Erro{} + for rows.Next() { + var f string + var e Erro + if err := rows.Scan(&f, &e.Codigo, &e.Mensagem, &e.Em); err != nil { + return nil, err + } + out[f] = e + } + return out, rows.Err() +} + +// Token é um token de sidebar que o orq publicou num pane ou workspace do Herdr. +type Token struct { + Alvo string `json:"alvo"` // pane ou workspace + AlvoID string `json:"alvo_id"` + Nome string `json:"nome"` + Valor string `json:"valor"` +} + +// TokensPublicados devolve os tokens que o orq publicou e ainda não limpou. +func (s *Store) TokensPublicados(ctx context.Context) ([]Token, error) { + ts := []Token{} + err := s.ler(ctx, func(tx *sql.Tx) error { + rows, err := tx.QueryContext(ctx, `SELECT alvo, alvo_id, nome, valor FROM token_publicado ORDER BY alvo, alvo_id, nome`) + if err != nil { + return err + } + defer rows.Close() + for rows.Next() { + var t Token + if err := rows.Scan(&t.Alvo, &t.AlvoID, &t.Nome, &t.Valor); err != nil { + return err + } + ts = append(ts, t) + } + return rows.Err() + }) + return ts, err +} + +// MarcarToken registra que o token foi publicado com esse valor. +func (s *Store) MarcarToken(ctx context.Context, t Token) error { + return s.escrever(ctx, func(tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, `INSERT INTO token_publicado (alvo, alvo_id, nome, valor, publicado_em) VALUES (?, ?, ?, ?, ?) + ON CONFLICT (alvo, alvo_id, nome) DO UPDATE SET valor = excluded.valor, publicado_em = excluded.publicado_em`, + t.Alvo, t.AlvoID, t.Nome, t.Valor, s.agora()) + return err + }) +} + +// DesmarcarToken esquece um token limpo (ou cujo alvo sumiu). +func (s *Store) DesmarcarToken(ctx context.Context, t Token) error { + return s.escrever(ctx, func(tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, `DELETE FROM token_publicado WHERE alvo = ? AND alvo_id = ? AND nome = ?`, t.Alvo, t.AlvoID, t.Nome) + return err + }) +} + +// Herdr é o estado vivo de um pane no snapshot. +type Herdr struct { + Status *string `json:"status"` + Agente *string `json:"agente"` + Nome *string `json:"nome"` + WorkspaceID *string `json:"workspace_id"` + AtualizadoEm *string `json:"atualizado_em"` + Erro *Erro `json:"erro"` +} + +// Taiga é o que o Taiga diz de uma US no snapshot. +type Taiga struct { + Titulo *string `json:"titulo"` + Status *string `json:"status"` + URL *string `json:"url"` + AtualizadoEm *string `json:"atualizado_em"` + Erro *Erro `json:"erro"` +} + +// Fonte resume uma fonte no snapshot. +type Fonte struct { + ConsultadoEm *string `json:"consultado_em"` + AtualizadoEm *string `json:"atualizado_em"` + Erro *Erro `json:"erro"` +} + +// herdrCache é a sessão do Herdr em cache, lida uma vez por leitura. +type herdrCache struct { + sessao *SessaoHerdr + linha *Cache +} + +func lerHerdr(ctx context.Context, q querier) (herdrCache, error) { + cs, err := lerCache(ctx, q, FonteHerdr) + if err != nil { + return herdrCache{}, err + } + c, ok := cs[""] + if !ok { + return herdrCache{}, nil + } + h := herdrCache{linha: &c} + if c.Valor != nil { + var s SessaoHerdr + if json.Unmarshal(c.Valor, &s) == nil { + h.sessao = &s + } + } + return h, nil +} + +// pane devolve o estado vivo de um pane: nulo sem pane, ausente quando o Herdr respondeu e o +// pane não estava lá. +func (h herdrCache) pane(id *string) *Herdr { + if id == nil { + return nil + } + out := &Herdr{} + if h.linha == nil { + return out + } + out.AtualizadoEm, out.Erro = h.linha.ValorEm, h.linha.Erro + if h.sessao == nil { + return out + } + p, ok := h.sessao.Panes[*id] + if !ok { + out.Status = ptr("ausente") + return out + } + out.Status, out.WorkspaceID = ptr(p.Status), ptr(p.WorkspaceID) + if p.Agente != "" { + out.Agente = ptr(p.Agente) + } + if p.Nome != "" { + out.Nome = ptr(p.Nome) + } + return out +} + +func carregarTaiga(ctx context.Context, q querier, chave string) (Taiga, error) { + var t Taiga + var valor, codigo, mensagem, em *string + err := q.QueryRowContext(ctx, `SELECT valor, valor_em, erro_codigo, erro_mensagem, erro_em FROM cache WHERE fonte = ? AND chave = ?`, FonteTaiga, chave). + Scan(&valor, &t.AtualizadoEm, &codigo, &mensagem, &em) + if semLinha(err) { + return t, nil + } + if err != nil { + return t, err + } + t.Erro = erroDe(codigo, mensagem, em) + if valor != nil { + var v ValorTaiga + if json.Unmarshal([]byte(*valor), &v) == nil { + t.Titulo, t.Status, t.URL = ptr(v.Titulo), ptr(v.Status), ptr(v.URL) + } + } + return t, nil +} + +// carregarFontes resume cada fonte: a consulta mais recente, o valor bom mais recente e o erro +// que vale para a fonte inteira (o do Herdr, ou a suspensão de uma ferramenta). +func carregarFontes(ctx context.Context, q querier) (map[string]Fonte, error) { + out := map[string]Fonte{} + for _, f := range Fontes { + var consultado, atualizado *string + if err := q.QueryRowContext(ctx, `SELECT max(consultado_em), max(valor_em) FROM cache WHERE fonte = ?`, f).Scan(&consultado, &atualizado); err != nil { + return nil, err + } + out[f] = Fonte{ConsultadoEm: consultado, AtualizadoEm: atualizado} + } + h, err := lerHerdr(ctx, q) + if err != nil { + return nil, err + } + if h.linha != nil && h.linha.Erro != nil { + f := out[FonteHerdr] + f.Erro = h.linha.Erro + out[FonteHerdr] = f + } + sus, err := suspensoes(ctx, q) + if err != nil { + return nil, err + } + nomes := make([]string, 0, len(sus)) + for n := range sus { + nomes = append(nomes, n) + } + sort.Strings(nomes) + for _, ferramenta := range nomes { + e := sus[ferramenta] + fonte := FonteDaFerramenta(ferramenta) + if f := out[fonte]; f.Erro == nil { + e.Mensagem = ferramenta + " suspenso: " + e.Mensagem + f.Erro = &e + out[fonte] = f + } + } + return out, nil +} + +// FonteDaFerramenta diz a que fonte uma ferramenta serve. +func FonteDaFerramenta(ferramenta string) string { + switch ferramenta { + case "glab", "gh": + return FonteMR + case "taiga": + return FonteTaiga + } + return FonteHerdr +} + +func ptr(s string) *string { return &s } + +// limitar corta s em n bytes sem partir runa. +func limitar(s string, n int) string { + s = strings.TrimSpace(s) + if len(s) <= n { + return s + } + for n > 0 && (s[n]&0xC0) == 0x80 { + n-- + } + return s[:n] +} + +// LerSessaoHerdr devolve a última sessão boa do Herdr (nil se nunca houve) e o erro da última +// consulta, quando ela falhou. +func (s *Store) LerSessaoHerdr(ctx context.Context) (*SessaoHerdr, *Erro, error) { + var h herdrCache + err := s.ler(ctx, func(tx *sql.Tx) error { + var err error + h, err = lerHerdr(ctx, tx) + return err + }) + if err != nil || h.linha == nil { + return nil, nil, err + } + return h.sessao, h.linha.Erro, nil +} diff --git a/internal/central/cache_test.go b/internal/central/cache_test.go new file mode 100644 index 0000000..b8d1bdd --- /dev/null +++ b/internal/central/cache_test.go @@ -0,0 +1,117 @@ +package central + +import ( + "context" + "testing" + + "github.com/BasisTI/orq/internal/refs" +) + +func TestCacheGuardaUltimoValorBom(t *testing.T) { + s := novoStore(t) + ctx := context.Background() + if err := s.GravarCache(ctx, FonteTaiga, "orq/4", ValorTaiga{Titulo: "t", Status: "New", URL: "u"}, nil); err != nil { + t.Fatal(err) + } + if err := s.GravarCache(ctx, FonteTaiga, "orq/4", nil, &Erro{Codigo: "rede", Mensagem: "caiu"}); err != nil { + t.Fatal(err) + } + cs, err := s.LerCache(ctx, FonteTaiga) + if err != nil { + t.Fatal(err) + } + c := cs["orq/4"] + if string(c.Valor) != `{"titulo":"t","status":"New","url":"u"}` || c.Erro == nil || c.Erro.Codigo != "rede" || c.ValorEm == nil || *c.ValorEm == c.ConsultadoEm { + t.Fatalf("cache = %+v %+v", c, c.Erro) + } + // Consulta boa de novo apaga o erro. + _ = s.GravarCache(ctx, FonteTaiga, "orq/4", ValorTaiga{Titulo: "t2"}, nil) + cs, _ = s.LerCache(ctx, FonteTaiga) + if cs["orq/4"].Erro != nil || string(cs["orq/4"].Valor) != `{"titulo":"t2","status":"","url":""}` { + t.Fatalf("cache = %+v", cs["orq/4"]) + } +} + +func TestGravarMRMudaEstadoComEvento(t *testing.T) { + s := novoStore(t) + ctx := context.Background() + a := coord(t, s, "a", "p1", "proj") + onda(t, s, a, NovaOnda{Titulo: "o", US: []refs.US{us(t, "1")}}) + url := "https://github.com/BasisTI/orq/pull/1" + if _, _, err := s.AdicionarMR(ctx, &a, us(t, "1"), url); err != nil { + t.Fatal(err) + } + antes := len(eventos(t, s)) + for _, e := range []string{"aberta", "aberta", "mesclada"} { + if err := s.GravarMR(ctx, url, ValorMR{Estado: e}, nil); err != nil { + t.Fatal(err) + } + } + // Erro depois não apaga o estado. + if err := s.GravarMR(ctx, url, ValorMR{}, &Erro{Codigo: "autenticacao", Mensagem: "401"}); err != nil { + t.Fatal(err) + } + es := eventos(t, s)[antes:] + if len(es) != 2 || es[0].Tipo != "mr.estado" || string(es[1].Detalhe) != `{"de":"aberta","para":"mesclada","url":"`+url+`"}` { + t.Fatalf("eventos = %+v", es) + } + snap, _ := s.Snapshot(ctx, false) + m := snap.Coordenadores[0].Ondas[0].US[0].MRs[0] + if m.Estado == nil || *m.Estado != "mesclada" || m.Erro == nil || m.Erro.Codigo != "autenticacao" || m.AtualizadoEm == nil { + t.Fatalf("mr = %+v", m) + } +} + +func TestSnapshotEnriquecido(t *testing.T) { + s := novoStore(t) + ctx := context.Background() + a := coord(t, s, "a", "w1:p1", "proj") + coord(t, s, "b", "w9:p9", "") + onda(t, s, a, NovaOnda{Titulo: "o", US: []refs.US{us(t, "1"), us(t, "2"), us(t, "3")}}) + p2, p3 := "w1:p2", "w1:p3" + if _, _, err := s.AtualizarStatus(ctx, &a, us(t, "1"), MudancaStatus{Status: "exec", Pane: &p2}); err != nil { + t.Fatal(err) + } + if _, _, err := s.AtualizarStatus(ctx, &a, us(t, "2"), MudancaStatus{Status: "exec", Pane: &p3}); err != nil { + t.Fatal(err) + } + + // Antes de qualquer consulta: objetos presentes, valores nulos. + snap, _ := s.Snapshot(ctx, false) + u := snap.Coordenadores[0].Ondas[0].US + if u[0].Herdr == nil || u[0].Herdr.Status != nil || u[2].Herdr != nil || u[0].Taiga.Titulo != nil { + t.Fatalf("sem cache: %+v %+v", u[0].Herdr, u[2].Herdr) + } + sessao := SessaoHerdr{ + Panes: map[string]PaneHerdr{"w1:p1": {Status: "working", Agente: "claude", WorkspaceID: "w1"}, "w1:p2": {Status: "blocked", Agente: "claude", Nome: "exec", WorkspaceID: "w1"}}, + Workspaces: map[string]WorkspaceHerdr{"w1": {}}, + } + _ = s.GravarCache(ctx, FonteHerdr, "", sessao, nil) + _ = s.GravarCache(ctx, FonteTaiga, "proj/1", ValorTaiga{Titulo: "Título", Status: "In progress", URL: "https://x/us/1"}, nil) + _ = s.Suspender(ctx, "taiga", Erro{Codigo: "autenticacao", Mensagem: "session_expired"}) + // Herdr caiu depois: fica o último valor bom com o erro. + _ = s.GravarCache(ctx, FonteHerdr, "", nil, &Erro{Codigo: "falha_comando", Mensagem: "socket"}) + + snap, _ = s.Snapshot(ctx, false) + c := snap.Coordenadores[0] + u = c.Ondas[0].US + if c.Herdr == nil || *c.Herdr.Status != "working" || c.Herdr.Erro == nil { + t.Fatalf("coord herdr = %+v", c.Herdr) + } + if *u[0].Herdr.Status != "blocked" || *u[0].Herdr.Nome != "exec" || *u[0].Herdr.WorkspaceID != "w1" { + t.Fatalf("us1 herdr = %+v", u[0].Herdr) + } + if *u[1].Herdr.Status != "ausente" || u[1].Herdr.Agente != nil { + t.Fatalf("pane que sumiu devia ser ausente: %+v", u[1].Herdr) + } + if *snap.Coordenadores[1].Herdr.Status != "ausente" { + t.Fatal("coordenador b sem pane no Herdr devia ser ausente") + } + if *u[0].Taiga.Titulo != "Título" || *u[0].Taiga.Status != "In progress" || u[1].Taiga.Titulo != nil { + t.Fatalf("taiga = %+v / %+v", u[0].Taiga, u[1].Taiga) + } + f := snap.Fontes + if f["herdr"].Erro == nil || f["herdr"].AtualizadoEm == nil || f["taiga"].Erro == nil || f["taiga"].Erro.Codigo != "autenticacao" || f["mr"].ConsultadoEm != nil { + t.Fatalf("fontes = %+v", f) + } +} diff --git a/internal/central/central_test.go b/internal/central/central_test.go index b2aa65d..e4eb59a 100644 --- a/internal/central/central_test.go +++ b/internal/central/central_test.go @@ -79,7 +79,7 @@ func TestOpenReabreSemRemigrar(t *testing.T) { } var v int var modo string - if err := s.db.QueryRow("PRAGMA user_version").Scan(&v); err != nil || v != 1 { + if err := s.db.QueryRow("PRAGMA user_version").Scan(&v); err != nil || v != 2 { t.Fatalf("user_version = %d, %v", v, err) } if err := s.db.QueryRow("PRAGMA journal_mode").Scan(&modo); err != nil || modo != "wal" { @@ -604,10 +604,14 @@ func TestSnapshot(t *testing.T) { v any want string }{ - {"raiz", m, "coordenadores,gerado_em,pendencias_abertas,versao"}, - {"coordenador", coordJ, "nome,ondas,pane_id,projeto_padrao,registrado_em,workspace_id"}, + {"raiz", m, "coordenadores,fontes,gerado_em,pendencias_abertas,versao"}, + {"fontes", m["fontes"], "herdr,mr,taiga"}, + {"fonte", m["fontes"].(map[string]any)["mr"], "atualizado_em,consultado_em,erro"}, + {"coordenador", coordJ, "herdr,nome,ondas,pane_id,projeto_padrao,registrado_em,workspace_id"}, + {"herdr", coordJ["herdr"], "agente,atualizado_em,erro,nome,status,workspace_id"}, {"onda", ondaJ, "autorizacao_ate,coordenador,criada_em,encerrada_em,id,ordem,titulo,us"}, - {"us", usJ, "atualizado_em,chave,depende_de,id,mrs,onda_id,pane_id,papel,pendencias,posicao,projeto,ref,rodada,status"}, + {"us", usJ, "atualizado_em,chave,depende_de,herdr,id,mrs,onda_id,pane_id,papel,pendencias,posicao,projeto,ref,rodada,status,taiga"}, + {"taiga", usJ["taiga"], "atualizado_em,erro,status,titulo,url"}, {"pendencia", pendJ, "aberta,aberta_em,coordenador,id,numero,onda_id,pane_origem,resolvida_em,resposta,texto,tipo,us"}, } for _, c := range contrato { @@ -620,7 +624,7 @@ func TestSnapshot(t *testing.T) { mb, _ := json.Marshal(comMR.Coordenadores[0].Ondas[0].US[0].MRs[0]) var mm map[string]any _ = json.Unmarshal(mb, &mm) - if got := strings.Join(chaves(mm), ","); got != "adicionada_em,estado,iac,iid,projeto_path,url" { + if got := strings.Join(chaves(mm), ","); got != "adicionada_em,atualizado_em,erro,estado,iac,iid,projeto_path,url" { t.Errorf("chaves de mr = %s", got) } todas, _ := s.Snapshot(ctx, true) diff --git a/internal/central/migrations/0002_enriquecimento.sql b/internal/central/migrations/0002_enriquecimento.sql new file mode 100644 index 0000000..7237906 --- /dev/null +++ b/internal/central/migrations/0002_enriquecimento.sql @@ -0,0 +1,32 @@ +-- Cache dos enriquecedores (US #4): o último valor bom de cada consulta e o último erro. +-- Não é estado da Central: gravar aqui não gera evento no diário. +CREATE TABLE cache ( + fonte TEXT NOT NULL CHECK (fonte IN ('herdr', 'mr', 'taiga')), + -- '' no herdr (a sessão inteira), a URL normalizada na mr, projeto/ref no taiga. + chave TEXT NOT NULL, + valor TEXT, + valor_em TEXT, + consultado_em TEXT NOT NULL, + erro_codigo TEXT, + erro_mensagem TEXT, + erro_em TEXT, + PRIMARY KEY (fonte, chave) +); + +-- Ferramenta suspensa por erro de autenticação: o orq não tenta login, só para de chamar. +CREATE TABLE suspensao ( + ferramenta TEXT PRIMARY KEY, + codigo TEXT NOT NULL, + mensagem TEXT NOT NULL, + desde TEXT NOT NULL +); + +-- Tokens de sidebar que o orq publicou no Herdr, para limpar o que deixou de valer. +CREATE TABLE token_publicado ( + alvo TEXT NOT NULL CHECK (alvo IN ('pane', 'workspace')), + alvo_id TEXT NOT NULL, + nome TEXT NOT NULL, + valor TEXT NOT NULL, + publicado_em TEXT NOT NULL, + PRIMARY KEY (alvo, alvo_id, nome) +); diff --git a/internal/central/mr.go b/internal/central/mr.go index 55ed44e..278ccbc 100644 --- a/internal/central/mr.go +++ b/internal/central/mr.go @@ -7,8 +7,8 @@ import ( "github.com/BasisTI/orq/internal/refs" ) -// MR é uma merge request (ou pull request) ligada a uma US. Estado fica nulo até os -// enriquecedores da US #4 consultarem a forja. +// MR é uma merge request (ou pull request) ligada a uma US. Estado fica nulo até o enriquecedor +// consultar a forja e, depois, guarda o último estado bom. type MR struct { URL string `json:"url"` ProjetoPath string `json:"projeto_path"` @@ -16,6 +16,20 @@ type MR struct { IaC bool `json:"iac"` Estado *string `json:"estado"` AdicionadaEm string `json:"adicionada_em"` + AtualizadoEm *string `json:"atualizado_em"` // última consulta bem-sucedida à forja + Erro *Erro `json:"erro"` // última consulta, se falhou +} + +// colunasMR lê a MR com o cache dela; scanMR casa com a ordem. +const colunasMR = `SELECT mr.url, mr.projeto_path, mr.iid, mr.iac, mr.estado, mr.adicionada_em, c.valor_em, c.erro_codigo, c.erro_mensagem, c.erro_em + FROM mr LEFT JOIN cache c ON c.fonte = 'mr' AND c.chave = mr.url ` + +func scanMR(r scanner) (MR, error) { + var m MR + var codigo, mensagem, em *string + err := r.Scan(&m.URL, &m.ProjetoPath, &m.IID, &m.IaC, &m.Estado, &m.AdicionadaEm, &m.AtualizadoEm, &codigo, &mensagem, &em) + m.Erro = erroDe(codigo, mensagem, em) + return m, err } // AdicionarMR liga a MR à US. A mesma URL de novo não muda nada (Criada = false, sem evento). @@ -48,8 +62,9 @@ func (s *Store) AdicionarMR(ctx context.Context, coord *Coordenador, ref refs.US } var out MR err = s.ler(ctx, func(tx *sql.Tx) error { - return tx.QueryRowContext(ctx, `SELECT url, projeto_path, iid, iac, estado, adicionada_em FROM mr WHERE projeto = ? AND ref = ? AND url = ?`, alvo.Projeto, alvo.Ref, m.URL). - Scan(&out.URL, &out.ProjetoPath, &out.IID, &out.IaC, &out.Estado, &out.AdicionadaEm) + var err error + out, err = scanMR(tx.QueryRowContext(ctx, colunasMR+`WHERE mr.projeto = ? AND mr.ref = ? AND mr.url = ?`, alvo.Projeto, alvo.Ref, m.URL)) + return err }) return out, criada, err } @@ -66,8 +81,7 @@ func (s *Store) RemoverMR(ctx context.Context, coord *Coordenador, ref refs.US, if err != nil { return err } - err = tx.QueryRowContext(ctx, `SELECT url, projeto_path, iid, iac, estado, adicionada_em FROM mr WHERE projeto = ? AND ref = ? AND url = ?`, alvo.Projeto, alvo.Ref, m.URL). - Scan(&out.URL, &out.ProjetoPath, &out.IID, &out.IaC, &out.Estado, &out.AdicionadaEm) + out, err = scanMR(tx.QueryRowContext(ctx, colunasMR+`WHERE mr.projeto = ? AND mr.ref = ? AND mr.url = ?`, alvo.Projeto, alvo.Ref, m.URL)) if semLinha(err) { return naoEncontrado(m.URL+" não está ligada a "+alvo.Chave(), "veja as MRs com orq onda ver") } @@ -83,15 +97,15 @@ func (s *Store) RemoverMR(ctx context.Context, coord *Coordenador, ref refs.US, } func carregarMRs(ctx context.Context, q querier, projeto string, ref int) ([]MR, error) { - rows, err := q.QueryContext(ctx, `SELECT url, projeto_path, iid, iac, estado, adicionada_em FROM mr WHERE projeto = ? AND ref = ? ORDER BY id`, projeto, ref) + rows, err := q.QueryContext(ctx, colunasMR+`WHERE mr.projeto = ? AND mr.ref = ? ORDER BY mr.id`, projeto, ref) if err != nil { return nil, err } defer rows.Close() ms := []MR{} for rows.Next() { - var m MR - if err := rows.Scan(&m.URL, &m.ProjetoPath, &m.IID, &m.IaC, &m.Estado, &m.AdicionadaEm); err != nil { + m, err := scanMR(rows) + if err != nil { return nil, err } ms = append(ms, m) diff --git a/internal/central/snapshot.go b/internal/central/snapshot.go index e49c8ce..189502b 100644 --- a/internal/central/snapshot.go +++ b/internal/central/snapshot.go @@ -15,15 +15,17 @@ const VersaoSnapshot = 1 // Snapshot é o estado consolidado que o TUI e o daemon consomem. type Snapshot struct { - Versao int `json:"versao"` - GeradoEm string `json:"gerado_em"` - Coordenadores []CoordSnapshot `json:"coordenadores"` - PendenciasAbertas []Pendencia `json:"pendencias_abertas"` + Versao int `json:"versao"` + GeradoEm string `json:"gerado_em"` + Coordenadores []CoordSnapshot `json:"coordenadores"` + PendenciasAbertas []Pendencia `json:"pendencias_abertas"` + Fontes map[string]Fonte `json:"fontes"` } -// CoordSnapshot é um coordenador com as ondas dele. +// CoordSnapshot é um coordenador com o estado vivo do pane dele e as ondas. type CoordSnapshot struct { Coordenador + Herdr *Herdr `json:"herdr"` Ondas []Onda `json:"ondas"` } @@ -35,6 +37,10 @@ func (s *Store) Snapshot(ctx context.Context, incluirEncerradas bool) (Snapshot, if err != nil { return err } + h, err := lerHerdr(ctx, tx) + if err != nil { + return err + } for _, c := range cs { clause := `WHERE coordenador = ?` if !incluirEncerradas { @@ -44,9 +50,12 @@ func (s *Store) Snapshot(ctx context.Context, incluirEncerradas bool) (Snapshot, if err != nil { return err } - snap.Coordenadores = append(snap.Coordenadores, CoordSnapshot{Coordenador: c, Ondas: os}) + snap.Coordenadores = append(snap.Coordenadores, CoordSnapshot{Coordenador: c, Herdr: h.pane(c.PaneID), Ondas: os}) + } + if snap.PendenciasAbertas, err = carregarPendencias(ctx, tx, `WHERE p.aberta = 1`); err != nil { + return err } - snap.PendenciasAbertas, err = carregarPendencias(ctx, tx, `WHERE p.aberta = 1`) + snap.Fontes, err = carregarFontes(ctx, tx) return err }) return snap, err diff --git a/internal/central/us.go b/internal/central/us.go index aa481fd..a656f0b 100644 --- a/internal/central/us.go +++ b/internal/central/us.go @@ -32,6 +32,8 @@ type US struct { AtualizadoEm string `json:"atualizado_em"` MRs []MR `json:"mrs"` Pendencias []Pendencia `json:"pendencias"` + Herdr *Herdr `json:"herdr"` // nulo sem pane + Taiga Taiga `json:"taiga"` } func carregarUS(ctx context.Context, q querier, clause string, args ...any) ([]US, error) { @@ -55,8 +57,16 @@ func carregarUS(ctx context.Context, q querier, clause string, args ...any) ([]U if err := rows.Err(); err != nil { return nil, err } + h, err := lerHerdr(ctx, q) + if err != nil { + return nil, err + } for i := range us { u := &us[i] + u.Herdr = h.pane(u.PaneID) + if u.Taiga, err = carregarTaiga(ctx, q, u.Chave); err != nil { + return nil, err + } if u.DependeDe, err = carregarDeps(ctx, q, u.Projeto, u.Ref); err != nil { return nil, err } diff --git a/internal/cli/daemon.go b/internal/cli/daemon.go new file mode 100644 index 0000000..fde01e1 --- /dev/null +++ b/internal/cli/daemon.go @@ -0,0 +1,143 @@ +package cli + +import ( + "context" + "fmt" + "io" + "os" + "os/signal" + "strings" + "syscall" + "time" + + "github.com/BasisTI/orq/internal/central" + "github.com/BasisTI/orq/internal/enriquecer" + "github.com/spf13/cobra" +) + +// enriquecedor monta o enriquecedor com o banco e o ambiente da execução. +func (a *App) enriquecedor(ctx context.Context) (*enriquecer.Enriquecedor, error) { + st, err := a.store(ctx) + if err != nil { + return nil, err + } + return &enriquecer.Enriquecedor{St: st, F: enriquecer.Ferramentas{Env: a.Env}}, nil +} + +func (a *App) atualizarCmd() *cobra.Command { + var fontes []string + var forcar bool + cmd := &cobra.Command{ + Use: "atualizar [--fonte herdr|mr|taiga] [--forcar]", + Short: "Consulta Herdr, MRs e Taiga uma vez, grava o cache e confere os tokens de sidebar", + Long: `Consulta as fontes uma vez (todas, sem --fonte) e grava o cache que o snapshot mostra. +Cada chave respeita o TTL da fonte (Herdr 5 s, MR 60 s, Taiga 5 min); --forcar ignora o TTL e a +suspensão por erro de autenticação. Com o Herdr entre as fontes, confere também os tokens de +sidebar (orq no pane do coordenador, mrs_prontas no workspace).`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + ctx := cmd.Context() + if err := enriquecer.ValidarFontes(fontes); err != nil { + return usoDe(err) + } + e, err := a.enriquecedor(ctx) + if err != nil { + return err + } + r, err := e.Atualizar(ctx, enriquecer.Opcoes{Fontes: fontes, Forcar: forcar}) + if err != nil { + return err + } + if h, ok := r.Fontes[central.FonteHerdr]; ok && len(h.Erros) == 0 { + t, err := e.PublicarTokens(ctx) + if err != nil { + return err + } + r.Tokens = &t + } + return a.emit(r, func(w io.Writer) error { return textoAtualizar(w, r) }) + }, + } + cmd.Flags().StringSliceVar(&fontes, "fonte", nil, "fonte a consultar: herdr, mr ou taiga (repetível; padrão: todas)") + cmd.Flags().BoolVar(&forcar, "forcar", false, "ignora o TTL e a suspensão por erro de autenticação") + return cmd +} + +func textoAtualizar(w io.Writer, r enriquecer.Resultado) error { + for _, f := range central.Fontes { + rf, ok := r.Fontes[f] + if !ok { + continue + } + if _, err := fmt.Fprintf(w, "%-6s consultadas %d em dia %d suspensas %d erros %d\n", f, rf.Consultadas, rf.EmDia, rf.Suspensas, len(rf.Erros)); err != nil { + return err + } + for _, e := range rf.Erros { + chave := e.Chave + if chave == "" { + chave = f + } + if _, err := fmt.Fprintf(w, " %s: %s: %s\n", q(chave), q(e.Erro.Codigo), q(e.Erro.Mensagem)); err != nil { + return err + } + } + } + if t := r.Tokens; t != nil { + if t.Pulado != nil { + _, err := fmt.Fprintf(w, "tokens não conferidos: %s\n", q(*t.Pulado)) + return err + } + for _, tk := range t.Publicados { + if _, err := fmt.Fprintf(w, "token %s=%s publicado em %s %s\n", tk.Nome, q(tk.Valor), tk.Alvo, q(tk.AlvoID)); err != nil { + return err + } + } + for _, tk := range t.Limpos { + if _, err := fmt.Fprintf(w, "token %s limpo em %s %s\n", tk.Nome, tk.Alvo, q(tk.AlvoID)); err != nil { + return err + } + } + if len(t.Erros) > 0 { + if _, err := fmt.Fprintf(w, "erros nos tokens: %s\n", q(strings.Join(t.Erros, "; "))); err != nil { + return err + } + } + } + return nil +} + +func (a *App) daemonCmd() *cobra.Command { + var tick, remoto time.Duration + cmd := &cobra.Command{ + Use: "daemon", + Short: "Mantém o cache e os tokens de sidebar vivos, em primeiro plano (um por máquina)", + Long: `Roda em primeiro plano até SIGTERM ou SIGINT. A cada tick consulta o Herdr e confere os +tokens de sidebar; MR e Taiga são olhados a cada --intervalo-remoto, respeitando o TTL de cada +um. Assina pane.agent_status_changed no socket do Herdr ($HERDR_SOCKET_PATH ou +~/.config/herdr/herdr.sock) e republica na hora. Ao sair, limpa os tokens que publicou. +Só roda um por diretório de estado: o segundo sai com daemon_ativo. O log vai para o stderr.`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + dir, err := central.DirPadrao(a.Env) + if err != nil { + return err + } + soltar, err := enriquecer.TravarDaemon(dir) + if err != nil { + return err + } + defer soltar() + ctx, parar := signal.NotifyContext(cmd.Context(), os.Interrupt, syscall.SIGTERM) + defer parar() + e, err := a.enriquecedor(ctx) + if err != nil { + return err + } + d := &enriquecer.Daemon{E: e, Socket: enriquecer.CaminhoSocket(a.Env), Tick: tick, IntervaloRemoto: remoto, Log: a.Err} + return d.Rodar(ctx) + }, + } + cmd.Flags().DurationVar(&tick, "tick", 5*time.Second, "intervalo da consulta ao Herdr e da conferência dos tokens") + cmd.Flags().DurationVar(&remoto, "intervalo-remoto", 15*time.Second, "intervalo com que MR e Taiga são olhados (cada um ainda respeita o TTL)") + return cmd +} diff --git a/internal/cli/daemon_test.go b/internal/cli/daemon_test.go new file mode 100644 index 0000000..9f71797 --- /dev/null +++ b/internal/cli/daemon_test.go @@ -0,0 +1,64 @@ +package cli + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/BasisTI/orq/internal/enriquecer" +) + +// Sem as ferramentas, orq atualizar não falha: registra binario_ausente e o snapshot mostra. +func TestAtualizarSemFerramentas(t *testing.T) { + env := ambiente(t) + for _, v := range enriquecer.VariavelBin { + env[v] = "/nao/existe/bin" + } + ok(t, env, "coord", "registrar", "a", "--projeto", "orq") + ok(t, env, "onda", "criar", "o", "--us", "4") + ok(t, env, "mr", "add", "4", "https://github.com/BasisTI/orq/pull/1") + var r enriquecer.Resultado + if err := json.Unmarshal([]byte(ok(t, env, "atualizar")), &r); err != nil { + t.Fatal(err) + } + for _, f := range []string{"herdr", "mr", "taiga"} { + if e := r.Fontes[f].Erros; len(e) != 1 || e[0].Erro.Codigo != "binario_ausente" { + t.Fatalf("%s = %+v", f, r.Fontes[f]) + } + } + if r.Tokens != nil { + t.Fatalf("tokens conferidos com o Herdr fora: %+v", r.Tokens) + } + snap := ok(t, env, "snapshot") + if !strings.Contains(snap, `"codigo": "binario_ausente"`) { + t.Fatalf("snapshot sem o erro: %s", snap) + } + // Dentro do TTL e sem --forcar, nada é consultado de novo. + r = enriquecer.Resultado{} + if err := json.Unmarshal([]byte(ok(t, env, "atualizar", "--fonte", "mr,taiga")), &r); err != nil { + t.Fatal(err) + } + if r.Fontes["mr"].EmDia != 1 || r.Fontes["taiga"].EmDia != 1 || r.Fontes["herdr"] != nil { + t.Fatalf("segunda = %+v", r.Fontes) + } + texto := ok(t, env, "--output", "text", "atualizar", "--fonte", "herdr", "--forcar") + if !strings.Contains(texto, "herdr consultadas 1") || !strings.Contains(texto, "binario_ausente") { + t.Fatalf("texto = %q", texto) + } + if r := rodar(t, env, "atualizar", "--fonte", "jira"); r.code != 2 || !strings.Contains(r.err, `"uso"`) { + t.Fatalf("fonte inválida: %d %s", r.code, r.err) + } +} + +func TestDaemonSegundoSai(t *testing.T) { + env := ambiente(t) + soltar, err := enriquecer.TravarDaemon(env["ORQ_STATE_DIR"]) + if err != nil { + t.Fatal(err) + } + defer soltar() + r := rodar(t, env, "daemon") + if r.code != 4 || !strings.Contains(r.err, `"daemon_ativo"`) { + t.Fatalf("segundo daemon: exit %d %s", r.code, r.err) + } +} diff --git a/internal/cli/root.go b/internal/cli/root.go index 10f8d4e..105d758 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -64,7 +64,7 @@ func (a *App) root() *cobra.Command { }) }, }) - root.AddCommand(a.coordCmd(), a.ondaCmd(), a.usCmd(), a.mrCmd(), a.pendCmd(), a.snapshotCmd(), a.diarioCmd()) + root.AddCommand(a.coordCmd(), a.ondaCmd(), a.usCmd(), a.mrCmd(), a.pendCmd(), a.snapshotCmd(), a.diarioCmd(), a.atualizarCmd(), a.daemonCmd()) return root } diff --git a/internal/cli/texto.go b/internal/cli/texto.go index 2b43866..f4a0388 100644 --- a/internal/cli/texto.go +++ b/internal/cli/texto.go @@ -48,7 +48,11 @@ func textoOnda(w io.Writer, o central.Onda) error { return err } for _, m := range u.MRs { - if _, err := fmt.Fprintf(w, " MR %s%s\n", m.URL, iacTexto(m.IaC)); err != nil { + estado := "" + if m.Estado != nil { + estado = " [" + q(*m.Estado) + "]" + } + if _, err := fmt.Fprintf(w, " MR %s%s%s\n", m.URL, estado, iacTexto(m.IaC)); err != nil { return err } } @@ -71,6 +75,12 @@ func linhaStatus(u central.US) string { } if u.PaneID != nil { s += " pane " + q(*u.PaneID) + if u.Herdr != nil && u.Herdr.Status != nil { + s += " (" + q(*u.Herdr.Status) + ")" + } + } + if u.Taiga.Status != nil { + s += " taiga " + q(*u.Taiga.Status) } return s } diff --git a/internal/enriquecer/daemon.go b/internal/enriquecer/daemon.go new file mode 100644 index 0000000..da1f056 --- /dev/null +++ b/internal/enriquecer/daemon.go @@ -0,0 +1,304 @@ +package enriquecer + +import ( + "context" + "errors" + "fmt" + "io" + "slices" + "sort" + "strings" + "sync" + "time" + + "github.com/BasisTI/orq/internal/central" + "github.com/BasisTI/orq/internal/output" +) + +// ArquivoTravaDaemon fica no diretório de estado e garante um daemon por máquina. +const ArquivoTravaDaemon = "daemon.lock" + +// Daemon roda os enriquecedores em laço e mantém os tokens de sidebar. +type Daemon struct { + E *Enriquecedor + Socket string // socket do Herdr; vazio desliga a assinatura de eventos + // Tick do Herdr e da conferência dos tokens; zero usa 5 s. + Tick time.Duration + // IntervaloRemoto é a cadência com que MR e Taiga são olhados (cada chave ainda respeita o + // TTL dela); zero usa 15 s. + IntervaloRemoto time.Duration + // Reconexao é a espera antes de tentar o socket de novo; zero usa 5 s. + Reconexao time.Duration + Log io.Writer + + mu sync.Mutex + ultimos map[string]string +} + +// Rodar segue até ctx acabar (SIGTERM/SIGINT no orq daemon) e, ao sair, limpa os tokens que +// publicou. Falha de fonte não derruba o laço; só o erro do banco volta. +func (d *Daemon) Rodar(ctx context.Context) error { + tick := valorDur(d.Tick, 5*time.Second) + gatilho := make(chan struct{}, 1) + sinalizar := func() { + select { + case gatilho <- struct{}{}: + default: + } + } + panes := make(chan []string, 1) + erros := make(chan error, 2) + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + if err := d.remoto(ctx, sinalizar); err != nil { + erros <- err + } + }() + if d.Socket != "" { + wg.Add(1) + go func() { + defer wg.Done() + d.assinatura(ctx, panes, sinalizar) + }() + } + d.logf("início", "orq daemon no ar (tick %s, socket %s)", tick, valorOu(d.Socket, "desligado")) + t := time.NewTicker(tick) + defer t.Stop() + var assinados []string + enviou := false + var falha error + for falha == nil { + ps, err := d.ciclo(ctx) + if err != nil { + if ctx.Err() != nil { + break + } + if !ocupado(err) { + falha = err + break + } + // Outro processo segurou o banco: tenta no próximo tick. + d.logf("", "ciclo: %v", err) + } + if ps != nil && (!enviou || !slices.Equal(ps, assinados)) { + assinados, enviou = ps, true + select { + case <-panes: + default: + } + panes <- ps + } + select { + case <-ctx.Done(): + case <-t.C: + case <-gatilho: + case falha = <-erros: + } + if ctx.Err() != nil { + break + } + } + wg.Wait() + // ctx já acabou: a limpeza ganha um prazo próprio. + fim, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if r, err := d.E.LimparTokens(fim); err != nil { + d.logf("", "limpeza dos tokens: %v", err) + } else { + if len(r.Limpos) > 0 { + d.logf("", "tokens limpos ao sair: %d", len(r.Limpos)) + } + if len(r.Erros) > 0 { + d.logf("", "erros ao limpar os tokens: %s", strings.Join(r.Erros, "; ")) + } + } + d.logf("fim", "orq daemon encerrado") + return falha +} + +// ciclo consulta o Herdr, confere os tokens e devolve os panes a assinar (nil se o Herdr +// falhou e a lista não é confiável). +func (d *Daemon) ciclo(ctx context.Context) ([]string, error) { + r, err := d.E.AtualizarHerdr(ctx) + if err != nil { + return nil, err + } + if len(r.Erros) > 0 { + d.logf("herdr", "herdr: %s: %s", r.Erros[0].Erro.Codigo, r.Erros[0].Erro.Mensagem) + return nil, nil + } + d.logf("herdr", "") + tk, err := d.E.PublicarTokens(ctx) + if err != nil { + return nil, err + } + for _, t := range tk.Publicados { + d.logf("", "token %s=%q publicado em %s %s", t.Nome, t.Valor, t.Alvo, t.AlvoID) + } + for _, t := range tk.Limpos { + d.logf("", "token %s limpo em %s %s", t.Nome, t.Alvo, t.AlvoID) + } + d.logf("tokens", "%s", strings.Join(tk.Erros, "; ")) + return d.panesAssinaveis(ctx) +} + +// panesAssinaveis são os panes de US e de coordenador das ondas abertas que existem agora. +func (d *Daemon) panesAssinaveis(ctx context.Context) ([]string, error) { + sessao, _, err := d.E.St.LerSessaoHerdr(ctx) + if err != nil || sessao == nil { + return nil, err + } + snap, err := d.E.St.Snapshot(ctx, false) + if err != nil { + return nil, err + } + set := map[string]bool{} + add := func(p *string) { + if p == nil { + return + } + if _, ok := sessao.Panes[*p]; ok { + set[*p] = true + } + } + for _, c := range snap.Coordenadores { + add(c.PaneID) + for _, o := range c.Ondas { + for _, u := range o.US { + add(u.PaneID) + } + } + } + ps := make([]string, 0, len(set)) + for p := range set { + ps = append(ps, p) + } + sort.Strings(ps) + return ps, nil +} + +// remoto olha MR e Taiga a cada IntervaloRemoto; cada chave respeita o TTL. Quando algo foi +// consultado, pede um ciclo para os tokens refletirem o estado novo das MRs. +func (d *Daemon) remoto(ctx context.Context, sinalizar func()) error { + t := time.NewTicker(valorDur(d.IntervaloRemoto, 15*time.Second)) + defer t.Stop() + for { + r, err := d.E.Atualizar(ctx, Opcoes{Fontes: []string{central.FonteMR, central.FonteTaiga}}) + if err != nil && !ocupado(err) { + if ctx.Err() != nil { + return nil + } + return err + } + if err != nil { + d.logf("", "MR e Taiga: %v", err) + } + consultou := false + for _, f := range []string{central.FonteMR, central.FonteTaiga} { + rf := r.Fontes[f] + if rf == nil { + continue + } + consultou = consultou || rf.Consultadas > 0 + var es []string + for _, e := range rf.Erros { + es = append(es, e.Chave+": "+e.Erro.Codigo) + } + d.logf(f, "%s", strings.Join(es, "; ")) + } + if consultou { + sinalizar() + } + select { + case <-ctx.Done(): + return nil + case <-t.C: + } + } +} + +// assinatura mantém a assinatura de eventos do Herdr com a lista de panes mais recente. Todo +// (re)início, inclusive depois de events_lost, pede um ciclo: a leitura completa reconcilia o +// que se perdeu. +func (d *Daemon) assinatura(ctx context.Context, panes <-chan []string, sinalizar func()) { + espera := valorDur(d.Reconexao, 5*time.Second) + var atual []string + select { + case <-ctx.Done(): + return + case atual = <-panes: + } + for { + sub, cancel := context.WithCancel(ctx) + fim := make(chan error, 1) + ps := atual + go func() { + fim <- Assinar(sub, d.Socket, ps, func() { + d.logf("socket", "") + sinalizar() + }, func(EventoHerdr) { sinalizar() }) + }() + select { + case <-ctx.Done(): + cancel() + <-fim + return + case atual = <-panes: + cancel() + <-fim + continue + case err := <-fim: + cancel() + if errors.Is(err, ErrEventosPerdidos) { + d.logf("", "herdr: events_lost; reassinando e reconciliando") + continue + } + if err != nil { + d.logf("socket", "assinatura de eventos: %v", err) + } + } + select { + case <-ctx.Done(): + return + case atual = <-panes: + case <-time.After(espera): + } + } +} + +// logf escreve uma linha. Com chave, só escreve quando a mensagem muda (erro repetido a cada +// tick não enche o log) e uma mensagem vazia marca que o problema passou. +func (d *Daemon) logf(chave, format string, args ...any) { + if d.Log == nil { + return + } + msg := fmt.Sprintf(format, args...) + if chave != "" { + d.mu.Lock() + if d.ultimos == nil { + d.ultimos = map[string]string{} + } + antes := d.ultimos[chave] + d.ultimos[chave] = msg + d.mu.Unlock() + if msg == antes { + return + } + if msg == "" { + msg = chave + ": ok de novo" + } + } + fmt.Fprintf(d.Log, "%s %s\n", time.Now().UTC().Format(time.RFC3339), msg) +} + +// ocupado diz se err é o banco_ocupado: espera de outro processo, passageira. +func ocupado(err error) bool { return output.AsError(err).Code == "banco_ocupado" } + +func valorDur(d, padrao time.Duration) time.Duration { + if d <= 0 { + return padrao + } + return d +} diff --git a/internal/enriquecer/enriquecedor.go b/internal/enriquecer/enriquecedor.go new file mode 100644 index 0000000..1f820aa --- /dev/null +++ b/internal/enriquecer/enriquecedor.go @@ -0,0 +1,296 @@ +package enriquecer + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/BasisTI/orq/internal/central" + "github.com/BasisTI/orq/internal/refs" +) + +// TTLs padrão de cada fonte. O Herdr é consultado a cada tick do daemon. +var TTLPadrao = map[string]time.Duration{ + central.FonteHerdr: 5 * time.Second, + central.FonteMR: 60 * time.Second, + central.FonteTaiga: 5 * time.Minute, +} + +// SuspensaoPadrao é quanto uma ferramenta fica sem ser chamada depois de erro de autenticação. +const SuspensaoPadrao = 30 * time.Minute + +// Enriquecedor consulta as fontes e grava o cache. +type Enriquecedor struct { + St *central.Store + F Ferramentas + // Now é o relógio do TTL; nil usa time.Now. + Now func() time.Time + // TTL por fonte; fonte ausente usa TTLPadrao. + TTL map[string]time.Duration + // Suspensao é a pausa depois de erro de autenticação; zero usa SuspensaoPadrao. + Suspensao time.Duration +} + +// Opcoes de uma atualização. Fontes vazio quer dizer todas. +type Opcoes struct { + Fontes []string + Forcar bool // ignora TTL e suspensão +} + +// Resultado conta o que cada fonte fez. +type Resultado struct { + Fontes map[string]*ResultadoFonte `json:"fontes"` + Tokens *ResultadoTokens `json:"tokens"` +} + +// ResultadoFonte conta as consultas de uma fonte. +type ResultadoFonte struct { + Consultadas int `json:"consultadas"` + EmDia int `json:"em_dia"` // puladas pelo TTL + Suspensas int `json:"suspensas"` // puladas por suspensão de autenticação + Erros []ErroChave `json:"erros"` +} + +// ErroChave é a falha da consulta de uma chave (URL da MR, projeto/ref, ou "" no Herdr). +type ErroChave struct { + Chave string `json:"chave"` + Erro central.Erro `json:"erro"` +} + +func (e *Enriquecedor) agora() time.Time { + if e.Now != nil { + return e.Now() + } + return time.Now() +} + +func (e *Enriquecedor) ttl(fonte string) time.Duration { + if d, ok := e.TTL[fonte]; ok { + return d + } + return TTLPadrao[fonte] +} + +// emDia diz se a última consulta da chave ainda vale pelo TTL. +func (e *Enriquecedor) emDia(c central.Cache, ok bool, fonte string) bool { + if !ok { + return false + } + t, err := time.Parse(time.RFC3339, c.ConsultadoEm) + return err == nil && e.agora().Sub(t) < e.ttl(fonte) +} + +// ValidarFontes confere os nomes de --fonte. +func ValidarFontes(fs []string) error { + for _, f := range fs { + if !contem(central.Fontes, f) { + return fmt.Errorf("fonte %q desconhecida: use %s", f, strings.Join(central.Fontes, ", ")) + } + } + return nil +} + +// Atualizar consulta as fontes pedidas. Falha de uma consulta fica no cache e no Resultado; +// o erro devolvido é só o do banco. +func (e *Enriquecedor) Atualizar(ctx context.Context, op Opcoes) (Resultado, error) { + fontes := op.Fontes + if len(fontes) == 0 { + fontes = central.Fontes + } + res := Resultado{Fontes: map[string]*ResultadoFonte{}} + snap, err := e.St.Snapshot(ctx, false) + if err != nil { + return res, err + } + for _, f := range central.Fontes { + if !contem(fontes, f) { + continue + } + var r ResultadoFonte + switch f { + case central.FonteHerdr: + r, err = e.atualizarHerdr(ctx, op.Forcar) + case central.FonteMR: + r, err = e.atualizarMRs(ctx, snap, op.Forcar) + case central.FonteTaiga: + r, err = e.atualizarTaiga(ctx, snap, op.Forcar) + } + if r.Erros == nil { + r.Erros = []ErroChave{} + } + res.Fontes[f] = &r + if err != nil { + return res, err + } + } + return res, nil +} + +// AtualizarHerdr consulta o Herdr agora, sem olhar o TTL (o tick do daemon). +func (e *Enriquecedor) AtualizarHerdr(ctx context.Context) (ResultadoFonte, error) { + return e.atualizarHerdr(ctx, true) +} + +func (e *Enriquecedor) atualizarHerdr(ctx context.Context, forcar bool) (ResultadoFonte, error) { + var r ResultadoFonte + if !forcar { + cs, err := e.St.LerCache(ctx, central.FonteHerdr) + if err != nil { + return r, err + } + if c, ok := cs[""]; e.emDia(c, ok, central.FonteHerdr) { + r.EmDia++ + return r, nil + } + } + r.Consultadas++ + s, err := e.F.ConsultarHerdr(ctx) + if err != nil { + ce := comoFalha(err).cache() + r.Erros = append(r.Erros, ErroChave{Erro: *ce}) + return r, e.St.GravarCache(ctx, central.FonteHerdr, "", nil, ce) + } + return r, e.St.GravarCache(ctx, central.FonteHerdr, "", s, nil) +} + +// suspensa diz se a ferramenta está suspensa por autenticação e a pausa ainda não acabou. +func (e *Enriquecedor) suspensa(sus map[string]central.Erro, ferramenta string) bool { + s, ok := sus[ferramenta] + if !ok { + return false + } + t, err := time.Parse(time.RFC3339, s.Em) + pausa := e.Suspensao + if pausa == 0 { + pausa = SuspensaoPadrao + } + return err != nil || e.agora().Sub(t) < pausa +} + +// consultaChave é o laço comum de MR e Taiga: TTL, suspensão, consulta e gravação. +type consultaChave struct { + chave string + ferramenta string + consultar func() (any, error) + gravar func(valor any, erro *central.Erro) error +} + +func (e *Enriquecedor) consultarChaves(ctx context.Context, fonte string, cs []consultaChave, forcar bool) (ResultadoFonte, error) { + var r ResultadoFonte + cache, err := e.St.LerCache(ctx, fonte) + if err != nil { + return r, err + } + sus, err := e.St.Suspensoes(ctx) + if err != nil { + return r, err + } + for _, c := range cs { + if ctx.Err() != nil { + return r, ctx.Err() + } + if l, ok := cache[c.chave]; !forcar && e.emDia(l, ok, fonte) { + r.EmDia++ + continue + } + if !forcar && e.suspensa(sus, c.ferramenta) { + r.Suspensas++ + continue + } + r.Consultadas++ + v, err := c.consultar() + if err != nil { + f := comoFalha(err) + r.Erros = append(r.Erros, ErroChave{Chave: c.chave, Erro: *f.cache()}) + if err := c.gravar(nil, f.cache()); err != nil { + return r, err + } + if f.Codigo == ErrAutenticacao { + // Não tenta login nem define token: para de chamar a ferramenta e registra. + if err := e.St.Suspender(ctx, c.ferramenta, *f.cache()); err != nil { + return r, err + } + sus[c.ferramenta] = central.Erro{Codigo: f.Codigo, Mensagem: f.Mensagem, Em: e.agora().UTC().Format(time.RFC3339)} + } + continue + } + if _, ok := sus[c.ferramenta]; ok { + if err := e.St.Retomar(ctx, c.ferramenta); err != nil { + return r, err + } + delete(sus, c.ferramenta) + } + if err := c.gravar(v, nil); err != nil { + return r, err + } + } + return r, nil +} + +func (e *Enriquecedor) atualizarMRs(ctx context.Context, snap central.Snapshot, forcar bool) (ResultadoFonte, error) { + var cs []consultaChave + vistas := map[string]bool{} + for _, c := range snap.Coordenadores { + for _, o := range c.Ondas { + for _, u := range o.US { + for _, m := range u.MRs { + if vistas[m.URL] { + continue + } + vistas[m.URL] = true + url := m.URL + ferramenta := "gh" + if fj, err := forjaDe(url); err == nil { + ferramenta = fj.ferramenta + } + cs = append(cs, consultaChave{ + chave: url, ferramenta: ferramenta, + consultar: func() (any, error) { return e.F.ConsultarMR(ctx, url) }, + gravar: func(v any, erro *central.Erro) error { + vm, _ := v.(central.ValorMR) + return e.St.GravarMR(ctx, url, vm, erro) + }, + }) + } + } + } + } + return e.consultarChaves(ctx, central.FonteMR, cs, forcar) +} + +func (e *Enriquecedor) atualizarTaiga(ctx context.Context, snap central.Snapshot, forcar bool) (ResultadoFonte, error) { + var cs []consultaChave + vistas := map[string]bool{} + for _, c := range snap.Coordenadores { + for _, o := range c.Ondas { + for _, u := range o.US { + if vistas[u.Chave] { + continue + } + vistas[u.Chave] = true + chave, alvo := u.Chave, refs.US{Projeto: u.Projeto, Ref: u.Ref} + cs = append(cs, consultaChave{ + chave: chave, ferramenta: "taiga", + consultar: func() (any, error) { return e.F.ConsultarTaiga(ctx, alvo.Projeto, alvo.Ref) }, + gravar: func(v any, erro *central.Erro) error { + if erro != nil { + return e.St.GravarCache(ctx, central.FonteTaiga, chave, nil, erro) + } + return e.St.GravarCache(ctx, central.FonteTaiga, chave, v, nil) + }, + }) + } + } + } + return e.consultarChaves(ctx, central.FonteTaiga, cs, forcar) +} + +func contem(xs []string, x string) bool { + for _, y := range xs { + if x == y { + return true + } + } + return false +} diff --git a/internal/enriquecer/enriquecer_test.go b/internal/enriquecer/enriquecer_test.go new file mode 100644 index 0000000..dd67ffa --- /dev/null +++ b/internal/enriquecer/enriquecer_test.go @@ -0,0 +1,369 @@ +package enriquecer + +import ( + "context" + "strconv" + "strings" + "testing" + "time" + "unicode/utf8" + + "github.com/BasisTI/orq/internal/central" + "github.com/BasisTI/orq/internal/output" +) + +func TestNormalizarMR(t *testing.T) { + gitlab := []struct { + state, detalhe string + draft, merged bool + want string + }{ + {"opened", "mergeable", false, false, "pronta"}, + {"opened", "mergeable", true, false, "draft"}, + {"opened", "draft_status", true, false, "draft"}, + {"opened", "conflict", false, false, "aberta"}, + {"opened", "unchecked", false, false, "aberta"}, + {"opened", "ci_still_running", false, false, "aberta"}, + {"merged", "not_open", false, true, "mesclada"}, + {"closed", "not_open", false, false, "fechada"}, + {"locked", "mergeable", false, true, "mesclada"}, + } + for _, c := range gitlab { + if got := NormalizarGitLab(c.state, c.draft, c.detalhe, c.merged); got != c.want { + t.Errorf("gitlab %+v = %s, queria %s", c, got, c.want) + } + } + github := []struct { + state, mergeable string + draft, merged bool + want string + }{ + {"open", "clean", false, false, "pronta"}, + {"open", "has_hooks", false, false, "pronta"}, + {"open", "clean", true, false, "draft"}, + {"open", "dirty", false, false, "aberta"}, + {"open", "blocked", false, false, "aberta"}, + {"open", "unstable", false, false, "aberta"}, + {"open", "unknown", false, false, "aberta"}, + {"closed", "unknown", false, true, "mesclada"}, + {"closed", "unknown", false, false, "fechada"}, + } + for _, c := range github { + if got := NormalizarGitHub(c.state, c.draft, c.merged, c.mergeable); got != c.want { + t.Errorf("github %+v = %s, queria %s", c, got, c.want) + } + } +} + +const ( + urlGL = "https://gitlab.basis.com.br/basis/iac/plataforma/-/merge_requests/41" + urlGH = "https://github.com/BasisTI/orq/pull/1" +) + +var argsGL = []string{"api", "--hostname", "gitlab.basis.com.br", "projects/basis%2Fiac%2Fplataforma/merge_requests/41"} +var argsGH = []string{"api", "repos/BasisTI/orq/pulls/1"} + +func TestConsultarMRPelasForjas(t *testing.T) { + c := novoCenario(t) + c.responder("glab", `{"state":"opened","draft":false,"detailed_merge_status":"mergeable","merged_at":null,"web_url":"`+urlGL+`"}`, false, argsGL...) + c.responder("gh", `{"state":"closed","draft":false,"merged":true,"mergeable_state":"unknown","html_url":"`+urlGH+`"}`, false, argsGH...) + v, err := c.e.F.ConsultarMR(context.Background(), urlGL) + if err != nil || v.Estado != "pronta" { + t.Fatalf("gitlab = %+v, %v", v, err) + } + v, err = c.e.F.ConsultarMR(context.Background(), urlGH) + if err != nil || v.Estado != "mesclada" { + t.Fatalf("github = %+v, %v", v, err) + } + if got := c.chamadas("glab"); len(got) != 1 || strings.Join(got[0], " ") != strings.Join(argsGL, " ") { + t.Fatalf("argv do glab = %q", got) + } +} + +func TestClassificarForja(t *testing.T) { + casos := []struct{ stderr, want string }{ + {"HTTP 404: Not Found (https://api.github.com/repos/BasisTI/orq/pulls/401)", ErrNaoEncontrado}, + {"HTTP 401: Bad credentials (https://api.github.com/repos/BasisTI/orq/pulls/404)", ErrAutenticacao}, + {"glab: 401 Unauthorized", ErrAutenticacao}, + {"glab: 404 Not Found", ErrNaoEncontrado}, + {"To get started with GitHub CLI, please run: gh auth login", ErrAutenticacao}, + {"HTTP 502: Bad Gateway (https://gitlab/merge_requests/401)", ErrFalhaComando}, + } + for _, c := range casos { + f := comoFalha(classificarForja(&Falha{Codigo: ErrFalhaComando, Erro: []byte(c.stderr)})) + if f.Codigo != c.want { + t.Errorf("%q = %s, queria %s", c.stderr, f.Codigo, c.want) + } + } +} + +// O TTL segura a consulta; o erro fica no cache sem apagar o último valor bom. +func TestTTLEErroPreservaValorBom(t *testing.T) { + c := novoCenario(t) + co := c.coord("a", "w1:p1", "") + c.onda(co, "1") + c.mr(co, "1", urlGH) + c.responder("gh", `{"state":"open","draft":false,"merged":false,"mergeable_state":"clean"}`, false, argsGH...) + + r := c.atualizar(Opcoes{Fontes: []string{"mr"}}) + if r.Fontes["mr"].Consultadas != 1 { + t.Fatalf("1ª = %+v", r.Fontes["mr"]) + } + c.rel.passar(30 * time.Second) + if r := c.atualizar(Opcoes{Fontes: []string{"mr"}}); r.Fontes["mr"].EmDia != 1 || r.Fontes["mr"].Consultadas != 0 { + t.Fatalf("dentro do TTL = %+v", r.Fontes["mr"]) + } + if r := c.atualizar(Opcoes{Fontes: []string{"mr"}, Forcar: true}); r.Fontes["mr"].Consultadas != 1 { + t.Fatalf("--forcar = %+v", r.Fontes["mr"]) + } + if n := len(c.chamadas("gh")); n != 2 { + t.Fatalf("gh chamado %d vezes, queria 2", n) + } + c.responder("gh", "gh: Could not resolve host: api.github.com", true, argsGH...) + c.rel.passar(61 * time.Second) + r = c.atualizar(Opcoes{Fontes: []string{"mr"}}) + if len(r.Fontes["mr"].Erros) != 1 || r.Fontes["mr"].Erros[0].Erro.Codigo != ErrFalhaComando { + t.Fatalf("erro = %+v", r.Fontes["mr"]) + } + m := c.snapshot().Coordenadores[0].Ondas[0].US[0].MRs[0] + if m.Estado == nil || *m.Estado != "pronta" || m.Erro == nil || !strings.Contains(m.Erro.Mensagem, "Could not resolve host") { + t.Fatalf("mr = %+v / %+v", m, m.Erro) + } +} + +// Erro de autenticação suspende a ferramenta: nada de login, só para de chamar. +func TestAutenticacaoSuspende(t *testing.T) { + c := novoCenario(t) + co := c.coord("a", "", "") + c.onda(co, "1", "2") + c.mr(co, "1", urlGH) + c.mr(co, "2", "https://github.com/BasisTI/skills/pull/29") + c.responder("gh", "HTTP 401: Bad credentials (https://api.github.com/repos/BasisTI/orq/pulls/1)", true, argsGH...) + c.responder("taiga", `{"error":{"code":"session_expired","source":"auth","cause":"sessão expirada"}}`, true, "story", "get", "1", "--project", "orq", "--output", "json") + + r := c.atualizar(Opcoes{}) + if e := r.Fontes["mr"]; e.Consultadas != 1 || e.Suspensas != 1 || e.Erros[0].Erro.Codigo != ErrAutenticacao { + t.Fatalf("mr = %+v", e) + } + if e := r.Fontes["taiga"]; e.Consultadas != 1 || e.Suspensas != 1 || e.Erros[0].Erro.Codigo != ErrAutenticacao { + t.Fatalf("taiga = %+v", e) + } + if n := len(c.chamadas("gh")); n != 1 { + t.Fatalf("gh chamado %d vezes depois do 401", n) + } + _ = c.chamadas("taiga") + f := c.snapshot().Fontes + if f["mr"].Erro == nil || !strings.HasPrefix(f["mr"].Erro.Mensagem, "gh suspenso") || f["taiga"].Erro == nil { + t.Fatalf("fontes = %+v %+v", f["mr"].Erro, f["taiga"].Erro) + } + c.rel.passar(10 * time.Minute) + if r := c.atualizar(Opcoes{}); r.Fontes["mr"].Suspensas != 2 || len(c.chamadas("gh"))+len(c.chamadas("taiga")) != 0 { + t.Fatalf("suspensa ainda chamou: %+v", r.Fontes["mr"]) + } + // --forcar tenta de novo; sucesso tira a suspensão. + c.responder("gh", `{"state":"open","merged":false,"mergeable_state":"blocked"}`, false, argsGH...) + c.atualizar(Opcoes{Fontes: []string{"mr"}, Forcar: true}) + if sus, _ := c.st.Suspensoes(context.Background()); sus["gh"].Codigo != "" { + t.Fatalf("gh continua suspenso: %+v", sus) + } + // Passada a pausa, a ferramenta é tentada de novo sozinha; o 401 de novo a suspende já na + // primeira chave. + c.rel.passar(31 * time.Minute) + _ = c.chamadas("taiga") + if r := c.atualizar(Opcoes{Fontes: []string{"taiga"}}); r.Fontes["taiga"].Consultadas != 1 || r.Fontes["taiga"].Suspensas != 1 { + t.Fatalf("depois da pausa = %+v", r.Fontes["taiga"]) + } +} + +func TestTaigaEBinarioAusente(t *testing.T) { + c := novoCenario(t) + co := c.coord("a", "", "") + c.onda(co, "4", "99999") + c.responder("taiga", `{"subject":"Enriquecedores","url":"https://agile.basis.com.br/project/orq/us/4","status_extra_info":{"name":"In progress"}}`, false, "story", "get", "4", "--project", "orq", "--output", "json") + c.responder("taiga", `{"error":{"code":"not_found","cause":"No UserStory"}}`, true, "story", "get", "99999", "--project", "orq", "--output", "json") + c.env["ORQ_HERDR_BIN"] = "/nao/existe/herdr" + r := c.atualizar(Opcoes{Fontes: []string{"taiga", "herdr"}}) + if e := r.Fontes["taiga"].Erros; len(e) != 1 || e[0].Chave != "orq/99999" || e[0].Erro.Codigo != ErrNaoEncontrado { + t.Fatalf("taiga = %+v", r.Fontes["taiga"]) + } + if e := r.Fontes["herdr"].Erros; len(e) != 1 || e[0].Erro.Codigo != ErrBinarioAusente { + t.Fatalf("herdr = %+v", r.Fontes["herdr"]) + } + s := c.snapshot() + u := s.Coordenadores[0].Ondas[0].US + if *u[0].Taiga.Titulo != "Enriquecedores" || *u[0].Taiga.Status != "In progress" || u[1].Taiga.Erro.Codigo != ErrNaoEncontrado { + t.Fatalf("taiga no snapshot = %+v / %+v", u[0].Taiga, u[1].Taiga) + } + if s.Fontes["herdr"].Erro == nil || s.Fontes["herdr"].Erro.Codigo != ErrBinarioAusente { + t.Fatalf("fonte herdr = %+v", s.Fontes["herdr"]) + } + // Variável relativa também é rejeitada: o PATH nunca entra. + c.env["ORQ_HERDR_BIN"] = "herdr" + if _, err := c.e.F.Caminho("herdr"); err == nil { + t.Fatal("caminho relativo devia falhar") + } + delete(c.env, "ORQ_HERDR_BIN") + c.env["HERDR_BIN_PATH"] = c.dir + "/bin/herdr" + if p, err := c.e.F.Caminho("herdr"); err != nil || p != c.dir+"/bin/herdr" { + t.Fatalf("HERDR_BIN_PATH = %q, %v", p, err) + } +} + +func TestHerdrPaneAusente(t *testing.T) { + c := novoCenario(t) + co := c.coord("a", "w1:p1", "") + c.onda(co, "1", "2") + c.status(co, "1", "exec", "w1:p2") + c.status(co, "2", "exec", "w1:p3") + c.sessao([]map[string]any{ + {"pane_id": "w1:p1", "workspace_id": "w1", "agent_status": "unknown"}, + {"pane_id": "w1:p2", "workspace_id": "w1", "agent": "claude", "agent_status": "working", "name": "tg1-exec"}, + }, []map[string]any{{"workspace_id": "w1"}}) + if r := c.atualizar(Opcoes{Fontes: []string{"herdr"}}); r.Fontes["herdr"].Consultadas != 1 || len(r.Fontes["herdr"].Erros) != 0 { + t.Fatalf("herdr = %+v", r.Fontes["herdr"]) + } + s := c.snapshot() + u := s.Coordenadores[0].Ondas[0].US + if *s.Coordenadores[0].Herdr.Status != "unknown" || s.Coordenadores[0].Herdr.Agente != nil { + t.Fatalf("coord = %+v", s.Coordenadores[0].Herdr) + } + if *u[0].Herdr.Status != "working" || *u[0].Herdr.Nome != "tg1-exec" || *u[1].Herdr.Status != "ausente" { + t.Fatalf("us = %+v / %+v", u[0].Herdr, u[1].Herdr) + } +} + +func TestResumoEJuntarRefs(t *testing.T) { + st := func(s string) *string { return &s } + c := central.CoordSnapshot{Ondas: []central.Onda{{US: []central.US{ + {Status: "exec", Herdr: ¢ral.Herdr{Status: st("working")}}, + {Status: "exec"}, + {Status: "rev", Pendencias: []central.Pendencia{{Aberta: true}, {Aberta: false}}}, + {Status: "wait"}, + }}}} + if got := Resumo(c); got != "1 Wait·2 Exec·1 Rev·1 pend" { + t.Fatalf("resumo = %q", got) + } + c.Ondas[0].US[1].Herdr = ¢ral.Herdr{Status: st("blocked")} + if got := Resumo(c); got != "⚠ 1 Wait·2 Exec·1 Rev·1 pend" { + t.Fatalf("resumo com blocked = %q", got) + } + if got := Resumo(central.CoordSnapshot{}); got != "" { + t.Fatalf("sem onda = %q", got) + } + var muitas []central.US + for i := 0; i < 2000; i++ { + muitas = append(muitas, central.US{Status: []string{"wait", "plan", "exec", "rev", "done"}[i%5], Herdr: ¢ral.Herdr{Status: st("blocked")}, + Pendencias: []central.Pendencia{{Aberta: true}}}) + } + if got := Resumo(central.CoordSnapshot{Ondas: []central.Onda{{US: muitas}}}); utf8.RuneCountInString(got) > LimiteToken { + t.Fatalf("resumo com %d runas: %q", utf8.RuneCountInString(got), got) + } + var rs []string + for i := 100; i < 130; i++ { + rs = append(rs, "!"+strconv.Itoa(i*7)) + } + j := JuntarRefs(append(rs, rs...)) + if utf8.RuneCountInString(j) > LimiteToken || !strings.Contains(j, " +") { + t.Fatalf("refs = %q", j) + } + if got := JuntarRefs([]string{"!32", "skills#29", "!32"}); got != "!32 skills#29" { + t.Fatalf("refs = %q", got) + } + if got := JuntarRefs(nil); got != "" { + t.Fatalf("sem refs = %q", got) + } +} + +func TestTokensPublicaConfereELimpa(t *testing.T) { + c := novoCenario(t) + ctx := context.Background() + co := c.coord("a", "w1:p1", "") + c.coord("fantasma", "w9:p9", "w9") // pane e workspace que não existem + c.onda(co, "1", "2") + c.status(co, "1", "exec", "w1:p2") + c.mr(co, "1", urlGH) + c.mr(co, "2", urlGL) + c.responder("gh", `{"state":"open","merged":false,"mergeable_state":"clean"}`, false, argsGH...) + c.responder("glab", `{"state":"opened","detailed_merge_status":"mergeable"}`, false, argsGL...) + painel := func(tokPane, tokWS map[string]string) { + c.sessao([]map[string]any{ + {"pane_id": "w1:p1", "workspace_id": "w1", "agent": "claude", "agent_status": "working", "tokens": tokPane}, + {"pane_id": "w1:p2", "workspace_id": "w1", "agent": "claude", "agent_status": "blocked"}, + }, []map[string]any{{"workspace_id": "w1", "tokens": tokWS}}) + } + painel(nil, nil) + c.atualizar(Opcoes{}) + _ = c.chamadas("herdr") + r, err := c.e.PublicarTokens(ctx) + if err != nil { + t.Fatal(err) + } + got := c.chamadas("herdr") + want := []string{ + "pane report-metadata w1:p1 --source orq --token orq=⚠ 1 Wait·1 Exec", + "workspace report-metadata w1 --source orq --token mrs_prontas=orq#1 !41", + } + if len(got) != 2 || strings.Join(got[0], " ") != want[0] || strings.Join(got[1], " ") != want[1] || len(r.Publicados) != 2 { + t.Fatalf("argv = %q, resultado %+v", got, r) + } + // O Herdr já mostra os tokens: nada a republicar. + painel(map[string]string{"orq": "⚠ 1 Wait·1 Exec"}, map[string]string{"mrs_prontas": "orq#1 !41"}) + c.atualizar(Opcoes{Fontes: []string{"herdr"}, Forcar: true}) + _ = c.chamadas("herdr") + if r, _ := c.e.PublicarTokens(ctx); len(r.Publicados)+len(r.Limpos) != 0 || len(c.chamadas("herdr")) != 0 { + t.Fatalf("republicou sem mudança: %+v", r) + } + // O servidor do Herdr reiniciou e os tokens sumiram: republica. + painel(nil, nil) + c.atualizar(Opcoes{Fontes: []string{"herdr"}, Forcar: true}) + _ = c.chamadas("herdr") + if r, _ := c.e.PublicarTokens(ctx); len(r.Publicados) != 2 { + t.Fatalf("não republicou: %+v", r) + } + _ = c.chamadas("herdr") + // As MRs saem de pronta: mrs_prontas é limpo. + painel(map[string]string{"orq": "⚠ 1 Wait·1 Exec"}, map[string]string{"mrs_prontas": "orq#1 !41"}) + c.responder("gh", `{"state":"closed","merged":true}`, false, argsGH...) + c.responder("glab", `{"state":"opened","draft":true,"detailed_merge_status":"draft_status"}`, false, argsGL...) + c.atualizar(Opcoes{Forcar: true}) + _ = c.chamadas("herdr") + r, _ = c.e.PublicarTokens(ctx) + got = c.chamadas("herdr") + if len(got) != 1 || strings.Join(got[0], " ") != "workspace report-metadata w1 --source orq --clear-token mrs_prontas" || len(r.Limpos) != 1 { + t.Fatalf("limpeza = %q, %+v", got, r) + } + // O pane do coordenador some: o registro é esquecido sem chamar o Herdr. + c.sessao([]map[string]any{{"pane_id": "w1:p2", "workspace_id": "w1", "agent_status": "idle"}}, []map[string]any{{"workspace_id": "w1"}}) + c.atualizar(Opcoes{Fontes: []string{"herdr"}, Forcar: true}) + _ = c.chamadas("herdr") + if r, _ := c.e.PublicarTokens(ctx); len(r.Publicados)+len(r.Limpos) != 0 || len(c.chamadas("herdr")) != 0 { + t.Fatalf("pane sumido: %+v", r) + } + if ts, _ := c.st.TokensPublicados(ctx); len(ts) != 0 { + t.Fatalf("tokens publicados = %+v", ts) + } + // Herdr com erro: não confere nada. + c.responder("herdr", "server not running", true, "api", "snapshot") + c.atualizar(Opcoes{Fontes: []string{"herdr"}, Forcar: true}) + if r, _ := c.e.PublicarTokens(ctx); r.Pulado == nil { + t.Fatal("devia pular com o Herdr fora") + } +} + +func TestDaemonUnico(t *testing.T) { + dir := t.TempDir() + soltar, err := TravarDaemon(dir) + if err != nil { + t.Fatal(err) + } + _, err = TravarDaemon(dir) + if e := output.AsError(err); err == nil || e.Code != "daemon_ativo" || e.Exit != output.ExitConflict || !strings.Contains(e.Cause, "pid ") { + t.Fatalf("segundo daemon = %v", err) + } + soltar() + soltar2, err := TravarDaemon(dir) + if err != nil { + t.Fatalf("depois de soltar: %v", err) + } + soltar2() +} diff --git a/internal/enriquecer/falsos_test.go b/internal/enriquecer/falsos_test.go new file mode 100644 index 0000000..9ab0941 --- /dev/null +++ b/internal/enriquecer/falsos_test.go @@ -0,0 +1,205 @@ +package enriquecer + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "regexp" + "strings" + "testing" + "time" + + "github.com/BasisTI/orq/internal/central" + "github.com/BasisTI/orq/internal/refs" +) + +// falsos monta herdr, glab, gh e taiga falsos num diretório temporário. Cada um grava o argv +// em log/ e responde com resp//.out (stdout, saída 0) ou .err (stderr, +// saída 1), onde a chave é o argv juntado por "_"; sem arquivo, sai 0 sem nada. +type falsos struct { + t *testing.T + dir string + env map[string]string +} + +const scriptFalso = `#!/bin/sh +d='%DIR%'; t='%NOME%' +printf '%s\037' "$@" >> "$d/log/$t"; echo >> "$d/log/$t" +key=$(printf '%s_' "$@" | LC_ALL=C tr -c 'A-Za-z0-9_.-' '_') +if [ -f "$d/resp/$t/$key.err" ]; then cat "$d/resp/$t/$key.err" >&2; exit 1; fi +if [ -f "$d/resp/$t/$key.out" ]; then cat "$d/resp/$t/$key.out"; exit 0; fi +exit 0 +` + +func novosFalsos(t *testing.T) *falsos { + t.Helper() + dir := t.TempDir() + f := &falsos{t: t, dir: dir, env: map[string]string{"HOME": dir}} + for _, n := range []string{"herdr", "glab", "gh", "taiga"} { + for _, sub := range []string{"log", "resp/" + n, "bin"} { + if err := os.MkdirAll(filepath.Join(dir, sub), 0o755); err != nil { + t.Fatal(err) + } + } + s := strings.NewReplacer("%DIR%", dir, "%NOME%", n).Replace(scriptFalso) + p := filepath.Join(dir, "bin", n) + if err := os.WriteFile(p, []byte(s), 0o755); err != nil { + t.Fatal(err) + } + f.env[VariavelBin[n]] = p + } + return f +} + +var naoChave = regexp.MustCompile(`[^A-Za-z0-9_.-]`) + +func chaveArgs(args ...string) string { + var b strings.Builder + for _, a := range args { + b.WriteString(a + "_") + } + return naoChave.ReplaceAllString(b.String(), "_") +} + +// responder grava a resposta da ferramenta para o argv dado; erro vai para o stderr com saída 1. +func (f *falsos) responder(nome, saida string, erro bool, args ...string) { + f.t.Helper() + base := filepath.Join(f.dir, "resp", nome, chaveArgs(args...)) + _ = os.Remove(base + ".out") + _ = os.Remove(base + ".err") + ext := ".out" + if erro { + ext = ".err" + } + if err := os.WriteFile(base+ext, []byte(saida), 0o644); err != nil { + f.t.Fatal(err) + } +} + +// chamadas devolve o argv de cada chamada à ferramenta, na ordem, e esvazia o log. +func (f *falsos) chamadas(nome string) [][]string { + f.t.Helper() + p := filepath.Join(f.dir, "log", nome) + b, err := os.ReadFile(p) + if os.IsNotExist(err) { + return nil + } + if err != nil { + f.t.Fatal(err) + } + _ = os.Remove(p) + var out [][]string + for _, l := range strings.Split(strings.TrimRight(string(b), "\n"), "\n") { + out = append(out, strings.Split(strings.TrimSuffix(l, "\x1f"), "\x1f")) + } + return out +} + +func (f *falsos) ferramentas() Ferramentas { + return Ferramentas{Env: func(k string) string { return f.env[k] }, Timeout: 10 * time.Second} +} + +// sessao escreve a resposta de `herdr api snapshot`. +func (f *falsos) sessao(panes []map[string]any, workspaces []map[string]any) { + f.t.Helper() + var agentes []map[string]any + for _, p := range panes { + if _, ok := p["agent"]; ok { + agentes = append(agentes, p) + } + } + b, _ := json.Marshal(map[string]any{"id": "cli:api:snapshot", "result": map[string]any{"type": "session_snapshot", "snapshot": map[string]any{ + "panes": panes, "agents": agentes, "workspaces": workspaces}}}) + f.responder("herdr", string(b), false, "api", "snapshot") +} + +// relogio é um relógio de teste compartilhado pelo store e pelo enriquecedor. +type relogio struct{ t time.Time } + +func (r *relogio) agora() time.Time { return r.t } +func (r *relogio) passar(d time.Duration) { + r.t = r.t.Add(d) +} + +type cenario struct { + *falsos + st *central.Store + e *Enriquecedor + rel *relogio +} + +func novoCenario(t *testing.T) *cenario { + t.Helper() + f := novosFalsos(t) + st, err := central.Open(context.Background(), t.TempDir()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = st.Close() }) + rel := &relogio{t: time.Date(2026, 10, 11, 12, 0, 0, 0, time.UTC)} + st.Now = rel.agora + return &cenario{falsos: f, st: st, rel: rel, e: &Enriquecedor{St: st, F: f.ferramentas(), Now: rel.agora}} +} + +func (c *cenario) coord(nome, pane, workspace string) central.Coordenador { + c.t.Helper() + co, err := c.st.RegistrarCoord(context.Background(), central.Registro{Nome: nome, Pane: pane, Workspace: workspace, Projeto: "orq"}) + if err != nil { + c.t.Fatal(err) + } + return co +} + +func (c *cenario) onda(co central.Coordenador, us ...string) { + c.t.Helper() + n := central.NovaOnda{Titulo: "onda"} + for _, u := range us { + r, err := refs.ParseUS(u) + if err != nil { + c.t.Fatal(err) + } + n.US = append(n.US, r) + } + if _, err := c.st.CriarOnda(context.Background(), co, n); err != nil { + c.t.Fatal(err) + } +} + +func (c *cenario) status(co central.Coordenador, u, status, pane string) { + c.t.Helper() + r, _ := refs.ParseUS(u) + m := central.MudancaStatus{Status: status} + if pane != "" { + m.Pane = &pane + } + if _, _, err := c.st.AtualizarStatus(context.Background(), &co, r, m); err != nil { + c.t.Fatal(err) + } +} + +func (c *cenario) mr(co central.Coordenador, u, url string) { + c.t.Helper() + r, _ := refs.ParseUS(u) + if _, _, err := c.st.AdicionarMR(context.Background(), &co, r, url); err != nil { + c.t.Fatal(err) + } +} + +func (c *cenario) snapshot() central.Snapshot { + c.t.Helper() + s, err := c.st.Snapshot(context.Background(), false) + if err != nil { + c.t.Fatal(err) + } + return s +} + +func (c *cenario) atualizar(op Opcoes) Resultado { + c.t.Helper() + r, err := c.e.Atualizar(context.Background(), op) + if err != nil { + c.t.Fatal(err) + } + return r +} diff --git a/internal/enriquecer/ferramentas.go b/internal/enriquecer/ferramentas.go new file mode 100644 index 0000000..c050c10 --- /dev/null +++ b/internal/enriquecer/ferramentas.go @@ -0,0 +1,163 @@ +// Package enriquecer consulta o Herdr, as forjas (GitLab e GitHub) e o Taiga, guarda o que +// leu no cache da Central e publica os tokens de sidebar do orq no Herdr. O daemon roda os +// enriquecedores em laço; orq atualizar roda uma vez. +// +// Toda ferramenta é chamada por caminho absoluto: o daemon nasce de um plugin do Herdr com PATH +// mínimo, sem ~/.local/bin. Falha de subprocesso vira um central.Erro no cache, nunca derruba +// o processo. +package enriquecer + +import ( + "bytes" + "context" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/BasisTI/orq/internal/central" +) + +// Códigos de erro gravados no cache. +const ( + ErrBinarioAusente = "binario_ausente" + ErrAutenticacao = "autenticacao" + ErrNaoEncontrado = "nao_encontrado" + ErrTempoEsgotado = "tempo_esgotado" + ErrFalhaComando = "falha_comando" + ErrRespostaInvalida = "resposta_invalida" +) + +// VariavelBin é a variável que aponta o caminho absoluto de cada ferramenta. +var VariavelBin = map[string]string{ + "herdr": "ORQ_HERDR_BIN", + "glab": "ORQ_GLAB_BIN", + "gh": "ORQ_GH_BIN", + "taiga": "ORQ_TAIGA_BIN", +} + +// Ferramentas resolve e roda os binários externos. +type Ferramentas struct { + // Env é o ambiente usado para resolver os caminhos (os testes trocam). + Env func(string) string + // Timeout de cada chamada; zero usa 30 s. + Timeout time.Duration +} + +// Falha é o erro de uma chamada, já com o código do cache. +type Falha struct { + Codigo string + Mensagem string + Saida []byte // stdout + Erro []byte // stderr +} + +func (f *Falha) Error() string { return f.Codigo + ": " + f.Mensagem } + +func (f *Falha) cache() *central.Erro { return ¢ral.Erro{Codigo: f.Codigo, Mensagem: f.Mensagem} } + +// Caminho resolve o binário: a variável ORQ__BIN, depois $HERDR_BIN_PATH (só o herdr) e, +// por fim, os diretórios de instalação conhecidos. O PATH do processo não entra: num pane ou +// numa ação de plugin ele não tem ~/.local/bin. +func (f Ferramentas) Caminho(nome string) (string, error) { + env := f.env() + variavel := VariavelBin[nome] + if v := env(variavel); v != "" { + if !filepath.IsAbs(v) { + return "", &Falha{Codigo: ErrBinarioAusente, Mensagem: fmt.Sprintf("%s=%q não é caminho absoluto", variavel, v)} + } + if !executavel(v) { + return "", &Falha{Codigo: ErrBinarioAusente, Mensagem: fmt.Sprintf("%s aponta %s, que não existe ou não é executável", variavel, v)} + } + return v, nil + } + if nome == "herdr" { + if v := env("HERDR_BIN_PATH"); v != "" && filepath.IsAbs(v) && executavel(v) { + return v, nil + } + } + var dirs []string + if home := env("HOME"); home != "" { + dirs = append(dirs, filepath.Join(home, ".local", "bin"), filepath.Join(home, "go", "bin"), filepath.Join(home, "bin")) + } + dirs = append(dirs, "/home/linuxbrew/.linuxbrew/bin", "/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin") + for _, d := range dirs { + if p := filepath.Join(d, nome); executavel(p) { + return p, nil + } + } + return "", &Falha{Codigo: ErrBinarioAusente, Mensagem: fmt.Sprintf("%s não encontrado em %s; defina %s com o caminho absoluto", nome, strings.Join(dirs, ", "), variavel)} +} + +// Rodar chama a ferramenta e devolve o stdout. Saída diferente de zero, binário ausente e tempo +// esgotado viram *Falha; quem chama refina o código (autenticação, não encontrado). +func (f Ferramentas) Rodar(ctx context.Context, nome string, args ...string) ([]byte, error) { + bin, err := f.Caminho(nome) + if err != nil { + return nil, err + } + timeout := f.Timeout + if timeout == 0 { + timeout = 30 * time.Second + } + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + cmd := exec.CommandContext(ctx, bin, args...) + cmd.WaitDelay = 2 * time.Second + var stdout, stderr bytes.Buffer + cmd.Stdout, cmd.Stderr = &stdout, &stderr + err = cmd.Run() + if err == nil { + return stdout.Bytes(), nil + } + falha := &Falha{Codigo: ErrFalhaComando, Saida: stdout.Bytes(), Erro: stderr.Bytes()} + var ee *exec.ExitError + switch { + case errors.Is(ctx.Err(), context.DeadlineExceeded): + falha.Codigo, falha.Mensagem = ErrTempoEsgotado, fmt.Sprintf("%s não respondeu em %s", nome, timeout) + case errors.As(err, &ee): + falha.Mensagem = fmt.Sprintf("%s saiu com %d: %s", nome, ee.ExitCode(), resumir(stderr.Bytes(), stdout.Bytes())) + default: + falha.Mensagem = fmt.Sprintf("%s: %v", nome, err) + } + return nil, falha +} + +func (f Ferramentas) env() func(string) string { + if f.Env != nil { + return f.Env + } + return os.Getenv +} + +func executavel(p string) bool { + st, err := os.Stat(p) + return err == nil && st.Mode().IsRegular() && st.Mode()&0o111 != 0 +} + +// resumir escolhe a primeira saída não vazia e a corta numa linha curta. +func resumir(saidas ...[]byte) string { + for _, s := range saidas { + t := strings.Join(strings.Fields(string(s)), " ") + if t == "" { + continue + } + if r := []rune(t); len(r) > 300 { + t = string(r[:300]) + "…" + } + return t + } + return "sem saída" +} + +// comoFalha devolve err como *Falha. +func comoFalha(err error) *Falha { + var f *Falha + if errors.As(err, &f) { + return f + } + return &Falha{Codigo: ErrFalhaComando, Mensagem: err.Error()} +} diff --git a/internal/enriquecer/fontes.go b/internal/enriquecer/fontes.go new file mode 100644 index 0000000..fb5bef1 --- /dev/null +++ b/internal/enriquecer/fontes.go @@ -0,0 +1,271 @@ +package enriquecer + +import ( + "context" + "encoding/json" + "net/url" + "strconv" + "strings" + + "github.com/BasisTI/orq/internal/central" + "github.com/BasisTI/orq/internal/refs" +) + +// ConsultarHerdr lê a sessão do Herdr com `herdr api snapshot` (session.snapshot): numa chamada +// só vêm os agentes (o mesmo registro de `herdr agent list`, com status e nome), os panes sem +// agente e os tokens dos panes e workspaces. +func (f Ferramentas) ConsultarHerdr(ctx context.Context) (central.SessaoHerdr, error) { + out, err := f.Rodar(ctx, "herdr", "api", "snapshot") + if err != nil { + return central.SessaoHerdr{}, err + } + return ParseSessaoHerdr(out) +} + +// ParseSessaoHerdr converte a resposta de `herdr api snapshot`. +func ParseSessaoHerdr(b []byte) (central.SessaoHerdr, error) { + type pane struct { + PaneID string `json:"pane_id"` + WorkspaceID string `json:"workspace_id"` + Agent *string `json:"agent"` + AgentStatus string `json:"agent_status"` + Name *string `json:"name"` + Tokens map[string]string `json:"tokens"` + } + var r struct { + Result *struct { + Snapshot *struct { + Panes []pane `json:"panes"` + Agents []pane `json:"agents"` + Workspaces []struct { + WorkspaceID string `json:"workspace_id"` + Tokens map[string]string `json:"tokens"` + } `json:"workspaces"` + } `json:"snapshot"` + } `json:"result"` + Error *struct { + Code string `json:"code"` + Message string `json:"message"` + } `json:"error"` + } + if err := json.Unmarshal(b, &r); err != nil { + return central.SessaoHerdr{}, &Falha{Codigo: ErrRespostaInvalida, Mensagem: "herdr api snapshot: " + err.Error()} + } + if r.Error != nil { + return central.SessaoHerdr{}, &Falha{Codigo: ErrFalhaComando, Mensagem: "herdr api snapshot: " + r.Error.Code + ": " + r.Error.Message} + } + if r.Result == nil || r.Result.Snapshot == nil { + return central.SessaoHerdr{}, &Falha{Codigo: ErrRespostaInvalida, Mensagem: "herdr api snapshot sem result.snapshot"} + } + snap := r.Result.Snapshot + s := central.SessaoHerdr{Panes: map[string]central.PaneHerdr{}, Workspaces: map[string]central.WorkspaceHerdr{}} + for _, w := range snap.Workspaces { + s.Workspaces[w.WorkspaceID] = central.WorkspaceHerdr{Tokens: w.Tokens} + } + for _, p := range snap.Panes { + s.Panes[p.PaneID] = central.PaneHerdr{Status: valorOu(p.AgentStatus, "unknown"), Agente: deref(p.Agent), Nome: deref(p.Name), WorkspaceID: p.WorkspaceID, Tokens: p.Tokens} + } + // O registro do agente é o de `herdr agent list`: vale mais que o do pane. + for _, a := range snap.Agents { + p, ok := s.Panes[a.PaneID] + if !ok { + p = central.PaneHerdr{WorkspaceID: a.WorkspaceID, Tokens: a.Tokens} + } + p.Status = valorOu(a.AgentStatus, p.Status) + if a.Agent != nil { + p.Agente = *a.Agent + } + if a.Name != nil { + p.Nome = *a.Name + } + s.Panes[a.PaneID] = p + } + return s, nil +} + +// Forja de uma MR. +type forja struct { + ferramenta string // glab ou gh + host string + projeto string + iid int +} + +func forjaDe(rawURL string) (forja, error) { + m, err := refs.ParseMR(rawURL) + if err != nil { + return forja{}, &Falha{Codigo: ErrRespostaInvalida, Mensagem: err.Error()} + } + u, _ := url.Parse(m.URL) + f := forja{ferramenta: "glab", host: u.Host, projeto: m.ProjetoPath, iid: m.IID} + if !strings.Contains(u.Path, "/-/merge_requests/") { + f.ferramenta = "gh" + } + return f, nil +} + +// ConsultarMR lê a MR na forja (só leitura) e devolve o estado normalizado. +func (f Ferramentas) ConsultarMR(ctx context.Context, rawURL string) (central.ValorMR, error) { + fj, err := forjaDe(rawURL) + if err != nil { + return central.ValorMR{}, err + } + if fj.ferramenta == "glab" { + // O path do projeto vai inteiro codificado num segmento: basis%2Fiac%2Fplataforma. + caminho := "projects/" + url.QueryEscape(fj.projeto) + "/merge_requests/" + strconv.Itoa(fj.iid) + out, err := f.Rodar(ctx, "glab", "api", "--hostname", fj.host, caminho) + if err != nil { + return central.ValorMR{}, classificarForja(err) + } + var r struct { + State string `json:"state"` + Draft bool `json:"draft"` + WorkInProgress bool `json:"work_in_progress"` + DetailedMergeStatus string `json:"detailed_merge_status"` + MergedAt *string `json:"merged_at"` + WebURL string `json:"web_url"` + } + if err := json.Unmarshal(out, &r); err != nil || r.State == "" { + return central.ValorMR{}, &Falha{Codigo: ErrRespostaInvalida, Mensagem: "glab api devolveu algo que não é uma MR: " + resumir(out)} + } + return central.ValorMR{Estado: NormalizarGitLab(r.State, r.Draft || r.WorkInProgress, r.DetailedMergeStatus, r.MergedAt != nil), WebURL: r.WebURL}, nil + } + args := []string{"api"} + if fj.host != "github.com" { + args = append(args, "--hostname", fj.host) + } + args = append(args, "repos/"+fj.projeto+"/pulls/"+strconv.Itoa(fj.iid)) + out, err := f.Rodar(ctx, "gh", args...) + if err != nil { + return central.ValorMR{}, classificarForja(err) + } + var r struct { + State string `json:"state"` + Draft bool `json:"draft"` + Merged bool `json:"merged"` + MergeableState string `json:"mergeable_state"` + HTMLURL string `json:"html_url"` + } + if err := json.Unmarshal(out, &r); err != nil || r.State == "" { + return central.ValorMR{}, &Falha{Codigo: ErrRespostaInvalida, Mensagem: "gh api devolveu algo que não é um PR: " + resumir(out)} + } + return central.ValorMR{Estado: NormalizarGitHub(r.State, r.Draft, r.Merged, r.MergeableState), WebURL: r.HTMLURL}, nil +} + +// NormalizarGitLab traduz a MR do GitLab: mesclada, fechada, draft, pronta (aberta e +// mesclável) ou aberta (aberta com pendência: conflito, pipeline, discussão, ou o GitLab ainda +// não calculou). +func NormalizarGitLab(state string, draft bool, detailedMergeStatus string, mesclada bool) string { + switch { + case state == "merged" || mesclada: + return "mesclada" + case state == "closed": + return "fechada" + case draft: + return "draft" + case detailedMergeStatus == "mergeable": + return "pronta" + } + return "aberta" +} + +// NormalizarGitHub traduz o PR do GitHub. Pronta é aberta, não draft e com mergeable_state +// clean ou has_hooks; unstable (check falhando), blocked, behind, dirty e unknown ficam aberta. +func NormalizarGitHub(state string, draft, merged bool, mergeableState string) string { + switch { + case merged: + return "mesclada" + case state == "closed": + return "fechada" + case draft: + return "draft" + case mergeableState == "clean" || mergeableState == "has_hooks": + return "pronta" + } + return "aberta" +} + +// classificarForja separa autenticação e "não encontrado" das outras falhas do glab e do gh. +func classificarForja(err error) error { + f := comoFalha(err) + if f.Codigo != ErrFalhaComando { + return f + } + saida := string(f.Erro) + "\n" + string(f.Saida) + baixa := strings.ToLower(saida) + // Só o status HTTP conta: um "401" solto pode ser o número do PR na URL do erro. + switch { + case strings.Contains(saida, "HTTP 401") || strings.Contains(baixa, "401 unauthorized") || strings.Contains(baixa, "bad credentials") || + strings.Contains(baixa, "auth login") || strings.Contains(baixa, "not logged in"): + f.Codigo = ErrAutenticacao + case strings.Contains(saida, "HTTP 404") || strings.Contains(baixa, "404 not found"): + f.Codigo = ErrNaoEncontrado + } + return f +} + +// ConsultarTaiga lê a US no Taiga (só leitura) pela CLI taiga. +func (f Ferramentas) ConsultarTaiga(ctx context.Context, projeto string, ref int) (central.ValorTaiga, error) { + out, err := f.Rodar(ctx, "taiga", "story", "get", strconv.Itoa(ref), "--project", projeto, "--output", "json") + if err != nil { + return central.ValorTaiga{}, classificarTaiga(err) + } + var r struct { + Subject string `json:"subject"` + URL string `json:"url"` + StatusExtraInfo struct { + Name string `json:"name"` + } `json:"status_extra_info"` + } + if err := json.Unmarshal(out, &r); err != nil || r.Subject == "" { + return central.ValorTaiga{}, &Falha{Codigo: ErrRespostaInvalida, Mensagem: "taiga story get devolveu algo que não é uma story: " + resumir(out)} + } + return central.ValorTaiga{Titulo: r.Subject, Status: r.StatusExtraInfo.Name, URL: r.URL}, nil +} + +// classificarTaiga lê o envelope {"error":{"code"}} da CLI taiga: auth_* e session_* são +// autenticação; not_found é não encontrado. +func classificarTaiga(err error) error { + f := comoFalha(err) + if f.Codigo != ErrFalhaComando { + return f + } + for _, s := range [][]byte{f.Saida, f.Erro} { + var env struct { + Error *struct { + Code string `json:"code"` + Cause string `json:"cause"` + } `json:"error"` + } + if json.Unmarshal(s, &env) != nil || env.Error == nil { + continue + } + c := env.Error.Code + f.Mensagem = "taiga: " + c + if env.Error.Cause != "" { + f.Mensagem += ": " + resumir([]byte(env.Error.Cause)) + } + switch { + case strings.HasPrefix(c, "auth_") || strings.HasPrefix(c, "session_"): + f.Codigo = ErrAutenticacao + case c == "not_found": + f.Codigo = ErrNaoEncontrado + } + return f + } + return f +} + +func deref(s *string) string { + if s == nil { + return "" + } + return *s +} + +func valorOu(s, padrao string) string { + if s == "" { + return padrao + } + return s +} diff --git a/internal/enriquecer/socket.go b/internal/enriquecer/socket.go new file mode 100644 index 0000000..2ce6eb4 --- /dev/null +++ b/internal/enriquecer/socket.go @@ -0,0 +1,134 @@ +package enriquecer + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "path/filepath" +) + +// ErrEventosPerdidos é o events_lost do Herdr: o assinante ficou para trás, a conexão foi +// fechada e o estado em cache tem de ser reconciliado com uma leitura completa. +var ErrEventosPerdidos = errors.New("herdr: events_lost") + +// CaminhoSocket resolve o socket do Herdr: $HERDR_SOCKET_PATH, a sessão nomeada de +// $HERDR_SESSION ou o socket padrão em ~/.config/herdr. +func CaminhoSocket(env func(string) string) string { + if p := env("HERDR_SOCKET_PATH"); p != "" { + return p + } + base := env("XDG_CONFIG_HOME") + if base == "" || !filepath.IsAbs(base) { + base = filepath.Join(env("HOME"), ".config") + } + if s := env("HERDR_SESSION"); s != "" { + return filepath.Join(base, "herdr", "sessions", s, "herdr.sock") + } + return filepath.Join(base, "herdr", "herdr.sock") +} + +// EventoHerdr é um evento recebido na assinatura. +type EventoHerdr struct { + Tipo string `json:"event"` + PaneID string `json:"pane_id"` + AgentStatus string `json:"agent_status"` +} + +// Assinar abre o socket, assina a mudança de status dos panes dados e os eventos de ciclo de +// vida de pane, chama iniciou depois que o Herdr confirma e aoEvento a cada evento. Volta +// quando ctx acaba (nil), quando a conexão cai ou com ErrEventosPerdidos. Pane que não existe +// faz o Herdr recusar a assinatura inteira: passe só panes vistos na última leitura. +func Assinar(ctx context.Context, caminho string, panes []string, iniciou func(), aoEvento func(EventoHerdr)) error { + var d net.Dialer + conn, err := d.DialContext(ctx, "unix", caminho) + if err != nil { + return err + } + defer conn.Close() + parar := context.AfterFunc(ctx, func() { _ = conn.Close() }) + defer parar() + + subs := []map[string]string{{"type": "pane.created"}, {"type": "pane.closed"}, {"type": "pane.exited"}, {"type": "pane.agent_detected"}} + for _, p := range panes { + subs = append(subs, map[string]string{"type": "pane.agent_status_changed", "pane_id": p}) + } + const id = "orq-daemon" + req, err := json.Marshal(map[string]any{"id": id, "method": "events.subscribe", "params": map[string]any{"subscriptions": subs}}) + if err != nil { + return err + } + if _, err := conn.Write(append(req, '\n')); err != nil { + return fimDe(ctx, err) + } + r := bufio.NewReader(conn) + iniciada := false + for { + linha, err := r.ReadBytes('\n') + if err != nil { + if errors.Is(err, io.EOF) && len(linha) == 0 && ctx.Err() == nil { + return errors.New("herdr fechou a conexão de eventos") + } + if len(linha) == 0 { + return fimDe(ctx, err) + } + } + var m struct { + ID *string `json:"id"` + Result json.RawMessage `json:"result"` + Error *struct { + Code string `json:"code"` + Message string `json:"message"` + } `json:"error"` + Event string `json:"event"` + Data json.RawMessage `json:"data"` + } + if json.Unmarshal(linha, &m) != nil { + continue + } + switch { + case m.Error != nil: + if m.Error.Code == "events_lost" { + return ErrEventosPerdidos + } + return fmt.Errorf("herdr recusou a assinatura: %s: %s", m.Error.Code, m.Error.Message) + case m.Event != "": + ev := EventoHerdr{Tipo: m.Event} + _ = json.Unmarshal(m.Data, &ev) + ev.Tipo = m.Event + if ev.PaneID == "" { + // Eventos de ciclo de vida trazem o pane dentro de "pane". + var p struct { + Pane struct { + PaneID string `json:"pane_id"` + } `json:"pane"` + } + if json.Unmarshal(m.Data, &p) == nil { + ev.PaneID = p.Pane.PaneID + } + } + if aoEvento != nil { + aoEvento(ev) + } + case m.ID != nil && *m.ID == id && m.Result != nil && !iniciada: + iniciada = true + if iniciou != nil { + iniciou() + } + } + if err != nil { + return fimDe(ctx, err) + } + } +} + +// fimDe devolve nil quando a conexão caiu porque ctx acabou. +func fimDe(ctx context.Context, err error) error { + if ctx.Err() != nil { + return nil + } + return err +} diff --git a/internal/enriquecer/socket_test.go b/internal/enriquecer/socket_test.go new file mode 100644 index 0000000..34936d2 --- /dev/null +++ b/internal/enriquecer/socket_test.go @@ -0,0 +1,220 @@ +package enriquecer + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "net" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" +) + +// servidorFalso é um Herdr de mentira no socket: confirma a assinatura e roda o roteiro de cada +// conexão (uma função por conexão, na ordem; acabado o roteiro, a conexão fica aberta). +type servidorFalso struct { + t *testing.T + caminho string + ln net.Listener + mu sync.Mutex + pedidos []map[string]any + roteiro []func(enviar func(string)) +} + +func novoServidor(t *testing.T, roteiro ...func(enviar func(string))) *servidorFalso { + t.Helper() + // Caminho curto: socket Unix tem limite de ~108 bytes. + dir, err := os.MkdirTemp("", "orq") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.RemoveAll(dir) }) + s := &servidorFalso{t: t, caminho: filepath.Join(dir, "h.sock"), roteiro: roteiro} + if s.ln, err = net.Listen("unix", s.caminho); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = s.ln.Close() }) + go s.servir() + return s +} + +func (s *servidorFalso) servir() { + for n := 0; ; n++ { + c, err := s.ln.Accept() + if err != nil { + return + } + go func(n int, c net.Conn) { + defer c.Close() + r := bufio.NewReader(c) + linha, err := r.ReadBytes('\n') + if err != nil { + return + } + var req map[string]any + _ = json.Unmarshal(linha, &req) + s.mu.Lock() + s.pedidos = append(s.pedidos, req) + s.mu.Unlock() + enviar := func(l string) { _, _ = c.Write([]byte(l + "\n")) } + enviar(`{"id":"` + req["id"].(string) + `","result":{"type":"subscription_started"}}`) + if n < len(s.roteiro) { + s.roteiro[n](enviar) + } + _, _ = r.ReadBytes('\n') // segura a conexão até o cliente fechar + }(n, c) + } +} + +func (s *servidorFalso) conexoes() []map[string]any { + s.mu.Lock() + defer s.mu.Unlock() + return append([]map[string]any(nil), s.pedidos...) +} + +const eventoBlocked = `{"event":"pane.agent_status_changed","data":{"pane_id":"w1:p2","workspace_id":"w1","agent_status":"blocked"}}` + +func TestAssinarEventoEEventsLost(t *testing.T) { + s := novoServidor(t, func(enviar func(string)) { + enviar(eventoBlocked) + enviar(`{"event":"pane.closed","data":{"pane":{"pane_id":"w1:p9","workspace_id":"w1"}}}`) + enviar(`{"id":"orq-daemon","error":{"code":"events_lost","message":"subscriber fell behind"}}`) + }) + var evs []EventoHerdr + iniciou := 0 + err := Assinar(context.Background(), s.caminho, []string{"w1:p1", "w1:p2"}, func() { iniciou++ }, func(e EventoHerdr) { evs = append(evs, e) }) + if !errors.Is(err, ErrEventosPerdidos) { + t.Fatalf("err = %v", err) + } + if iniciou != 1 || len(evs) != 2 || evs[0] != (EventoHerdr{"pane.agent_status_changed", "w1:p2", "blocked"}) || evs[1].PaneID != "w1:p9" { + t.Fatalf("iniciou %d, eventos %+v", iniciou, evs) + } + req := s.conexoes()[0] + b, _ := json.Marshal(req["params"]) + if req["method"] != "events.subscribe" || !strings.Contains(string(b), `{"pane_id":"w1:p2","type":"pane.agent_status_changed"}`) { + t.Fatalf("pedido = %v", req) + } +} + +func TestAssinarRecusadaECancelada(t *testing.T) { + s := novoServidor(t, func(enviar func(string)) { + enviar(`{"id":"orq-daemon","error":{"code":"not_found","message":"pane not found"}}`) + }) + if err := Assinar(context.Background(), s.caminho, []string{"w1:p404"}, nil, nil); err == nil || !strings.Contains(err.Error(), "not_found") { + t.Fatalf("recusa = %v", err) + } + ctx, cancel := context.WithCancel(context.Background()) + fim := make(chan error, 1) + go func() { fim <- Assinar(ctx, s.caminho, nil, func() { cancel() }, nil) }() + select { + case err := <-fim: + if err != nil { + t.Fatalf("cancelada = %v", err) + } + case <-time.After(5 * time.Second): + t.Fatal("Assinar não voltou com ctx cancelado") + } + if err := Assinar(context.Background(), filepath.Join(t.TempDir(), "nada.sock"), nil, nil, nil); err == nil { + t.Fatal("socket inexistente devia falhar") + } +} + +func TestCaminhoSocket(t *testing.T) { + env := func(m map[string]string) func(string) string { return func(k string) string { return m[k] } } + casos := []struct { + env map[string]string + want string + }{ + {map[string]string{"HERDR_SOCKET_PATH": "/x/h.sock", "HOME": "/h"}, "/x/h.sock"}, + {map[string]string{"HOME": "/h"}, "/h/.config/herdr/herdr.sock"}, + {map[string]string{"HOME": "/h", "HERDR_SESSION": "s1"}, "/h/.config/herdr/sessions/s1/herdr.sock"}, + {map[string]string{"HOME": "/h", "XDG_CONFIG_HOME": "/c"}, "/c/herdr/herdr.sock"}, + } + for _, c := range casos { + if got := CaminhoSocket(env(c.env)); got != c.want { + t.Errorf("%v = %s, queria %s", c.env, got, c.want) + } + } +} + +// O daemon assina os panes vistos, republica a cada evento, reassina e reconcilia depois de +// events_lost e limpa os tokens ao sair. +func TestDaemonEventosEReconciliacao(t *testing.T) { + c := novoCenario(t) + c.st.Now, c.e.Now = time.Now, time.Now + co := c.coord("a", "w1:p1", "") + c.onda(co, "1") + c.status(co, "1", "exec", "w1:p2") + c.sessao([]map[string]any{ + {"pane_id": "w1:p1", "workspace_id": "w1", "agent": "claude", "agent_status": "working"}, + {"pane_id": "w1:p2", "workspace_id": "w1", "agent": "claude", "agent_status": "working"}, + }, []map[string]any{{"workspace_id": "w1"}}) + continuar := make(chan struct{}) + s := novoServidor(t, func(enviar func(string)) { + <-continuar + enviar(eventoBlocked) + <-continuar + enviar(`{"id":"orq-daemon","error":{"code":"events_lost","message":"x"}}`) + }) + ctx, cancel := context.WithCancel(context.Background()) + d := &Daemon{E: c.e, Socket: s.caminho, Tick: time.Hour, IntervaloRemoto: time.Hour, Reconexao: 50 * time.Millisecond} + fim := make(chan error, 1) + go func() { fim <- d.Rodar(ctx) }() + + snapshots := func() int { + n := 0 + for _, a := range c.chamadas("herdr") { + if strings.Join(a, " ") == "api snapshot" { + n++ + } + } + return n + } + esperar := func(oque string, cond func() bool) { + t.Helper() + limite := time.Now().Add(10 * time.Second) + for !cond() { + if time.Now().After(limite) { + t.Fatalf("esperando %s", oque) + } + time.Sleep(20 * time.Millisecond) + } + } + // Início: ciclo, assinatura e o ciclo de reconciliação que ela pede. + vistos := 0 + esperar("assinatura e reconciliação", func() bool { vistos += snapshots(); return len(s.conexoes()) == 1 && vistos >= 2 }) + b, _ := json.Marshal(s.conexoes()[0]["params"]) + if !strings.Contains(string(b), `"w1:p1"`) || !strings.Contains(string(b), `"w1:p2"`) { + t.Fatalf("assinatura = %s", b) + } + vistos = 0 + continuar <- struct{}{} + esperar("ciclo pelo evento", func() bool { vistos += snapshots(); return vistos >= 1 }) + vistos = 0 + continuar <- struct{}{} + esperar("reassinatura depois de events_lost", func() bool { vistos += snapshots(); return len(s.conexoes()) == 2 && vistos >= 1 }) + ts, _ := c.st.TokensPublicados(context.Background()) + if len(ts) != 1 || ts[0].Valor != "1 Exec" { + t.Fatalf("tokens = %+v", ts) + } + cancel() + select { + case err := <-fim: + if err != nil { + t.Fatalf("Rodar = %v", err) + } + case <-time.After(10 * time.Second): + t.Fatal("daemon não saiu com ctx cancelado") + } + limpou := false + for _, a := range c.chamadas("herdr") { + limpou = limpou || strings.Join(a, " ") == "pane report-metadata w1:p1 --source orq --clear-token orq" + } + if ts, _ := c.st.TokensPublicados(context.Background()); !limpou || len(ts) != 0 { + t.Fatalf("não limpou ao sair: %+v", ts) + } +} diff --git a/internal/enriquecer/tokens.go b/internal/enriquecer/tokens.go new file mode 100644 index 0000000..8add19b --- /dev/null +++ b/internal/enriquecer/tokens.go @@ -0,0 +1,288 @@ +package enriquecer + +import ( + "context" + "sort" + "strconv" + "strings" + "unicode/utf8" + + "github.com/BasisTI/orq/internal/central" + "github.com/BasisTI/orq/internal/output" +) + +// Tokens de sidebar do orq no Herdr. +const ( + FonteTokens = "orq" // --source dos report-metadata + TokenResumo = "orq" // no pane do coordenador + TokenMRsProntas = "mrs_prontas" // no workspace do coordenador + LimiteToken = 80 // o Herdr corta o valor em 80 caracteres +) + +// ResultadoTokens diz o que a publicação fez. +type ResultadoTokens struct { + Publicados []central.Token `json:"publicados"` + Limpos []central.Token `json:"limpos"` + Erros []string `json:"erros"` + Pulado *string `json:"pulado"` // motivo, quando nada foi conferido +} + +func novoResultadoTokens() ResultadoTokens { + return ResultadoTokens{Publicados: []central.Token{}, Limpos: []central.Token{}, Erros: []string{}} +} + +var rotulos = []struct{ status, rotulo string }{ + {"wait", "Wait"}, {"plan", "Plan"}, {"exec", "Exec"}, {"rev", "Rev"}, {"done", "Done"}, +} + +// Resumo é o token orq do coordenador: contagem das US das ondas abertas por status e das +// pendências abertas, ex. `2 Exec·1 Rev·1 pend`, com ⚠ na frente quando algum pane de US está +// blocked. Sem onda aberta, vazio (o token é limpo). +func Resumo(c central.CoordSnapshot) string { + n := map[string]int{} + pend, bloqueado, total := 0, false, 0 + for _, o := range c.Ondas { + for _, u := range o.US { + total++ + n[u.Status]++ + if u.Herdr != nil && u.Herdr.Status != nil && *u.Herdr.Status == "blocked" { + bloqueado = true + } + for _, p := range u.Pendencias { + if p.Aberta { + pend++ + } + } + } + } + if total == 0 { + return "" + } + var partes []string + for _, r := range rotulos { + if n[r.status] > 0 { + partes = append(partes, strconv.Itoa(n[r.status])+" "+r.rotulo) + } + } + if pend > 0 { + partes = append(partes, strconv.Itoa(pend)+" pend") + } + s := strings.Join(partes, "·") + if bloqueado { + s = "⚠ " + s + } + return cortar(s, LimiteToken) +} + +// RefsProntas devolve as refs curtas das MRs pronta das ondas abertas do coordenador: `!32` +// no GitLab e `repo#29` no GitHub. +func RefsProntas(c central.CoordSnapshot) []string { + var rs []string + for _, o := range c.Ondas { + for _, u := range o.US { + for _, m := range u.MRs { + if m.Estado == nil || *m.Estado != "pronta" { + continue + } + rs = append(rs, refCurta(m)) + } + } + } + return rs +} + +func refCurta(m central.MR) string { + if strings.Contains(m.URL, "/-/merge_requests/") { + return "!" + strconv.Itoa(m.IID) + } + repo := m.ProjetoPath[strings.LastIndex(m.ProjetoPath, "/")+1:] + return repo + "#" + strconv.Itoa(m.IID) +} + +// JuntarRefs junta as refs sem repetir, cabendo em 80 caracteres: o que não cabe vira `+N`. +func JuntarRefs(rs []string) string { + var u []string + visto := map[string]bool{} + for _, r := range rs { + if !visto[r] { + visto[r] = true + u = append(u, r) + } + } + s := strings.Join(u, " ") + if utf8.RuneCountInString(s) <= LimiteToken { + return s + } + for i := len(u) - 1; i > 0; i-- { + s = strings.Join(u[:i], " ") + " +" + strconv.Itoa(len(u)-i) + if utf8.RuneCountInString(s) <= LimiteToken { + return s + } + } + return cortar(u[0], LimiteToken) +} + +// cortar deixa s com até n runas, sem controles (o Herdr faz o mesmo ao gravar; se o orq não +// fizesse, o valor gravado nunca bateria com o publicado e o token seria republicado a cada tick). +func cortar(s string, n int) string { + s = strings.TrimSpace(strings.Map(func(r rune) rune { + if output.UnsafeRune(r) { + return -1 + } + return r + }, s)) + if r := []rune(s); len(r) > n { + return strings.TrimSpace(string(r[:n])) + } + return s +} + +type chaveToken struct{ alvo, id, nome string } + +func (k chaveToken) token(valor string) central.Token { + return central.Token{Alvo: k.alvo, AlvoID: k.id, Nome: k.nome, Valor: valor} +} + +// PublicarTokens confere os tokens do orq contra a última sessão do Herdr: publica o que falta +// ou mudou (inclusive o que sumiu porque o servidor do Herdr reiniciou) e limpa o que deixou +// de valer. Só toca panes e workspaces que existem na sessão. +func (e *Enriquecedor) PublicarTokens(ctx context.Context) (ResultadoTokens, error) { + res := novoResultadoTokens() + sessao, erro, err := e.St.LerSessaoHerdr(ctx) + if err != nil { + return res, err + } + if sessao == nil || erro != nil { + motivo := "sem sessão do Herdr em cache" + if erro != nil { + motivo = "a última consulta ao Herdr falhou (" + erro.Codigo + ")" + } + res.Pulado = &motivo + return res, nil + } + snap, err := e.St.Snapshot(ctx, false) + if err != nil { + return res, err + } + desejados := map[chaveToken]string{} + refsPorWS := map[string][]string{} + for _, c := range snap.Coordenadores { + ws := "" + if c.PaneID != nil { + if p, ok := sessao.Panes[*c.PaneID]; ok { + if r := Resumo(c); r != "" { + desejados[chaveToken{"pane", *c.PaneID, TokenResumo}] = r + } + ws = p.WorkspaceID + } + } + if c.WorkspaceID != nil { + ws = *c.WorkspaceID + } + if _, ok := sessao.Workspaces[ws]; ok && ws != "" { + refsPorWS[ws] = append(refsPorWS[ws], RefsProntas(c)...) + } + } + for ws, rs := range refsPorWS { + if v := JuntarRefs(rs); v != "" { + desejados[chaveToken{"workspace", ws, TokenMRsProntas}] = v + } + } + atual := func(k chaveToken) (string, bool) { + if k.alvo == "pane" { + p, ok := sessao.Panes[k.id] + return p.Tokens[k.nome], ok + } + w, ok := sessao.Workspaces[k.id] + return w.Tokens[k.nome], ok + } + pubs, err := e.St.TokensPublicados(ctx) + if err != nil { + return res, err + } + publicado := map[chaveToken]string{} + for _, t := range pubs { + publicado[chaveToken{t.Alvo, t.AlvoID, t.Nome}] = t.Valor + } + for _, k := range ordenar(desejados) { + v := desejados[k] + if a, _ := atual(k); a != v { + if err := e.reportar(ctx, k, &v); err != nil { + res.Erros = append(res.Erros, err.Error()) + continue + } + res.Publicados = append(res.Publicados, k.token(v)) + } + if p, ok := publicado[k]; !ok || p != v { + if err := e.St.MarcarToken(ctx, k.token(v)); err != nil { + return res, err + } + } + } + for _, k := range ordenar(publicado) { + if _, ok := desejados[k]; ok { + continue + } + if a, existe := atual(k); existe && a != "" { + if err := e.reportar(ctx, k, nil); err != nil { + res.Erros = append(res.Erros, err.Error()) + continue + } + res.Limpos = append(res.Limpos, k.token("")) + } + if err := e.St.DesmarcarToken(ctx, k.token("")); err != nil { + return res, err + } + } + return res, nil +} + +// LimparTokens limpa tudo o que o orq publicou (o daemon chama ao sair). +func (e *Enriquecedor) LimparTokens(ctx context.Context) (ResultadoTokens, error) { + res := novoResultadoTokens() + pubs, err := e.St.TokensPublicados(ctx) + if err != nil { + return res, err + } + for _, t := range pubs { + k := chaveToken{t.Alvo, t.AlvoID, t.Nome} + if err := e.reportar(ctx, k, nil); err != nil { + res.Erros = append(res.Erros, err.Error()) + } else { + res.Limpos = append(res.Limpos, k.token("")) + } + if err := e.St.DesmarcarToken(ctx, t); err != nil { + return res, err + } + } + return res, nil +} + +// reportar publica (valor não nil) ou limpa o token pelo report-metadata do Herdr. +func (e *Enriquecedor) reportar(ctx context.Context, k chaveToken, valor *string) error { + args := []string{k.alvo, "report-metadata", k.id, "--source", FonteTokens} + if valor != nil { + args = append(args, "--token", k.nome+"="+*valor) + } else { + args = append(args, "--clear-token", k.nome) + } + _, err := e.F.Rodar(ctx, "herdr", args...) + return err +} + +func ordenar[V any](m map[chaveToken]V) []chaveToken { + ks := make([]chaveToken, 0, len(m)) + for k := range m { + ks = append(ks, k) + } + sort.Slice(ks, func(i, j int) bool { + if ks[i].alvo != ks[j].alvo { + return ks[i].alvo < ks[j].alvo + } + if ks[i].id != ks[j].id { + return ks[i].id < ks[j].id + } + return ks[i].nome < ks[j].nome + }) + return ks +} diff --git a/internal/enriquecer/trava_flock.go b/internal/enriquecer/trava_flock.go new file mode 100644 index 0000000..a024a64 --- /dev/null +++ b/internal/enriquecer/trava_flock.go @@ -0,0 +1,47 @@ +//go:build linux || darwin || freebsd || netbsd || openbsd || dragonfly + +package enriquecer + +import ( + "errors" + "os" + "path/filepath" + "strconv" + "strings" + "syscall" + + "github.com/BasisTI/orq/internal/output" +) + +// TravarDaemon segura o flock de daemon.lock no diretório de estado enquanto o daemon roda e +// grava o pid nele. Um segundo daemon não espera: sai com daemon_ativo. O kernel solta a trava +// se o processo morrer, então não há trava velha para limpar. +func TravarDaemon(dir string) (func(), error) { + if err := os.MkdirAll(dir, 0o700); err != nil { + return nil, err + } + path := filepath.Join(dir, ArquivoTravaDaemon) + f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600) + if err != nil { + return nil, err + } + if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil { + _ = f.Close() + if !errors.Is(err, syscall.EWOULDBLOCK) { + return nil, err + } + pid := "?" + if b, err := os.ReadFile(path); err == nil && strings.TrimSpace(string(b)) != "" { + pid = strings.TrimSpace(string(b)) + } + return nil, &output.Error{Code: "daemon_ativo", Cause: "outro orq daemon já roda com este diretório de estado (pid " + pid + ", trava " + path + ")", + Recovery: "só roda um daemon por máquina; use o que está no ar ou pare-o (SIGTERM) antes de subir outro", Exit: output.ExitConflict} + } + _ = f.Truncate(0) + _, _ = f.WriteAt([]byte(strconv.Itoa(os.Getpid())+"\n"), 0) + return func() { + _ = f.Truncate(0) + _ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN) + _ = f.Close() + }, nil +} diff --git a/internal/enriquecer/trava_outros.go b/internal/enriquecer/trava_outros.go new file mode 100644 index 0000000..3a17d14 --- /dev/null +++ b/internal/enriquecer/trava_outros.go @@ -0,0 +1,10 @@ +//go:build !(linux || darwin || freebsd || netbsd || openbsd || dragonfly) + +package enriquecer + +import "github.com/BasisTI/orq/internal/output" + +// TravarDaemon não tem flock nesta plataforma, e o Herdr é só Linux: o daemon não sobe. +func TravarDaemon(string) (func(), error) { + return nil, &output.Error{Code: "plataforma", Cause: "orq daemon precisa de flock (Linux)", Exit: output.ExitUnexpected} +} From 7708b7735a874e89ab0568985ec0182213815ff6 Mon Sep 17 00:00:00 2001 From: Cedric Lamalle Date: Sun, 11 Oct 2026 15:05:06 +0000 Subject: [PATCH 2/2] =?UTF-8?q?Restringir=20o=20orq=20aos=20tokens=20que?= =?UTF-8?q?=20publicou=20e=20endurecer=20a=20suspens=C3=A3o=20por=20autent?= =?UTF-8?q?ica=C3=A7=C3=A3o=20orq=20#4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 2 +- README.md | 10 +- docs/snapshot.md | 4 +- internal/cli/daemon.go | 5 +- internal/enriquecer/daemon.go | 15 ++- internal/enriquecer/enriquecedor.go | 5 +- internal/enriquecer/enriquecer_test.go | 163 +++++++++++++++++++++---- internal/enriquecer/falsos_test.go | 14 ++- internal/enriquecer/ferramentas.go | 2 + internal/enriquecer/fontes.go | 22 +++- internal/enriquecer/socket_test.go | 5 + internal/enriquecer/tokens.go | 110 ++++++++++++----- 12 files changed, 286 insertions(+), 71 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 48d3fe7..0157774 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,7 +27,7 @@ O CI (`.github/workflows/ci.yml`) roda os três. O teste de concorrência (`inte - Mensagens, docs e códigos de erro em português; saída JSON fora de terminal, texto num terminal (`--output` força). - Toda escrita no banco é uma transação curta (`BEGIN IMMEDIATE` via `_txlock`) e grava o seu evento no diário na mesma transação. A exceção é o que não é estado da Central: cache, suspensão e tokens publicados dos enriquecedores. A mudança de estado de uma MR é estado e gera `mr.estado`. -- Ferramenta externa (`herdr`, `glab`, `gh`, `taiga`) é chamada por caminho absoluto (`enriquecer.Ferramentas`), nunca pelo PATH, e só para ler — o daemon escreve no Herdr apenas os tokens `--source orq`. Falha de subprocesso vira `Erro` no cache; não derruba o processo. +- Ferramenta externa (`herdr`, `glab`, `gh`, `taiga`) é chamada por caminho absoluto (`enriquecer.Ferramentas`), nunca pelo PATH, e só para ler — o daemon escreve no Herdr apenas os tokens `orq` (pane) e `orq_mrs` (workspace), com `--source orq`. No Herdr o token é por nome, não por fonte: registre em `token_publicado` só depois de um `report-metadata` bem-sucedido, e limpe só se o Herdr ainda mostra o último valor do orq. Valor de outro produtor (ex. `mrs_prontas` da skill manual) não é adotado, sobrescrito nem limpo. Falha de subprocesso vira `Erro` no cache; não derruba o processo. - A abertura do banco (criação, WAL, migrations) fica dentro do `flock` de `central.db.init.lock`: a troca para WAL num banco novo devolve `SQLITE_BUSY` na hora, sem passar pelo `busy_timeout`. - Migration nova é arquivo novo; nunca edite uma já publicada. - Mudança que remove ou muda o sentido de um campo do snapshot sobe `versao` e atualiza `docs/snapshot.md`. diff --git a/README.md b/README.md index b643a79..2fe22bc 100644 --- a/README.md +++ b/README.md @@ -62,18 +62,20 @@ Em `onda list` e `pend list`, `--coord` filtra pelo coordenador dono da onda; em ### Daemon e enriquecedores -`orq daemon` roda em primeiro plano (o plugin do Herdr o inicia; o log vai para o stderr) e só existe um por diretório de estado: ele segura um `flock` em `daemon.lock`, e o segundo sai com `daemon_ativo` (exit 4) dizendo o pid do primeiro. A cada tick ele lê o Herdr (`herdr api snapshot`) e confere os tokens; MR e Taiga são olhados a cada `--intervalo-remoto`, cada chave com o seu TTL (MR 60 s, Taiga 5 min). Ele também assina `pane.agent_status_changed` dos panes das ondas abertas no socket do Herdr (`$HERDR_SOCKET_PATH`, a sessão de `$HERDR_SESSION` ou `~/.config/herdr/herdr.sock`) e republica na hora; em `events_lost`, reassina e reconcilia com uma leitura completa. Ao sair, limpa os tokens que publicou. +`orq daemon` roda em primeiro plano (o plugin do Herdr o inicia; o log vai para o stderr) e só existe um por diretório de estado: ele segura um `flock` em `daemon.lock`, e o segundo sai com `daemon_ativo` (exit 4) dizendo o pid do primeiro. A cada tick ele lê o Herdr (`herdr api snapshot`) e confere os tokens; MR e Taiga são olhados a cada `--intervalo-remoto`, cada chave com o seu TTL (MR 60 s, Taiga 5 min). Ele também assina `pane.agent_status_changed` dos panes das ondas abertas no socket do Herdr (`$HERDR_SOCKET_PATH`, a sessão de `$HERDR_SESSION` ou `~/.config/herdr/herdr.sock`) e republica na hora; em `events_lost`, reassina e reconcilia com uma leitura completa. Ao sair, limpa os tokens que publicou e que ainda têm o valor dele. -Só leituras nas forjas e no Taiga. Falha de uma consulta (binário ausente, autenticação, rede) fica no cache com o código e o snapshot mostra o último valor bom com o `erro`; nenhuma derruba o processo. Erro de autenticação suspende a ferramenta por 30 min: o orq não tenta login nem define token. +Só leituras nas forjas e no Taiga. Falha de uma consulta (binário ausente, autenticação, rede) fica no cache com o código e o snapshot mostra o último valor bom com o `erro`; nenhuma derruba o processo. Erro de autenticação (na taiga-cli, o exit 3) suspende a ferramenta por 30 min: o orq não tenta login nem define token. `--forcar` passa por cima de uma suspensão anterior, mas uma recusa na própria execução interrompe as chamadas seguintes da ferramenta. As ferramentas são chamadas por caminho absoluto, porque num pane ou numa ação de plugin o PATH não tem `~/.local/bin`. A ordem: `ORQ_HERDR_BIN`, `ORQ_GLAB_BIN`, `ORQ_GH_BIN` e `ORQ_TAIGA_BIN`; para o herdr, `$HERDR_BIN_PATH`; e então `~/.local/bin`, `~/go/bin`, `~/bin`, `/home/linuxbrew/.linuxbrew/bin`, `/opt/homebrew/bin`, `/usr/local/bin`, `/usr/bin` e `/bin`. O PATH do processo não entra. Tokens de sidebar (`herdr pane|workspace report-metadata --source orq`), conferidos a cada tick contra o que o Herdr mostra — os tokens somem quando o servidor do Herdr reinicia, e o daemon republica: - `orq`, no pane de cada coordenador: as US das ondas abertas por status e as pendências abertas, ex. `2 Exec·1 Rev·1 pend`, com `⚠ ` na frente quando algum pane de US está `blocked`. Até 80 caracteres. -- `mrs_prontas`, no workspace do coordenador (o registrado ou o do pane dele): as MRs `pronta`, `!32` no GitLab e `skills#29` no GitHub; o que não cabe em 80 caracteres vira `+N`. +- `orq_mrs`, no workspace do coordenador (o registrado ou o do pane dele): as MRs `pronta`, `!32` no GitLab e `skills#29` no GitHub; o que não cabe em 80 caracteres vira `+N`. -Só pane e workspace que existem recebem token, e o que deixou de valer é limpo (`--clear-token`). +Os nomes `orq` e `orq_mrs` são do orq; `mrs_prontas`, `story` e `fase` são da publicação manual da skill do orquestrador (`--source orquestrador`), que convive com o daemon até ser aposentada. O plugin configura as linhas da sidebar com os nomes do orq. + +Só pane e workspace que existem recebem token. No Herdr o token é por nome, não por fonte, então a propriedade vem do próprio orq: ele registra um token só depois de publicá-lo com sucesso, e o token continua sendo dele enquanto o Herdr mostrar o último valor que ele publicou. Um token ausente é (re)publicado. Um valor de outro produtor no mesmo nome não é adotado, sobrescrito nem limpo; vira aviso (`avisos` em `orq atualizar`, uma linha no log do daemon). O que deixou de valer só é limpo (`--clear-token`) se ainda tem o valor do orq; na saída, o daemon relê o Herdr antes de limpar. ### Ondas e a próxima US diff --git a/docs/snapshot.md b/docs/snapshot.md index 4195a6f..d9cd4b1 100644 --- a/docs/snapshot.md +++ b/docs/snapshot.md @@ -30,7 +30,9 @@ O snapshot não consulta nada: ele lê o cache que `orq daemon` (em laço) e `or Estado da MR: `mesclada`, `fechada`, `draft`, `pronta` (aberta, não draft e mesclável: `detailed_merge_status` `mergeable` no GitLab, `mergeable_state` `clean` ou `has_hooks` no GitHub) ou `aberta` (o resto: conflito, pipeline, revisão obrigatória, ainda não calculado). Mudança de estado gera `mr.estado` no diário. -Erro de autenticação de `glab`, `gh` ou `taiga` suspende a ferramenta por 30 min (ou até `orq atualizar --forcar`): o orq não tenta login nem define token. A suspensão aparece em `fontes..erro`. +Erro de autenticação de `glab`, `gh` ou `taiga` suspende a ferramenta por 30 min: o orq não tenta login nem define token. Na taiga-cli, é o exit 3 (`auth_*`, `session_*`, `secret_*`, `keyring_*`, `*_secret_*`, `password_file_*`). `orq atualizar --forcar` passa por cima de uma suspensão anterior, mas uma recusa na própria execução interrompe as chamadas seguintes da ferramenta. A suspensão aparece em `fontes..erro`. + +O snapshot não traz os tokens de sidebar. O daemon publica, com nomes próprios, `orq` no pane do coordenador e `orq_mrs` no workspace, e nunca toca token de outro produtor (README, "Daemon e enriquecedores"). ## Esquema diff --git a/internal/cli/daemon.go b/internal/cli/daemon.go index fde01e1..3a7f6e9 100644 --- a/internal/cli/daemon.go +++ b/internal/cli/daemon.go @@ -33,7 +33,8 @@ func (a *App) atualizarCmd() *cobra.Command { Long: `Consulta as fontes uma vez (todas, sem --fonte) e grava o cache que o snapshot mostra. Cada chave respeita o TTL da fonte (Herdr 5 s, MR 60 s, Taiga 5 min); --forcar ignora o TTL e a suspensão por erro de autenticação. Com o Herdr entre as fontes, confere também os tokens de -sidebar (orq no pane do coordenador, mrs_prontas no workspace).`, +sidebar (orq no pane do coordenador, orq_mrs no workspace). Valor de outro produtor nesses +nomes não é adotado, sobrescrito nem limpo; aparece em avisos.`, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { ctx := cmd.Context() @@ -114,7 +115,7 @@ func (a *App) daemonCmd() *cobra.Command { Long: `Roda em primeiro plano até SIGTERM ou SIGINT. A cada tick consulta o Herdr e confere os tokens de sidebar; MR e Taiga são olhados a cada --intervalo-remoto, respeitando o TTL de cada um. Assina pane.agent_status_changed no socket do Herdr ($HERDR_SOCKET_PATH ou -~/.config/herdr/herdr.sock) e republica na hora. Ao sair, limpa os tokens que publicou. +~/.config/herdr/herdr.sock) e republica na hora. Ao sair, relê o Herdr e limpa os tokens que publicou e ainda têm o valor dele. Só roda um por diretório de estado: o segundo sai com daemon_ativo. O log vai para o stderr.`, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { diff --git a/internal/enriquecer/daemon.go b/internal/enriquecer/daemon.go index da1f056..5238f42 100644 --- a/internal/enriquecer/daemon.go +++ b/internal/enriquecer/daemon.go @@ -35,8 +35,8 @@ type Daemon struct { ultimos map[string]string } -// Rodar segue até ctx acabar (SIGTERM/SIGINT no orq daemon) e, ao sair, limpa os tokens que -// publicou. Falha de fonte não derruba o laço; só o erro do banco volta. +// Rodar segue até ctx acabar (SIGTERM/SIGINT no orq daemon) e, ao sair, relê o Herdr e limpa +// os tokens que publicou e ainda têm o valor do orq. Falha de fonte não derruba o laço; só o erro do banco volta. func (d *Daemon) Rodar(ctx context.Context) error { tick := valorDur(d.Tick, 5*time.Second) gatilho := make(chan struct{}, 1) @@ -104,6 +104,10 @@ func (d *Daemon) Rodar(ctx context.Context) error { // ctx já acabou: a limpeza ganha um prazo próprio. fim, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() + // Relê o Herdr para limpar só o que ainda mostra o valor do orq. + if _, err := d.E.AtualizarHerdr(fim); err != nil { + d.logf("", "leitura do Herdr antes da limpeza: %v", err) + } if r, err := d.E.LimparTokens(fim); err != nil { d.logf("", "limpeza dos tokens: %v", err) } else { @@ -113,6 +117,12 @@ func (d *Daemon) Rodar(ctx context.Context) error { if len(r.Erros) > 0 { d.logf("", "erros ao limpar os tokens: %s", strings.Join(r.Erros, "; ")) } + for _, a := range r.Avisos { + d.logf("", "%s", a) + } + if r.Pulado != nil { + d.logf("", "limpeza dos tokens: %s", *r.Pulado) + } } d.logf("fim", "orq daemon encerrado") return falha @@ -141,6 +151,7 @@ func (d *Daemon) ciclo(ctx context.Context) ([]string, error) { d.logf("", "token %s limpo em %s %s", t.Nome, t.Alvo, t.AlvoID) } d.logf("tokens", "%s", strings.Join(tk.Erros, "; ")) + d.logf("avisos", "%s", strings.Join(tk.Avisos, "; ")) return d.panesAssinaveis(ctx) } diff --git a/internal/enriquecer/enriquecedor.go b/internal/enriquecer/enriquecedor.go index 1f820aa..f40e60e 100644 --- a/internal/enriquecer/enriquecedor.go +++ b/internal/enriquecer/enriquecedor.go @@ -186,6 +186,7 @@ func (e *Enriquecedor) consultarChaves(ctx context.Context, fonte string, cs []c if err != nil { return r, err } + novas := map[string]bool{} for _, c := range cs { if ctx.Err() != nil { return r, ctx.Err() @@ -194,7 +195,8 @@ func (e *Enriquecedor) consultarChaves(ctx context.Context, fonte string, cs []c r.EmDia++ continue } - if !forcar && e.suspensa(sus, c.ferramenta) { + // --forcar passa por cima da suspensão anterior, não da que nasceu nesta execução. + if (!forcar && e.suspensa(sus, c.ferramenta)) || novas[c.ferramenta] { r.Suspensas++ continue } @@ -211,6 +213,7 @@ func (e *Enriquecedor) consultarChaves(ctx context.Context, fonte string, cs []c if err := e.St.Suspender(ctx, c.ferramenta, *f.cache()); err != nil { return r, err } + novas[c.ferramenta] = true sus[c.ferramenta] = central.Erro{Codigo: f.Codigo, Mensagem: f.Mensagem, Em: e.agora().UTC().Format(time.RFC3339)} } continue diff --git a/internal/enriquecer/enriquecer_test.go b/internal/enriquecer/enriquecer_test.go index dd67ffa..2cbf0d9 100644 --- a/internal/enriquecer/enriquecer_test.go +++ b/internal/enriquecer/enriquecer_test.go @@ -175,6 +175,58 @@ func TestAutenticacaoSuspende(t *testing.T) { } } +// --forcar ignora a suspensão anterior, mas a recusa de autenticação desta execução para as +// chamadas seguintes da mesma ferramenta. +func TestForcarRespeitaSuspensaoNova(t *testing.T) { + c := novoCenario(t) + co := c.coord("a", "", "") + c.onda(co, "1", "2", "3") + for _, n := range []string{"1", "2", "3"} { + c.responderCodigo("taiga", `{"error":{"code":"session_expired","cause":"simulado"}}`, 3, "story", "get", n, "--project", "orq", "--output", "json") + } + _ = c.st.Suspender(context.Background(), "taiga", central.Erro{Codigo: ErrAutenticacao, Mensagem: "antiga"}) + r := c.atualizar(Opcoes{Fontes: []string{"taiga"}, Forcar: true}) + if f := r.Fontes["taiga"]; f.Consultadas != 1 || f.Suspensas != 2 || len(f.Erros) != 1 { + t.Fatalf("taiga = %+v", f) + } + if n := len(c.chamadas("taiga")); n != 1 { + t.Fatalf("taiga chamado %d vezes, queria 1", n) + } +} + +// A taiga-cli sai com 3 em erro de credencial; keyring_unavailable também suspende. +func TestTaigaExit3EAutenticacao(t *testing.T) { + casos := []struct { + codigo string + exit int + want string + }{ + {"keyring_unavailable", 3, ErrAutenticacao}, + {"keyring_unavailable", 1, ErrAutenticacao}, // o prefixo reforça + {"password_file_unreadable", 1, ErrAutenticacao}, + {"auth_secret_missing", 1, ErrAutenticacao}, + {"algo_novo", 3, ErrAutenticacao}, // o exit 3 basta + {"not_found", 5, ErrNaoEncontrado}, + {"api_error", 1, ErrFalhaComando}, + } + for _, k := range casos { + f := comoFalha(classificarTaiga(&Falha{Codigo: ErrFalhaComando, Status: k.exit, Erro: []byte(`{"error":{"code":"` + k.codigo + `"}}`)})) + if f.Codigo != k.want { + t.Errorf("%s/exit %d = %s, queria %s", k.codigo, k.exit, f.Codigo, k.want) + } + } + c := novoCenario(t) + co := c.coord("a", "", "") + c.onda(co, "1", "2", "3") + for _, n := range []string{"1", "2", "3"} { + c.responderCodigo("taiga", `{"error":{"code":"keyring_unavailable","cause":"simulado"}}`, 3, "story", "get", n, "--project", "orq", "--output", "json") + } + r := c.atualizar(Opcoes{Fontes: []string{"taiga"}}) + if f := r.Fontes["taiga"]; f.Consultadas != 1 || f.Suspensas != 2 || f.Erros[0].Erro.Codigo != ErrAutenticacao { + t.Fatalf("taiga = %+v", f) + } +} + func TestTaigaEBinarioAusente(t *testing.T) { c := novoCenario(t) co := c.coord("a", "", "") @@ -290,53 +342,73 @@ func TestTokensPublicaConfereELimpa(t *testing.T) { {"pane_id": "w1:p1", "workspace_id": "w1", "agent": "claude", "agent_status": "working", "tokens": tokPane}, {"pane_id": "w1:p2", "workspace_id": "w1", "agent": "claude", "agent_status": "blocked"}, }, []map[string]any{{"workspace_id": "w1", "tokens": tokWS}}) + c.atualizar(Opcoes{Fontes: []string{"herdr"}, Forcar: true}) + _ = c.chamadas("herdr") + } + publicar := func() ResultadoTokens { + t.Helper() + r, err := c.e.PublicarTokens(ctx) + if err != nil { + t.Fatal(err) + } + return r } - painel(nil, nil) c.atualizar(Opcoes{}) - _ = c.chamadas("herdr") - r, err := c.e.PublicarTokens(ctx) - if err != nil { - t.Fatal(err) - } + painel(nil, map[string]string{"mrs_prontas": "!403"}) // o mrs_prontas da skill manual não é do orq + r := publicar() got := c.chamadas("herdr") want := []string{ "pane report-metadata w1:p1 --source orq --token orq=⚠ 1 Wait·1 Exec", - "workspace report-metadata w1 --source orq --token mrs_prontas=orq#1 !41", + "workspace report-metadata w1 --source orq --token orq_mrs=orq#1 !41", } if len(got) != 2 || strings.Join(got[0], " ") != want[0] || strings.Join(got[1], " ") != want[1] || len(r.Publicados) != 2 { t.Fatalf("argv = %q, resultado %+v", got, r) } // O Herdr já mostra os tokens: nada a republicar. - painel(map[string]string{"orq": "⚠ 1 Wait·1 Exec"}, map[string]string{"mrs_prontas": "orq#1 !41"}) - c.atualizar(Opcoes{Fontes: []string{"herdr"}, Forcar: true}) - _ = c.chamadas("herdr") - if r, _ := c.e.PublicarTokens(ctx); len(r.Publicados)+len(r.Limpos) != 0 || len(c.chamadas("herdr")) != 0 { + painel(map[string]string{"orq": "⚠ 1 Wait·1 Exec"}, map[string]string{"orq_mrs": "orq#1 !41", "mrs_prontas": "!403"}) + if r := publicar(); len(r.Publicados)+len(r.Limpos)+len(r.Avisos) != 0 || len(c.chamadas("herdr")) != 0 { t.Fatalf("republicou sem mudança: %+v", r) } // O servidor do Herdr reiniciou e os tokens sumiram: republica. painel(nil, nil) - c.atualizar(Opcoes{Fontes: []string{"herdr"}, Forcar: true}) - _ = c.chamadas("herdr") - if r, _ := c.e.PublicarTokens(ctx); len(r.Publicados) != 2 { + if r := publicar(); len(r.Publicados) != 2 { t.Fatalf("não republicou: %+v", r) } _ = c.chamadas("herdr") - // As MRs saem de pronta: mrs_prontas é limpo. - painel(map[string]string{"orq": "⚠ 1 Wait·1 Exec"}, map[string]string{"mrs_prontas": "orq#1 !41"}) + // As MRs saem de pronta: orq_mrs, que ainda tem o valor do orq, é limpo. + painel(map[string]string{"orq": "⚠ 1 Wait·1 Exec"}, map[string]string{"orq_mrs": "orq#1 !41", "mrs_prontas": "!403"}) c.responder("gh", `{"state":"closed","merged":true}`, false, argsGH...) c.responder("glab", `{"state":"opened","draft":true,"detailed_merge_status":"draft_status"}`, false, argsGL...) - c.atualizar(Opcoes{Forcar: true}) - _ = c.chamadas("herdr") - r, _ = c.e.PublicarTokens(ctx) + c.atualizar(Opcoes{Fontes: []string{"mr"}, Forcar: true}) + r = publicar() got = c.chamadas("herdr") - if len(got) != 1 || strings.Join(got[0], " ") != "workspace report-metadata w1 --source orq --clear-token mrs_prontas" || len(r.Limpos) != 1 { + if len(got) != 1 || strings.Join(got[0], " ") != "workspace report-metadata w1 --source orq --clear-token orq_mrs" || len(r.Limpos) != 1 { t.Fatalf("limpeza = %q, %+v", got, r) } + // Outro produtor sobrescreve o token do orq: o orq não o sobrescreve de volta nem o limpa. + painel(map[string]string{"orq": "fase: revisão"}, nil) + r = publicar() + if len(c.chamadas("herdr")) != 0 || len(r.Avisos) != 1 || !strings.Contains(r.Avisos[0], `"fase: revisão"`) { + t.Fatalf("token alheio = %+v", r) + } + if ts, _ := c.st.TokensPublicados(ctx); len(ts) != 0 { + t.Fatalf("token alheio continua registrado: %+v", ts) + } + c.status(co, "1", "done", "") + c.status(co, "2", "done", "") + if r := publicar(); len(c.chamadas("herdr")) != 0 || len(r.Limpos) != 0 { + t.Fatalf("limpou token alheio: %+v", r) + } // O pane do coordenador some: o registro é esquecido sem chamar o Herdr. + painel(nil, nil) + if r := publicar(); len(r.Publicados) != 1 { + t.Fatalf("devia republicar o resumo: %+v", r) + } + _ = c.chamadas("herdr") c.sessao([]map[string]any{{"pane_id": "w1:p2", "workspace_id": "w1", "agent_status": "idle"}}, []map[string]any{{"workspace_id": "w1"}}) c.atualizar(Opcoes{Fontes: []string{"herdr"}, Forcar: true}) _ = c.chamadas("herdr") - if r, _ := c.e.PublicarTokens(ctx); len(r.Publicados)+len(r.Limpos) != 0 || len(c.chamadas("herdr")) != 0 { + if r := publicar(); len(r.Publicados)+len(r.Limpos) != 0 || len(c.chamadas("herdr")) != 0 { t.Fatalf("pane sumido: %+v", r) } if ts, _ := c.st.TokensPublicados(ctx); len(ts) != 0 { @@ -345,11 +417,58 @@ func TestTokensPublicaConfereELimpa(t *testing.T) { // Herdr com erro: não confere nada. c.responder("herdr", "server not running", true, "api", "snapshot") c.atualizar(Opcoes{Fontes: []string{"herdr"}, Forcar: true}) - if r, _ := c.e.PublicarTokens(ctx); r.Pulado == nil { + if r := publicar(); r.Pulado == nil { t.Fatal("devia pular com o Herdr fora") } } +// O caso da revisão r1: outra fonte já publicou exatamente o valor que o orq quer. O orq não o +// adota (não registra) e, quando deixa de querer, não o limpa — nem no fim do daemon. +func TestTokenIgualDeOutraFonteNaoEAdotado(t *testing.T) { + c := novoCenario(t) + ctx := context.Background() + co := c.coord("a", "w1:p1", "") + c.onda(co, "4") + c.sessao([]map[string]any{{"pane_id": "w1:p1", "workspace_id": "w1", "tokens": map[string]string{"orq": "1 Wait"}}}, + []map[string]any{{"workspace_id": "w1"}}) + c.atualizar(Opcoes{Fontes: []string{"herdr"}}) + _ = c.chamadas("herdr") + r, err := c.e.PublicarTokens(ctx) + if err != nil { + t.Fatal(err) + } + if ts, _ := c.st.TokensPublicados(ctx); len(ts) != 0 || len(r.Publicados) != 0 || len(r.Avisos) != 1 { + t.Fatalf("adotou: registro %+v, resultado %+v", ts, r) + } + if _, err := c.st.EncerrarOnda(ctx, "a", 1); err != nil { + t.Fatal(err) + } + if r, _ := c.e.PublicarTokens(ctx); len(r.Limpos) != 0 { + t.Fatalf("limpou: %+v", r) + } + if r, _ := c.e.LimparTokens(ctx); len(r.Limpos) != 0 { + t.Fatalf("limpou ao sair: %+v", r) + } + if got := c.chamadas("herdr"); len(got) != 0 { + t.Fatalf("chamou o Herdr: %q", got) + } +} + +// Publicação que falhou não é registrada: o orq só é dono do que publicou com sucesso. +func TestTokenSoERegistradoDepoisDePublicar(t *testing.T) { + c := novoCenario(t) + ctx := context.Background() + co := c.coord("a", "w1:p1", "") + c.onda(co, "4") + c.sessao([]map[string]any{{"pane_id": "w1:p1", "workspace_id": "w1"}}, []map[string]any{{"workspace_id": "w1"}}) + c.atualizar(Opcoes{Fontes: []string{"herdr"}}) + c.responder("herdr", "pane not found", true, "pane", "report-metadata", "w1:p1", "--source", "orq", "--token", "orq=1 Wait") + r, _ := c.e.PublicarTokens(ctx) + if ts, _ := c.st.TokensPublicados(ctx); len(ts) != 0 || len(r.Erros) != 1 || len(r.Publicados) != 0 { + t.Fatalf("registro %+v, resultado %+v", ts, r) + } +} + func TestDaemonUnico(t *testing.T) { dir := t.TempDir() soltar, err := TravarDaemon(dir) diff --git a/internal/enriquecer/falsos_test.go b/internal/enriquecer/falsos_test.go index 9ab0941..382b374 100644 --- a/internal/enriquecer/falsos_test.go +++ b/internal/enriquecer/falsos_test.go @@ -6,6 +6,7 @@ import ( "os" "path/filepath" "regexp" + "strconv" "strings" "testing" "time" @@ -27,7 +28,7 @@ const scriptFalso = `#!/bin/sh d='%DIR%'; t='%NOME%' printf '%s\037' "$@" >> "$d/log/$t"; echo >> "$d/log/$t" key=$(printf '%s_' "$@" | LC_ALL=C tr -c 'A-Za-z0-9_.-' '_') -if [ -f "$d/resp/$t/$key.err" ]; then cat "$d/resp/$t/$key.err" >&2; exit 1; fi +if [ -f "$d/resp/$t/$key.err" ]; then cat "$d/resp/$t/$key.err" >&2; exit $(cat "$d/resp/$t/$key.code" 2>/dev/null || echo 1); fi if [ -f "$d/resp/$t/$key.out" ]; then cat "$d/resp/$t/$key.out"; exit 0; fi exit 0 ` @@ -68,6 +69,7 @@ func (f *falsos) responder(nome, saida string, erro bool, args ...string) { base := filepath.Join(f.dir, "resp", nome, chaveArgs(args...)) _ = os.Remove(base + ".out") _ = os.Remove(base + ".err") + _ = os.Remove(base + ".code") ext := ".out" if erro { ext = ".err" @@ -77,6 +79,16 @@ func (f *falsos) responder(nome, saida string, erro bool, args ...string) { } } +// responderCodigo é o responder de erro com código de saída próprio. +func (f *falsos) responderCodigo(nome, saida string, codigo int, args ...string) { + f.t.Helper() + f.responder(nome, saida, true, args...) + base := filepath.Join(f.dir, "resp", nome, chaveArgs(args...)) + if err := os.WriteFile(base+".code", []byte(strconv.Itoa(codigo)), 0o644); err != nil { + f.t.Fatal(err) + } +} + // chamadas devolve o argv de cada chamada à ferramenta, na ordem, e esvazia o log. func (f *falsos) chamadas(nome string) [][]string { f.t.Helper() diff --git a/internal/enriquecer/ferramentas.go b/internal/enriquecer/ferramentas.go index c050c10..abaff9c 100644 --- a/internal/enriquecer/ferramentas.go +++ b/internal/enriquecer/ferramentas.go @@ -53,6 +53,7 @@ type Falha struct { Mensagem string Saida []byte // stdout Erro []byte // stderr + Status int // código de saída do processo; 0 quando ele nem rodou ou foi morto } func (f *Falha) Error() string { return f.Codigo + ": " + f.Mensagem } @@ -119,6 +120,7 @@ func (f Ferramentas) Rodar(ctx context.Context, nome string, args ...string) ([] case errors.Is(ctx.Err(), context.DeadlineExceeded): falha.Codigo, falha.Mensagem = ErrTempoEsgotado, fmt.Sprintf("%s não respondeu em %s", nome, timeout) case errors.As(err, &ee): + falha.Status = ee.ExitCode() falha.Mensagem = fmt.Sprintf("%s saiu com %d: %s", nome, ee.ExitCode(), resumir(stderr.Bytes(), stdout.Bytes())) default: falha.Mensagem = fmt.Sprintf("%s: %v", nome, err) diff --git a/internal/enriquecer/fontes.go b/internal/enriquecer/fontes.go index fb5bef1..85d1b5d 100644 --- a/internal/enriquecer/fontes.go +++ b/internal/enriquecer/fontes.go @@ -223,8 +223,10 @@ func (f Ferramentas) ConsultarTaiga(ctx context.Context, projeto string, ref int return central.ValorTaiga{Titulo: r.Subject, Status: r.StatusExtraInfo.Name, URL: r.URL}, nil } -// classificarTaiga lê o envelope {"error":{"code"}} da CLI taiga: auth_* e session_* são -// autenticação; not_found é não encontrado. +// classificarTaiga separa autenticação e "não encontrado" das outras falhas da CLI taiga. A +// taiga-cli sai com 3 em erro de autenticação ou credencial (auth_*, session_*, secret_*, +// keyring_*, *_secret_*, password_file_*); os prefixos do código no envelope +// {"error":{"code"}} reforçam quando o código de saída se perde. not_found é não encontrado. func classificarTaiga(err error) error { f := comoFalha(err) if f.Codigo != ErrFalhaComando { @@ -246,16 +248,28 @@ func classificarTaiga(err error) error { f.Mensagem += ": " + resumir([]byte(env.Error.Cause)) } switch { - case strings.HasPrefix(c, "auth_") || strings.HasPrefix(c, "session_"): + case codigoCredencialTaiga(c): f.Codigo = ErrAutenticacao case c == "not_found": f.Codigo = ErrNaoEncontrado } - return f + break + } + if f.Status == 3 { + f.Codigo = ErrAutenticacao } return f } +func codigoCredencialTaiga(c string) bool { + for _, p := range []string{"auth_", "session_", "secret_", "keyring_", "password_file_"} { + if strings.HasPrefix(c, p) { + return true + } + } + return strings.Contains(c, "_secret_") +} + func deref(s *string) string { if s == nil { return "" diff --git a/internal/enriquecer/socket_test.go b/internal/enriquecer/socket_test.go index 34936d2..b0a15e1 100644 --- a/internal/enriquecer/socket_test.go +++ b/internal/enriquecer/socket_test.go @@ -201,6 +201,11 @@ func TestDaemonEventosEReconciliacao(t *testing.T) { if len(ts) != 1 || ts[0].Valor != "1 Exec" { t.Fatalf("tokens = %+v", ts) } + // O Herdr passa a mostrar o token do orq: a saída relê o Herdr e limpa o que é do orq. + c.sessao([]map[string]any{ + {"pane_id": "w1:p1", "workspace_id": "w1", "agent": "claude", "agent_status": "working", "tokens": map[string]string{"orq": "1 Exec"}}, + {"pane_id": "w1:p2", "workspace_id": "w1", "agent": "claude", "agent_status": "working"}, + }, []map[string]any{{"workspace_id": "w1"}}) cancel() select { case err := <-fim: diff --git a/internal/enriquecer/tokens.go b/internal/enriquecer/tokens.go index 8add19b..38a2cf0 100644 --- a/internal/enriquecer/tokens.go +++ b/internal/enriquecer/tokens.go @@ -2,6 +2,7 @@ package enriquecer import ( "context" + "fmt" "sort" "strconv" "strings" @@ -13,10 +14,10 @@ import ( // Tokens de sidebar do orq no Herdr. const ( - FonteTokens = "orq" // --source dos report-metadata - TokenResumo = "orq" // no pane do coordenador - TokenMRsProntas = "mrs_prontas" // no workspace do coordenador - LimiteToken = 80 // o Herdr corta o valor em 80 caracteres + FonteTokens = "orq" // --source dos report-metadata + TokenResumo = "orq" // no pane do coordenador + TokenMRsProntas = "orq_mrs" // no workspace do coordenador + LimiteToken = 80 // o Herdr corta o valor em 80 caracteres ) // ResultadoTokens diz o que a publicação fez. @@ -24,11 +25,12 @@ type ResultadoTokens struct { Publicados []central.Token `json:"publicados"` Limpos []central.Token `json:"limpos"` Erros []string `json:"erros"` + Avisos []string `json:"avisos"` // tokens com valor de outro produtor, que o orq não toca Pulado *string `json:"pulado"` // motivo, quando nada foi conferido } func novoResultadoTokens() ResultadoTokens { - return ResultadoTokens{Publicados: []central.Token{}, Limpos: []central.Token{}, Erros: []string{}} + return ResultadoTokens{Publicados: []central.Token{}, Limpos: []central.Token{}, Erros: []string{}, Avisos: []string{}} } var rotulos = []struct{ status, rotulo string }{ @@ -143,9 +145,12 @@ func (k chaveToken) token(valor string) central.Token { return central.Token{Alvo: k.alvo, AlvoID: k.id, Nome: k.nome, Valor: valor} } -// PublicarTokens confere os tokens do orq contra a última sessão do Herdr: publica o que falta -// ou mudou (inclusive o que sumiu porque o servidor do Herdr reiniciou) e limpa o que deixou -// de valer. Só toca panes e workspaces que existem na sessão. +// PublicarTokens confere os tokens do orq contra a última sessão do Herdr. No Herdr o token é +// por nome, não por fonte, então a propriedade vem do registro do orq (token_publicado): um +// token é do orq só enquanto o Herdr mostra o último valor que o orq publicou com sucesso. +// Publica o que está ausente (inclusive o que sumiu porque o servidor do Herdr reiniciou) ou +// ainda tem o valor antigo do orq; valor de outro produtor não é adotado, sobrescrito nem +// limpo, e vira aviso. Só toca panes e workspaces que existem na sessão. func (e *Enriquecedor) PublicarTokens(ctx context.Context) (ResultadoTokens, error) { res := novoResultadoTokens() sessao, erro, err := e.St.LerSessaoHerdr(ctx) @@ -188,14 +193,7 @@ func (e *Enriquecedor) PublicarTokens(ctx context.Context) (ResultadoTokens, err desejados[chaveToken{"workspace", ws, TokenMRsProntas}] = v } } - atual := func(k chaveToken) (string, bool) { - if k.alvo == "pane" { - p, ok := sessao.Panes[k.id] - return p.Tokens[k.nome], ok - } - w, ok := sessao.Workspaces[k.id] - return w.Tokens[k.nome], ok - } + atual := sessaoTokens{sessao}.token pubs, err := e.St.TokensPublicados(ctx) if err != nil { return res, err @@ -206,52 +204,86 @@ func (e *Enriquecedor) PublicarTokens(ctx context.Context) (ResultadoTokens, err } for _, k := range ordenar(desejados) { v := desejados[k] - if a, _ := atual(k); a != v { + a, _ := atual(k) + p, nosso := publicado[k] + switch { + case nosso && a == p && a == v: + // Em dia. + case a == "" || (nosso && a == p): + // Ausente (o Herdr reiniciou, ou nunca foi publicado) ou é o valor antigo do orq. if err := e.reportar(ctx, k, &v); err != nil { res.Erros = append(res.Erros, err.Error()) continue } res.Publicados = append(res.Publicados, k.token(v)) - } - if p, ok := publicado[k]; !ok || p != v { if err := e.St.MarcarToken(ctx, k.token(v)); err != nil { return res, err } + default: + // Outro produtor escreveu esse nome (com o mesmo valor ou não): o token não é do + // orq, que não o adota nem sobrescreve. + res.Avisos = append(res.Avisos, alheio(k, a)) + if nosso { + if err := e.St.DesmarcarToken(ctx, k.token("")); err != nil { + return res, err + } + } } } for _, k := range ordenar(publicado) { if _, ok := desejados[k]; ok { continue } - if a, existe := atual(k); existe && a != "" { + if err := e.limparSeNosso(ctx, k, publicado[k], atual, &res); err != nil { + return res, err + } + } + return res, nil +} + +// limparSeNosso limpa o token só se o Herdr ainda mostra o último valor que o orq publicou; +// valor de outro produtor fica, e o registro é esquecido. Falha ao limpar mantém o registro +// para a próxima tentativa. +func (e *Enriquecedor) limparSeNosso(ctx context.Context, k chaveToken, publicado string, atual func(chaveToken) (string, bool), res *ResultadoTokens) error { + a, existe := atual(k) + if existe && a != "" { + if a != publicado { + res.Avisos = append(res.Avisos, alheio(k, a)) + } else { if err := e.reportar(ctx, k, nil); err != nil { res.Erros = append(res.Erros, err.Error()) - continue + return nil } res.Limpos = append(res.Limpos, k.token("")) } - if err := e.St.DesmarcarToken(ctx, k.token("")); err != nil { - return res, err - } } - return res, nil + return e.St.DesmarcarToken(ctx, k.token("")) } -// LimparTokens limpa tudo o que o orq publicou (o daemon chama ao sair). +func alheio(k chaveToken, valor string) string { + return fmt.Sprintf("%s %s: o token %s tem %q, que o orq não publicou; fica como está", k.alvo, k.id, k.nome, valor) +} + +// LimparTokens limpa o que o orq publicou e o Herdr ainda mostra com o valor do orq (o daemon +// chama ao sair, depois de reler o Herdr). Sem sessão confiável não limpa nada: não dá para +// saber se o token ainda é do orq. func (e *Enriquecedor) LimparTokens(ctx context.Context) (ResultadoTokens, error) { res := novoResultadoTokens() + sessao, erro, err := e.St.LerSessaoHerdr(ctx) + if err != nil { + return res, err + } + if sessao == nil || erro != nil { + motivo := "sem sessão confiável do Herdr: tokens não conferidos nem limpos" + res.Pulado = &motivo + return res, nil + } pubs, err := e.St.TokensPublicados(ctx) if err != nil { return res, err } for _, t := range pubs { - k := chaveToken{t.Alvo, t.AlvoID, t.Nome} - if err := e.reportar(ctx, k, nil); err != nil { - res.Erros = append(res.Erros, err.Error()) - } else { - res.Limpos = append(res.Limpos, k.token("")) - } - if err := e.St.DesmarcarToken(ctx, t); err != nil { + if err := e.limparSeNosso(ctx, chaveToken{t.Alvo, t.AlvoID, t.Nome}, t.Valor, sessaoTokens{sessao}.token, &res); err != nil { return res, err } } @@ -270,6 +302,18 @@ func (e *Enriquecedor) reportar(ctx context.Context, k chaveToken, valor *string return err } +// sessaoTokens lê o valor atual de um token na sessão do Herdr e se o alvo existe. +type sessaoTokens struct{ *central.SessaoHerdr } + +func (s sessaoTokens) token(k chaveToken) (string, bool) { + if k.alvo == "pane" { + p, ok := s.Panes[k.id] + return p.Tokens[k.nome], ok + } + w, ok := s.Workspaces[k.id] + return w.Tokens[k.nome], ok +} + func ordenar[V any](m map[chaveToken]V) []chaveToken { ks := make([]chaveToken, 0, len(m)) for k := range m {