diff --git a/AGENTS.md b/AGENTS.md index 4b19bec..0157774 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 `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 88d928e..2fe22bc 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,27 @@ 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 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 (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. +- `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`. + +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 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..d9cd4b1 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,27 @@ - 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: 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 ```text @@ -20,7 +41,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 +74,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 +103,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 +119,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 +156,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 +193,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 +214,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 +251,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..3a7f6e9 --- /dev/null +++ b/internal/cli/daemon.go @@ -0,0 +1,144 @@ +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, 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() + 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, 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 { + 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..5238f42 --- /dev/null +++ b/internal/enriquecer/daemon.go @@ -0,0 +1,315 @@ +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, 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) + 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() + // 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 { + 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, "; ")) + } + 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 +} + +// 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, "; ")) + d.logf("avisos", "%s", strings.Join(tk.Avisos, "; ")) + 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..f40e60e --- /dev/null +++ b/internal/enriquecer/enriquecedor.go @@ -0,0 +1,299 @@ +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 + } + novas := map[string]bool{} + 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 + } + // --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 + } + 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 + } + novas[c.ferramenta] = true + 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..2cbf0d9 --- /dev/null +++ b/internal/enriquecer/enriquecer_test.go @@ -0,0 +1,488 @@ +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"]) + } +} + +// --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", "", "") + 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}}) + 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 + } + c.atualizar(Opcoes{}) + 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 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{"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) + if r := publicar(); len(r.Publicados) != 2 { + t.Fatalf("não republicou: %+v", r) + } + _ = c.chamadas("herdr") + // 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{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 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 := 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 { + 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 := 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) + 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..382b374 --- /dev/null +++ b/internal/enriquecer/falsos_test.go @@ -0,0 +1,217 @@ +package enriquecer + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "regexp" + "strconv" + "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 $(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 +` + +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") + _ = os.Remove(base + ".code") + ext := ".out" + if erro { + ext = ".err" + } + if err := os.WriteFile(base+ext, []byte(saida), 0o644); err != nil { + f.t.Fatal(err) + } +} + +// 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() + 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..abaff9c --- /dev/null +++ b/internal/enriquecer/ferramentas.go @@ -0,0 +1,165 @@ +// 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 + 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 } + +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.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) + } + 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..85d1b5d --- /dev/null +++ b/internal/enriquecer/fontes.go @@ -0,0 +1,285 @@ +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 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 { + 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 codigoCredencialTaiga(c): + f.Codigo = ErrAutenticacao + case c == "not_found": + f.Codigo = ErrNaoEncontrado + } + 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 "" + } + 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..b0a15e1 --- /dev/null +++ b/internal/enriquecer/socket_test.go @@ -0,0 +1,225 @@ +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) + } + // 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: + 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..38a2cf0 --- /dev/null +++ b/internal/enriquecer/tokens.go @@ -0,0 +1,332 @@ +package enriquecer + +import ( + "context" + "fmt" + "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 = "orq_mrs" // 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"` + 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{}, Avisos: []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. 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) + 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 := sessaoTokens{sessao}.token + 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] + 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 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 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()) + return nil + } + res.Limpos = append(res.Limpos, k.token("")) + } + } + return e.St.DesmarcarToken(ctx, k.token("")) +} + +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 { + if err := e.limparSeNosso(ctx, chaveToken{t.Alvo, t.AlvoID, t.Nome}, t.Valor, sessaoTokens{sessao}.token, &res); 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 +} + +// 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 { + 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} +}