diff --git a/crates/forge-cli/src/doctor_console.rs b/crates/forge-cli/src/doctor_console.rs new file mode 100644 index 0000000..e1cb7ba --- /dev/null +++ b/crates/forge-cli/src/doctor_console.rs @@ -0,0 +1,194 @@ +//! Fase 7 Onda 13 (Modelo & Onboarding): `GET /api/doctor` agrega checagens +//! já existentes mas espalhadas (env vars do gateway, `uv --version`, ping ao +//! Docker via bollard, git) numa única resposta — mesmo padrão de agregação +//! de `GET /api/sandbox`/`GET /api/mcp` (Ondas 7/10). Mora aqui (não em +//! `forge-server`) porque a checagem de Docker precisa de +//! `forge_tools::sandbox::Sandbox` — regra de posicionamento de rota da fase. + +use axum::response::{IntoResponse, Json}; +use axum::routing::get; +use axum::Router; +use forge_tools::sandbox::Sandbox; +use serde::Serialize; + +#[derive(Serialize)] +struct DoctorCheck { + id: &'static str, + ok: bool, + detail: String, +} + +#[derive(Serialize)] +struct DoctorView { + checks: Vec, +} + +/// Mesma ordem/conjunto de `forge-server`'s `KNOWN_PROVIDERS` (Onda 12) — +/// duplicada, não importada: a direção de dependência entre os dois crates +/// só permite `forge-cli` depender de `forge-server`, nunca o contrário, e +/// isto mora em `forge-cli`. Mesma convenção já usada por `git_sha`/ +/// `now_rfc3339`. +const KNOWN_PROVIDERS: [&str; 3] = ["anthropic", "deepseek", "openai"]; + +fn providers_check() -> DoctorCheck { + let gateway = forge_llm::gateway::Gateway::from_env(); + let available: std::collections::HashSet = gateway.available().into_iter().collect(); + let configured = KNOWN_PROVIDERS + .iter() + .filter(|id| available.contains(**id)) + .count(); + DoctorCheck { + id: "providers", + ok: configured > 0, + detail: format!( + "{configured}/{} provider(s) configurado(s)", + KNOWN_PROVIDERS.len() + ), + } +} + +/// `uv --version` com PATH injetável — permite ao teste simular "uv ausente" +/// apontando pra um PATH garantidamente vazio, sem depender do PATH real do +/// processo de teste (mesmo espírito de `Sandbox::ping_with` receber um +/// client já configurado em vez de só `ping()`). Confere `status.success()`, +/// não só se o processo conseguiu ser criado — diferente do guard de teste +/// `uv_missing()` (duplicado em vários `#[cfg(test)]` deste workspace, que só +/// quer saber "existe pra pular o teste"), este é o doctor real mostrado ao +/// usuário, então um `uv` presente mas quebrado deve aparecer como ausente. +fn uv_check_with_path(path_override: Option<&str>) -> DoctorCheck { + let mut cmd = std::process::Command::new("uv"); + cmd.arg("--version"); + if let Some(path) = path_override { + cmd.env("PATH", path); + } + let ok = cmd.output().is_ok_and(|o| o.status.success()); + DoctorCheck { + id: "uv", + ok, + detail: if ok { + "uv encontrado — sidecar Python (squad/PromptForge) disponível".into() + } else { + "uv ausente do PATH — squad/PromptForge ficam indisponíveis".into() + }, + } +} + +/// Reusa `crate::git_sha()` (`main.rs`) — mesma checagem que já formata o sha +/// no cabeçalho do `forge verify`, aqui só reinterpretada como um check +/// ok/detail. `pub(crate)` não é necessário: `git_sha` já é visível a +/// qualquer módulo deste crate (item privado do módulo raiz). +fn git_check() -> DoctorCheck { + match crate::git_sha() { + Some(sha) => DoctorCheck { + id: "git", + ok: true, + detail: format!( + "repositório git detectado — HEAD {}", + &sha[..sha.len().min(8)] + ), + }, + None => DoctorCheck { + id: "git", + ok: false, + detail: "git ausente do PATH ou fora de um repositório".into(), + }, + } +} + +async fn docker_check() -> DoctorCheck { + let ok = Sandbox::ping().await; + DoctorCheck { + id: "docker", + ok, + detail: if ok { + "daemon Docker alcançável — sandbox de skills de terceiro disponível".into() + } else { + "daemon Docker inalcançável — skills de terceiro não rodam confinadas".into() + }, + } +} + +/// `GET /api/doctor` — as 4 checagens de `forge init`, hoje espalhadas, +/// agregadas numa resposta só. Reexecuta tudo a cada request (mesmo estilo +/// síncrono-por-request de `/api/sandbox`/`/api/mcp`) sem cache — o custo é +/// baixo (1 spawn de processo cada + 1 ping Docker com timeout curto). +async fn get_doctor() -> impl IntoResponse { + let checks = vec![ + providers_check(), + uv_check_with_path(None), + docker_check().await, + git_check(), + ]; + Json(DoctorView { checks }) +} + +/// Router aditivo do doctor — `.merge()`ado ao router do agente web, mesma +/// composição de `sandbox_console::router`/`lsp_console::router`. +pub fn router() -> Router { + Router::new().route("/api/doctor", get(get_doctor)) +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::body::Body; + use axum::http::{Request, StatusCode}; + use tower::ServiceExt; + + /// Determinístico, sem depender do PATH real do processo de teste: aponta + /// pra um PATH vazio, garantidamente sem `uv`. + #[test] + fn uv_ausente_do_path_da_check_false() { + let check = uv_check_with_path(Some("")); + assert!(!check.ok); + assert!(check.detail.contains("ausente")); + } + + /// Fronteira: `providers` bate por igualdade com os mesmos env vars reais + /// que uma sessão real leria — mesma técnica de isolamento de + /// `providers_reflete_env_vars_reais` (`forge-server`, Onda 12): nenhum + /// outro teste deste crate lê essas 3 chaves, então não há disputa com + /// outro teste rodando em paralelo no mesmo binário. `uv`/`docker`/`git` + /// não têm valor fixo afirmado aqui (variam por ambiente — mesmo espírito + /// do teste de `/api/sandbox`); só que vieram bem formados. + #[tokio::test] + async fn doctor_agrega_as_4_checagens_com_providers_real() { + std::env::remove_var("DEEPSEEK_API_KEY"); + std::env::remove_var("OPENAI_API_KEY"); + std::env::set_var("ANTHROPIC_API_KEY", "test-key-onda-13"); + + let app = router(); + let resp = app + .oneshot( + Request::builder() + .uri("/api/doctor") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body = axum::body::to_bytes(resp.into_body(), usize::MAX) + .await + .unwrap(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + let checks = json["checks"].as_array().unwrap(); + assert_eq!(checks.len(), 4); + + let ids: Vec<&str> = checks.iter().map(|c| c["id"].as_str().unwrap()).collect(); + assert_eq!(ids, vec!["providers", "uv", "docker", "git"]); + + let providers = checks.iter().find(|c| c["id"] == "providers").unwrap(); + assert_eq!(providers["ok"], true); + assert_eq!(providers["detail"], "1/3 provider(s) configurado(s)"); + + for id in ["uv", "docker", "git"] { + let c = checks.iter().find(|c| c["id"] == id).unwrap(); + assert!( + c["ok"].is_boolean(), + "{id} deveria devolver um bool bem formado" + ); + assert!(c["detail"].is_string()); + } + } +} diff --git a/crates/forge-cli/src/main.rs b/crates/forge-cli/src/main.rs index 0c2a726..1799c62 100644 --- a/crates/forge-cli/src/main.rs +++ b/crates/forge-cli/src/main.rs @@ -6,6 +6,7 @@ //! `squad` ativa o sidecar Python na Fase 4; `verify` completa na Fase 5. mod cache; +mod doctor_console; mod lsp_console; mod mcp_console; mod memory_console; @@ -296,12 +297,14 @@ async fn run_dashboard(port: u16, web_agent: bool) -> Result<()> { let memory_router = memory_console::router(memory_service); let sandbox_router = sandbox_console::router(); let lsp_router = lsp_console::router(root.clone()); + let doctor_router = doctor_console::router(); let extra_router = squad_router .merge(prompt_router) .merge(mcp_router) .merge(memory_router) .merge(sandbox_router) - .merge(lsp_router); + .merge(lsp_router) + .merge(doctor_router); web_agent::serve_with_agent( telemetry, prompt_library, diff --git a/crates/forge-cli/src/squad.rs b/crates/forge-cli/src/squad.rs index 9dbc0f8..f0dbcee 100644 --- a/crates/forge-cli/src/squad.rs +++ b/crates/forge-cli/src/squad.rs @@ -224,6 +224,14 @@ async fn try_squad( let verification_evidence_json = serde_json::to_string(&evidence) .map_err(|e| format!("falha ao serializar evidência: {e}"))?; + // `max_autonomy_level` hardcoded (não uma flag de CLI, nem lido do + // request web): confirmado nesta onda que o campo é ignorado + // ponta-a-ponta hoje — `forge_squad/server.py::ExecuteTask` nunca lê + // `request.max_autonomy_level`; a autonomia real vem de + // `ProgressiveAutonomyManager`/`agent_trust_scores` (`hitl.py`), + // desconectado deste campo do proto. Wire-lo até a UI seria só "o campo + // viajou" sem efeito nenhum — descope explícito (ADR 0021), não + // esquecimento. Ver `pendencias.md` (Onda 13). let stream = client .execute_task(SquadTask { task_id: format!("s{pid:x}", pid = std::process::id()), diff --git a/crates/forge-cli/src/squad_agent.rs b/crates/forge-cli/src/squad_agent.rs index 8b2878d..2fa9314 100644 --- a/crates/forge-cli/src/squad_agent.rs +++ b/crates/forge-cli/src/squad_agent.rs @@ -332,6 +332,10 @@ where .map_err(|e: SidecarError| e.to_string())?; let mut client = lease.client().clone(); + // Hardcoded, não lido de `RunSquadBody`: o campo é ignorado + // ponta-a-ponta pelo Python hoje (ver mesmo comentário em `squad.rs`) — + // wire-lo até a UI seria "o campo viajou" sem efeito real. Descope + // explícito da Onda 13 (ADR 0021), não esquecimento. let mut stream = client .execute_task(SquadTask { task_id: task_id.clone(), diff --git a/crates/forge-cli/src/web_agent.rs b/crates/forge-cli/src/web_agent.rs index a6c6961..be2f001 100644 --- a/crates/forge-cli/src/web_agent.rs +++ b/crates/forge-cli/src/web_agent.rs @@ -1602,6 +1602,123 @@ mod tests { std::env::set_current_dir(orig_cwd).unwrap(); } + /// Onda 13 (Modelo & Onboarding): `model`/`agent` já existiam em + /// `SendMessageBody` desde a Onda 1, mas o frontend nunca os populava — + /// `unwrap_or_else` sempre caía no default. Esta onda liga o frontend; + /// este teste prova que o campo `agent` do corpo HTTP produz + /// comportamento OBSERVÁVEL diferente via `load_rule_overrides(&root, + /// &opts.agent)` — não só "o campo viajou": um override real (mesmo + /// mecanismo persistido da matriz de permissão, Onda 2) para + /// `plan`+`bash` = deny faz a MESMA mensagem roteirizada terminar direto + /// em `tool_denied` quando `agent: "plan"` é enviado, e pedir + /// confirmação (`permission_requested`, o default real de `build`, sem + /// override) quando `agent` nem é enviado. + #[tokio::test(flavor = "multi_thread")] + async fn post_message_respeita_o_agent_do_corpo_via_override_persistido() { + let _guard = lock_cwd().await; + std::env::set_var("FORGE_SCRIPTED", "1"); + let dir = tempfile::tempdir().unwrap(); + let orig_cwd = std::env::current_dir().unwrap(); + std::env::set_current_dir(dir.path()).unwrap(); + + open_rule_store(dir.path()) + .unwrap() + .set( + "plan", + "bash", + None, + forge_store::RuleDecision::Deny, + &crate::session::now_rfc3339(), + ) + .unwrap(); + + let hub = SessionHub::new(8, Duration::from_secs(5)); + let app = router(hub.clone()); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + let client = reqwest::Client::new(); + + // agent="plan": override deny já decide, nunca pede confirmação. + let sse_plan = client + .get(format!("http://{addr}/api/session/plan1/events")) + .send() + .await + .unwrap(); + let mut stream_plan = sse_plan.bytes_stream(); + client + .post(format!("http://{addr}/api/session/plan1/message")) + .json(&serde_json::json!({"message": "diga oi", "agent": "plan"})) + .send() + .await + .unwrap(); + let mut buf_plan = String::new(); + loop { + let chunk = stream_plan.next().await.unwrap().unwrap(); + buf_plan.push_str(std::str::from_utf8(&chunk).unwrap()); + if extract_events(&buf_plan) + .iter() + .any(|e| e.get("type").map(|t| t == "done").unwrap_or(false)) + { + break; + } + } + let events_plan = extract_events(&buf_plan); + assert!( + events_plan + .iter() + .any(|e| e.get("type").map(|t| t == "tool_denied").unwrap_or(false)), + "agent=plan com override deny deveria negar bash sem perguntar: {events_plan:?}" + ); + assert!( + !events_plan.iter().any(|e| e + .get("type") + .map(|t| t == "permission_requested") + .unwrap_or(false)), + "não deveria pedir confirmação — o override já decide sozinho" + ); + + // Sem `agent` no corpo: cai no default "build" (sem override) — bash + // pede confirmação de verdade, comportamento diferente do caso acima. + let sse_build = client + .get(format!("http://{addr}/api/session/build1/events")) + .send() + .await + .unwrap(); + let mut stream_build = sse_build.bytes_stream(); + client + .post(format!("http://{addr}/api/session/build1/message")) + .json(&serde_json::json!({"message": "diga oi"})) + .send() + .await + .unwrap(); + let mut buf_build = String::new(); + let request_id = loop { + let chunk = stream_build.next().await.unwrap().unwrap(); + buf_build.push_str(std::str::from_utf8(&chunk).unwrap()); + let found = extract_events(&buf_build).into_iter().find_map(|e| { + if e.get("type")? == "permission_requested" { + Some(e.get("request_id")?.as_str()?.to_string()) + } else { + None + } + }); + if let Some(id) = found { + break id; + } + }; + client + .post(format!("http://{addr}/api/session/build1/permission")) + .json(&serde_json::json!({"request_id": request_id, "allow": true})) + .send() + .await + .unwrap(); + + std::env::set_current_dir(orig_cwd).unwrap(); + } + #[tokio::test] async fn post_message_alem_do_teto_de_sessoes_recebe_429() { let hub = SessionHub::new(1, Duration::from_secs(5)); diff --git a/docs/adr/0021-autonomia-progressiva-escopo-fase-7.md b/docs/adr/0021-autonomia-progressiva-escopo-fase-7.md new file mode 100644 index 0000000..8e06549 --- /dev/null +++ b/docs/adr/0021-autonomia-progressiva-escopo-fase-7.md @@ -0,0 +1,91 @@ +# ADR 0021 — escopo da autonomia progressiva na Fase 7 (`max_autonomy_level`) + +- Status: aceita +- Data: 2026-07-07 + +## Contexto + +O plano-mestre da Fase 7 (Onda 13, "Modelo & Onboarding") previa duas rotas +possíveis para o campo `max_autonomy_level` (`schemas/proto/squad.proto`, +`SquadTask`): (a) só ligar o valor real escolhido na UI até o campo HTTP, +deixando o orquestrador continuar ignorando-o, com a tela declarando isso +explicitamente; ou (b) também mudar o orquestrador Python para respeitar um +teto de autonomia por tarefa, produzindo comportamento observável diferente +por nível — a fronteira que o próprio plano exige caso "autonomia entre": +"dois `SquadTask` com `max_autonomy_level` diferentes produzem comportamento +diferente (aprovação pedida num caso, não no outro) — não só 'o campo +viajou'". + +Investigação desta onda confirmou que o campo é **ignorado ponta-a-ponta** +hoje, nos dois pontos de entrada: + +- `crates/forge-cli/src/squad.rs` (CLI, `forge squad`) e + `crates/forge-cli/src/squad_agent.rs` (`POST /api/squad/run`, web) — ambos + hardcodam `max_autonomy_level: 3` ao montar `SquadTask`; nenhum dos dois lê + de uma flag de CLI ou de um campo do corpo HTTP (`RunSquadBody` só tem + `task: String`). +- `python/packages/forge-squad/src/forge_squad/server.py`'s `ExecuteTask` + nunca lê `request.max_autonomy_level` ao montar o `task` interno repassado + ao orquestrador — confirmado por grep no pacote inteiro (a única outra + ocorrência do nome é o `FileDescriptorProto` serializado dentro do stub + gerado, `squad_pb2.py`). +- A autonomia que de fato roda vem de um mecanismo **completamente + desconectado** desse campo: `ProgressiveAutonomyManager` + (`forge_squad/hitl.py`), que decide por **agente**, via + `agent_trust_scores` (score inicial 0.5 → nível 2, ajustado ±0.02/-0.1 por + sucesso/falha em `record_action`) — nunca por um teto vindo de fora na + tarefa. + +## Decisão + +**Piso, não degrau — opção (a).** A Onda 13 liga `model`/`agent` (que já +tinham campo em `SendMessageBody` desde a Onda 1, nunca populados pelo +frontend) até a sessão de chat real, porque esses dois genuinamente mudam +comportamento observável (perfil de agente seleciona overlay de permissão +real via `load_rule_overrides`). `max_autonomy_level` **não** é wireado até +a UI. A tela (`Modelo.tsx`) mantém os 3 níveis como um bloco informativo, +não um seletor com efeito: cada um descreve o que acontece hoje (`interativo`: +toda ferramenta "ask" pede confirmação, sem teto por tarefa; `automático`: +não implementado; `somente leitura`: use o perfil de agente "plan", que já +nega edits por padrão) com uma nota explícita de que nada disso é aplicado +pelo orquestrador ainda. + +**Por que não a opção (b):** mudar o orquestrador para respeitar um teto por +tarefa é uma mudança arquitetural real no mecanismo de HITL do squad — trocar +ou aumentar `ProgressiveAutonomyManager`'s decisão-por-agente por um teto +externo por tarefa, cross-linguagem (Rust decide o valor, Python decide o +efeito). Isso está fora do escopo de "Modelo & Onboarding" (uma onda de +telas), e o próprio plano-mestre trata essa mudança como uma decisão +separada, não uma consequência tácita de expor o campo na UI. + +**Por que não wireá-lo mesmo assim, "só para viajar":** faria exatamente o +que o texto do plano nomeia como insuficiente — "o campo viajou" sem +nenhum efeito, uma segunda camada de teatro sobre um campo que já era +teatro (hardcoded `3`, nunca lido). Pior que não wireado: pareceria +funcional (o valor chega ao `SquadTask` de verdade) sem jamais mudar uma +decisão de aprovação. + +## Não-escopo explícito + +- Mudança no orquestrador Python (`ProgressiveAutonomyManager`/`hitl.py`) + para aceitar e respeitar um teto de autonomia por tarefa: não implementada + nesta fase. Fica re-declarada como pendência real, mesmo padrão da + pendência de consenso→ledger da Fase 6 e do próprio `max_autonomy_level` + já citado como descope na Fase 7 original. +- `RunSquadBody`/flag de CLI para autonomia: não adicionados — não faria + sentido adicionar o transporte sem o consumidor real do outro lado. +- Os dois hardcodes (`squad.rs`, `squad_agent.rs`) permanecem `3` — agora + com comentário explícito apontando esta ADR, não silenciosos. + +## Consequências + +- `Modelo.tsx` não tem mais um controle de autonomia com efeito fabricado — + o nível de detalhe da tela cai (não há mais um "●" de seleção), mas nada + nela finge um efeito que não existe. +- Se um futuro trabalho decidir implementar (b), o ponto de entrada já + documentado é: `SquadTask.max_autonomy_level` chega intacto ao Python + (proto já carrega o valor); falta só `ExecuteTask` lê-lo e + `ProgressiveAutonomyManager` (ou um mecanismo novo) passar a respeitá-lo, + mais os dois hardcodes virarem parâmetros reais. +- Nenhum ADR anterior precisa mudar — `ProgressiveAutonomyManager` + (ADR 0006/0007) continua sendo o mecanismo de autonomia real do squad. diff --git a/pendencias.md b/pendencias.md index 11de038..b157407 100644 --- a/pendencias.md +++ b/pendencias.md @@ -990,3 +990,108 @@ ADR 0019, sem decisão em aberto que precisasse deste arquivo. providers vêm antes da legenda), não com um seletor mais específico — mesma classe de armadilha já registrada para `getByText('read-only')` na Onda 10. + +## Onda 13 — Modelo & Onboarding + +- **[achado] `model`/`agent` já existiam em `SendMessageBody` + (`web_agent.rs`) desde a Onda 1, mas o frontend nunca os populava** — + `sendMessage(text)` só mandava `{message}`, então o `unwrap_or_else` + sempre caía no default hardcoded (`"claude-sonnet-5"`/`"build"`), + independente do que `Modelo.tsx` mostrasse selecionado. Zero mudança de + backend foi necessária para o piso desta onda — só `SessionContext.tsx`'s + `sendMessage` ganhar um parâmetro opcional `{model, agent}` e + `Sessao.tsx`'s call site passar `{model: primaryModelName(modelTier), + agent: agentProfile}`. **Fronteira provada, não só "o campo viaja":** um + override real (mesmo mecanismo persistido de `RuleStore` que a matriz de + permissão da Onda 2 usa) para `plan`+`bash` = deny faz a mensagem + roteirizada terminar em `tool_denied` sem perguntar quando `agent: "plan"` + é enviado, e em `permission_requested` (ask, default real de `build`, sem + override) quando `agent` nem é mandado — dois caminhos HTTP idênticos + exceto o campo, comportamento observável diferente + (`post_message_respeita_o_agent_do_corpo_via_override_persistido`). +- **[decisão] `primaryModelName(tier)` (já existente, só usado até agora + pra exibir texto no cabeçalho da sessão) virou o valor real enviado no + campo `model`.** Não fabriquei um catálogo de model-id novo — reusei a + string que o usuário já vê na tela (`MODEL_TIERS[tier].models.split(' + · ')[0]`). Ressalva honesta: `small`'s entrada (`"haiku"`) não é + necessariamente um id de modelo resolvível por um provider real (ao + contrário de `"claude-sonnet-5"`, que já aparece como default em 3 outros + lugares do código) — isso é uma imprecisão PRÉ-EXISTENTE em `MODEL_TIERS` + (Onda 7), não introduzida aqui; corrigir o catálogo de modelos é fora do + escopo de "Modelo & Onboarding" (que é sobre telas, não sobre curar a + lista de modelos suportados). +- **[decisão] `selectTier`/`selectAgentProfile`/`selectAutonomy` + (`api/models.ts`) removidos — eram `simulateLatency(150); return + `, uma chamada fake sem efeito.** Tier/agente não são mais + "selecionados" via uma chamada à parte: a escolha em `Modelo.tsx` fica só + no `AppContext` (dispatch local) e é aplicada de verdade só quando a + próxima mensagem é enviada — parâmetro por sessão/tarefa, mirroring do + CLI (`--model`/`--agent` são flags por invocação, não uma preferência + persistida), não um store de preferência novo. Os toasts refletem isso + ("aplica à próxima mensagem enviada"), não mais um "selecionado"/"ativo" + que sugeria um round-trip imediato ao backend. +- **[decisão — ADR 0021] `max_autonomy_level` NÃO foi wireado até a UI — + descope explícito, não esquecimento.** Confirmado nesta onda: o campo é + ignorado ponta-a-ponta hoje (hardcoded `3` em `squad.rs`+`squad_agent.rs`, + nunca lido por `forge_squad/server.py::ExecuteTask`; a autonomia real vem + de `ProgressiveAutonomyManager`/`agent_trust_scores`, `hitl.py`, + desconectado deste campo do proto). A própria fronteira que o plano exige + para "se autonomia entrar" — comportamento observável diferente por + nível, não só o campo viajando — não seria alcançável sem mudar o + orquestrador Python, uma mudança arquitetural fora do escopo de uma onda + de telas. `Modelo.tsx`'s seção de autonomia virou um bloco informativo + (sem botões, sem estado local, sem toast de sucesso fake) com nota + explícita "não aplicado pelo orquestrador ainda". Os dois hardcodes + (`squad.rs`/`squad_agent.rs`) ganharam comentário apontando a ADR, não + ficaram silenciosos. Detalhe completo na ADR 0021. +- **[decisão] `GET /api/doctor` novo (`crates/forge-cli/src/doctor_console.rs`) + agrega 4 checagens: providers (reusa o mesmo `Gateway::from_env() + .available()` + `KNOWN_PROVIDERS` da Onda 12, duplicado — não importado, + mesma convenção de `git_sha`/`now_rfc3339` entre os dois crates), `uv + --version` (novo, com PATH injetável pra teste determinístico — ver + abaixo), ping Docker (reusa `Sandbox::ping()` da Onda 10 sem mudança), e + git (reusa `crate::git_sha()` de `main.rs` DIRETO — mesmo crate que + `doctor_console.rs`, então nenhuma duplicação faz sentido aqui, diferente + do caso cross-crate forge-server/forge-cli). Mora em `forge-cli` (não + `forge-server`) porque a checagem de Docker precisa de + `forge_tools::sandbox` — mesma regra de posicionamento de rota das + demais telas admin desta fase. +- **[decisão] `uv_check_with_path` checa `status.success()`, não só se o + processo subiu — diferente do guard de teste `uv_missing()` (duplicado em + ~7 arquivos `#[cfg(test)]` deste workspace, que só quer saber "existe pra + pular o teste").** Este é o doctor mostrado ao usuário real: um `uv` + presente mas quebrado (exit ≠0) deve aparecer como ausente, não como + presente. PATH é injetável (`Option<&str>`) pro teste simular "uv + ausente" apontando pra um PATH vazio, sem depender do PATH real do + processo de teste — mesmo espírito de `Sandbox::ping_with` receber um + client já configurado em vez de só `ping()`. +- **[decisão] `onboarding.ts`/`Onboarding.tsx` reescritos: `ENV_KEYS`/ + `DOCTOR_OUTPUT` (arrays estáticos, sempre "tudo verde" exceto os + fallbacks marcados de propósito) saem, `fetchDoctor()` real entra.** + Deliberadamente NÃO importei `fetchProviders`/`api/providers.ts` da Onda + 12 pro card "Chaves de API" — a Onda 12 ainda não tinha mergeado quando + esta onda começou (branch aberta direto de `origin/main`, sem + sobreposição de arquivo com `claude/fase-7-onda-12`, de propósito, pra + não empilhar sem necessidade). O card usa o resumo agregado que o próprio + `/api/doctor` já calcula (`"N/3 provider(s) configurado(s)"`) em vez de + duplicar a lógica de `KNOWN_PROVIDERS` uma terceira vez só pra ter uma + lista por-key aqui também — o detalhe por provider individual mora na + tela Providers, não duplicado nesta. +- **[nota] `AutonomyLevel` (`web/src/types/domain.ts`) ficou sem uso** + depois que `Modelo.tsx` trocou pra um union type literal local — deixado + no arquivo de propósito (não removido) pra não tocar `domain.ts`, que a + Onda 12 (ainda não mergeada) também edita por perto (`ProviderInfo`); + remover é cosmético e pode esperar uma onda que já vá mexer nesse + arquivo por outro motivo. +- **[decisão] Fronteira do doctor por Playwright usa 2 gêmeos genuinamente + determinísticos, por motivos opostos — nenhum hardcoded por + conveniência.** `uv`: gêmeo POSITIVO real (o job `web` do CI instala via + `astral-sh/setup-uv@v5`, precondição já existente pro squad e2e — PATH + herdado pelo processo do dashboard tem `uv` de verdade). `git`: gêmeo + NEGATIVO real (o dashboard roda com `cwd` no `workDir` temporário de + `run-integration-server.mjs`, que nunca é um repositório git — `git + rev-parse HEAD` falha de verdade ali). `docker`/`providers` ficam sem + valor afirmado: docker varia por ambiente (mesma cautela da Onda 10); + providers já tem fronteira determinística a nível Rust + (`doctor_agrega_as_4_checagens_com_providers_real`, com isolamento de env + var) — reafirmar no Playwright seria uma segunda cópia do mesmo teste. diff --git a/web/src/api/models.ts b/web/src/api/models.ts index c3b9b63..40c46db 100644 --- a/web/src/api/models.ts +++ b/web/src/api/models.ts @@ -1,5 +1,4 @@ -import { simulateLatency } from './client' -import type { AgentProfile, AutonomyLevel, ModelTier, ModelTierId } from '../types/domain' +import type { ModelTier, ModelTierId } from '../types/domain' export const MODEL_TIERS: ModelTier[] = [ { id: 'small', models: 'haiku · deepseek-chat', label: 'step-discipline' }, @@ -13,26 +12,36 @@ export function primaryModelName(tier: ModelTierId): string { return found ? found.models.split(' · ')[0] : tier } -export const AUTONOMY_LEVELS: { id: AutonomyLevel; label: string; enabled: boolean }[] = [ - { id: 'interativo', label: 'Interativo', enabled: true }, - { id: 'automatico', label: 'Automático (em dev)', enabled: false }, - { id: 'somente_leitura', label: 'Somente leitura', enabled: true }, +/** + * Fase 7 Onda 13: `selectTier`/`selectAgentProfile`/`selectAutonomy` (mocks + * de `simulateLatency` que só devolviam o mesmo valor recebido) foram + * removidos — tier/agente não são mais "selecionados" via uma chamada à + * parte; a escolha feita em `Modelo.tsx` fica só no `AppContext` (parâmetro + * por sessão/tarefa, mirroring do CLI — sem store de preferência novo) e é + * aplicada de verdade quando a próxima mensagem é enviada + * (`SessionContext::sendMessage`'s `model`/`agent`, que já chegam a + * `SendMessageBody` real no Rust). + * + * `AUTONOMY_LEVELS` continua só informativo: `max_autonomy_level` + * (`SquadTask`) é ignorado ponta-a-ponta pelo orquestrador Python hoje + * (`ProgressiveAutonomyManager`/`agent_trust_scores` decide de verdade, + * `hitl.py`) — descope explícito registrado na ADR 0021, não wireable sem + * fabricar um efeito que não existe. + */ +export const AUTONOMY_LEVELS: { id: 'interativo' | 'automatico' | 'somente_leitura'; label: string; detail: string }[] = [ + { + id: 'interativo', + label: 'Interativo', + detail: 'hoje: toda ferramenta "ask" pede confirmação — não há teto por tarefa.', + }, + { + id: 'automatico', + label: 'Automático', + detail: 'não implementado — o orquestrador não aceita um teto de autonomia por tarefa ainda.', + }, + { + id: 'somente_leitura', + label: 'Somente leitura', + detail: 'use o perfil de agente "plan" (acima) — edits já são negados por padrão nesse perfil.', + }, ] - -/** // TODO: backend Fase 5 — persiste seleção de tier no forge-core, propaga ao gateway LLM. */ -export async function selectTier(tier: ModelTierId): Promise { - await simulateLatency(150) - return tier -} - -/** // TODO: backend Fase 5 — muda o agente ativo (build/plan), recalcula matriz de permissões. */ -export async function selectAgentProfile(profile: AgentProfile): Promise { - await simulateLatency(150) - return profile -} - -/** // TODO: backend Fase 6 — liga a nível de autonomia do hitl.py (ProgressiveAutonomyManager). */ -export async function selectAutonomy(level: AutonomyLevel): Promise { - await simulateLatency(150) - return level -} diff --git a/web/src/api/onboarding.ts b/web/src/api/onboarding.ts index 88ea5f4..74bda2c 100644 --- a/web/src/api/onboarding.ts +++ b/web/src/api/onboarding.ts @@ -1,28 +1,29 @@ -import { simulateLatency } from './client' +/** + * Fase 7 Onda 13 (Modelo & Onboarding): `ENV_KEYS`/`DOCTOR_OUTPUT` (arrays + * estáticos, sempre "tudo verde" exceto os fallbacks marcados de propósito) + * saem do mock — `GET /api/doctor` (`forge-cli`, `doctor_console.rs`) agrega + * as 4 checagens reais (providers do gateway, `uv --version`, ping Docker, + * git). A checagem de providers aqui é só o resumo agregado (`N/3 + * configurado(s)`) — nunca um preview de key mascarada (o backend nunca + * expôs isso, "keys só no Rust", e esta onda não muda essa fronteira). O + * detalhe por provider individual mora na tela Providers (Onda 12) — não + * duplicado aqui; esta onda foi deliberadamente aberta sem depender do + * merge da Onda 12 (branches sem sobreposição de arquivo), então não + * importa `api/providers.ts` daquela onda. + */ +import { fetchJson } from './client' -export interface EnvKeyStatus { - name: string - detected: boolean - masked?: string +export interface DoctorCheck { + id: 'providers' | 'uv' | 'docker' | 'git' + ok: boolean + detail: string } -export const ENV_KEYS: EnvKeyStatus[] = [ - { name: 'ANTHROPIC_API_KEY', detected: true, masked: 'sk-ant-••••9f3a' }, - { name: 'DEEPSEEK_API_KEY', detected: false }, - { name: 'OPENAI_API_KEY', detected: false }, -] - -export const DOCTOR_OUTPUT = [ - '✓ ANTHROPIC_API_KEY definida', - '○ DEEPSEEK_API_KEY ausente (fallback)', - '○ OPENAI_API_KEY ausente (fallback)', - '✓ uv encontrado — sidecar Python disponível', - '✓ git repositório detectado', - '✓ ledger não inicializado — será criado na 1ª sessão', -] +export async function fetchDoctor(): Promise { + const view = await fetchJson<{ checks: DoctorCheck[] }>('/api/doctor') + return view.checks +} -/** // TODO: backend Fase 5 — grava no clipboard via IPC do terminal alvo, hoje usa a Clipboard API do navegador. */ export async function copyToClipboard(text: string): Promise { - await simulateLatency(80) if (navigator.clipboard) await navigator.clipboard.writeText(text) } diff --git a/web/src/components/screens/user/Modelo.tsx b/web/src/components/screens/user/Modelo.tsx index 3ce5159..dc5b6c6 100644 --- a/web/src/components/screens/user/Modelo.tsx +++ b/web/src/components/screens/user/Modelo.tsx @@ -1,15 +1,12 @@ -import { useState } from 'react' import { Card } from '../../primitives/Card' import { useToast } from '../../primitives/Toast' import { useAppDispatch, useAppState } from '../../../state/AppContext' -import { AUTONOMY_LEVELS, MODEL_TIERS, selectAgentProfile, selectAutonomy, selectTier } from '../../../api/models' -import type { AutonomyLevel } from '../../../types/domain' +import { AUTONOMY_LEVELS, MODEL_TIERS } from '../../../api/models' export function Modelo() { const toast = useToast() const dispatch = useAppDispatch() const { modelTier: tier, agentProfile: agent } = useAppState() - const [autonomy, setAutonomy] = useState('interativo') return (
@@ -20,7 +17,7 @@ export function Modelo() {
NÍVEL DE AUTONOMIA
+ {/* Fase 7 Onda 13: informativo, não um seletor — `max_autonomy_level` + (SquadTask) é ignorado ponta-a-ponta pelo orquestrador hoje (ADR + 0021); wire-lo até aqui seria fabricar um efeito que não existe. + A autonomia real é decidida por agente via `agent_trust_scores` + (`ProgressiveAutonomyManager`, hitl.py), não por um teto de tarefa. */}
{AUTONOMY_LEVELS.map((lvl) => ( - + {lvl.label} + {lvl.detail} +
))} +

+ não aplicado pelo orquestrador ainda — descope explícito (ADR 0021), não um controle de verdade. +

janela 200k · cache on · compaction ~75% tier-gated diff --git a/web/src/components/screens/user/Onboarding.tsx b/web/src/components/screens/user/Onboarding.tsx index 8b82822..1e4c4fc 100644 --- a/web/src/components/screens/user/Onboarding.tsx +++ b/web/src/components/screens/user/Onboarding.tsx @@ -1,8 +1,11 @@ +import { useEffect } from 'react' import { Card } from '../../primitives/Card' import { Button } from '../../primitives/Button' +import { AsyncStatus } from '../../primitives/AsyncStatus' import { useAppDispatch } from '../../../state/AppContext' import { useToast } from '../../primitives/Toast' -import { copyToClipboard, DOCTOR_OUTPUT, ENV_KEYS } from '../../../api/onboarding' +import { useAsyncAction } from '../../../hooks/useAsyncAction' +import { copyToClipboard, fetchDoctor } from '../../../api/onboarding' function CodeBlock({ code }: { code: string }) { const toast = useToast() @@ -33,6 +36,12 @@ function CodeBlock({ code }: { code: string }) { export function Onboarding() { const dispatch = useAppDispatch() + const doctorState = useAsyncAction(fetchDoctor) + + useEffect(() => { + void doctorState.run() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) return (
@@ -50,17 +59,22 @@ export function Onboarding() { ◑ Chaves de API etapa atual
- {ENV_KEYS.map((k) => ( -
- {k.name} - - {k.detected ? `✓ definida (${k.masked})` : 'ausente · fallback'} - -
- ))} + void doctorState.run()}> + {(checks) => { + const providers = checks.find((c) => c.id === 'providers') + return ( +
+ providers + + {providers?.detail ?? '—'} + +
+ ) + }} +

- 🔑 keys vivem SÓ no processo Rust. + 🔑 keys vivem SÓ no processo Rust — detalhe por provider na tela Providers.

@@ -87,12 +101,18 @@ export function Onboarding() {
-
$ forge init
- {DOCTOR_OUTPUT.map((line) => ( -
- {line} -
- ))} +
$ forge doctor
+ void doctorState.run()}> + {(checks) => ( + <> + {checks.map((c) => ( +
+ {c.ok ? '✓' : '○'} {c.detail} +
+ ))} + + )} +
diff --git a/web/src/components/screens/user/Sessao.tsx b/web/src/components/screens/user/Sessao.tsx index 4153968..81f9e52 100644 --- a/web/src/components/screens/user/Sessao.tsx +++ b/web/src/components/screens/user/Sessao.tsx @@ -64,7 +64,7 @@ export function Sessao() { const text = input setInput('') try { - await sendMessage(text) + await sendMessage(text, { model: primaryModelName(modelTier), agent: agentProfile }) } catch { toast.push('error', 'falha ao enviar mensagem') } diff --git a/web/src/state/SessionContext.tsx b/web/src/state/SessionContext.tsx index e8a796e..edf099d 100644 --- a/web/src/state/SessionContext.tsx +++ b/web/src/state/SessionContext.tsx @@ -26,7 +26,14 @@ interface SessionContextValue { lastError: string | null /** Contagem real do último `Session::verify()` — `null` antes do 1º turno concluir. */ ledgerVerified: number | null - sendMessage: (message: string) => Promise + /** + * `opts.model`/`opts.agent` (Fase 7 Onda 13) vão direto no corpo de + * `POST .../message` — os mesmos campos que `SendMessageBody` (Rust) já + * aceita desde a Onda 1, antes nunca populados pelo cliente. Parâmetro + * por chamada, não estado persistido (mirroring do CLI: `--model`/ + * `--agent` são flags por invocação, não uma preferência salva). + */ + sendMessage: (message: string, opts?: { model?: string; agent?: string }) => Promise resolvePermission: (allow: boolean) => Promise } @@ -107,7 +114,7 @@ export function SessionProvider({ children }: { children: ReactNode }) { }, [sessionId]) const sendMessage = useCallback( - async (message: string) => { + async (message: string, opts?: { model?: string; agent?: string }) => { setLastError(null) setBusy(true) setTranscript((prev) => [...prev, { id: nextTurnId('u'), kind: 'user', text: message }]) @@ -115,7 +122,7 @@ export function SessionProvider({ children }: { children: ReactNode }) { await fetchJson(`/api/session/${encodeURIComponent(sessionId)}/message`, { method: 'POST', headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ message }), + body: JSON.stringify({ message, ...opts }), }) } catch (e) { setBusy(false) diff --git a/web/tests/e2e-integration/doctor-real-backend.spec.ts b/web/tests/e2e-integration/doctor-real-backend.spec.ts new file mode 100644 index 0000000..04b964f --- /dev/null +++ b/web/tests/e2e-integration/doctor-real-backend.spec.ts @@ -0,0 +1,54 @@ +import { test, expect } from '@playwright/test' + +/** Fase 7 Onda 13 (Modelo & Onboarding): prova a fronteira do doctor por + * EXECUÇÃO. `GET /api/doctor` (`forge-cli`, `doctor_console.rs`) agrega 4 + * checagens reais — a tela deixa de mostrar `DOCTOR_OUTPUT` fabricado + * (sempre "tudo verde"). + * + * `uv`/`git` são as duas checagens com valor determinístico NESTE fixture, + * por motivos opostos e ambos genuínos (não hardcoded pra conveniência do + * teste): `uv` está de verdade no PATH herdado pelo processo do dashboard + * (o job `web` do CI instala via `astral-sh/setup-uv@v5`, precondição já + * usada pelo squad e2e) — gêmeo POSITIVO. `git`, ao contrário, é um gêmeo + * NEGATIVO real: o dashboard roda com `cwd` num diretório temporário + * (`run-integration-server.mjs`'s `workDir`), que nunca é um repositório + * git — `git rev-parse HEAD` falha de verdade ali, então o doctor deve + * mostrar "ausente", não "tudo verde" por padrão. `docker`/`providers` não + * têm valor fixo afirmado aqui: `docker` varia por ambiente (mesma cautela + * já usada em `sandbox-real-backend.spec.ts`); a fronteira determinística + * de `providers` já está provada a nível Rust + * (`doctor_agrega_as_4_checagens_com_providers_real`, com isolamento de env + * var) — reafirmar aqui seria uma segunda cópia do mesmo teste. + */ +test('tela de onboarding mostra o doctor real — uv presente, git ausente (workDir não é repo)', async ({ page }) => { + await page.goto('/') + await page.getByRole('button', { name: 'Primeiros passos' }).click() + await expect(page.getByRole('heading', { name: 'Primeiros passos' })).toBeVisible() + + const terminal = page.locator('.mono', { hasText: 'forge doctor' }) + await expect(terminal).toBeVisible({ timeout: 10_000 }) + + await expect(terminal.getByText(/uv encontrado/)).toBeVisible() + await expect(terminal.getByText(/git ausente/)).toBeVisible() + + // 4 checagens reais, nunca a lista fixa antiga (sempre 6 linhas "✓"/"○"). + const providersCard = page.getByText('providers', { exact: true }) + await expect(providersCard).toBeVisible() +}) + +test('backend do doctor fora do ar mostra erro explícito, não o array mock antigo', async ({ page }) => { + await page.route('**/api/doctor', (route) => + route.fulfill({ status: 500, contentType: 'application/json', body: '{"error":"boom","code":"forced_failure"}' }), + ) + await page.goto('/') + await page.getByRole('button', { name: 'Primeiros passos' }).click() + await expect(page.getByRole('heading', { name: 'Primeiros passos' })).toBeVisible() + + // A tela tem 2 pontos de leitura do MESMO `doctorState` (card de chaves + + // terminal) — os dois mostram o erro de forma independente, daí `.first()` + // em vez de exigir exatamente 1 match. + await expect(page.getByText('boom').first()).toBeVisible() + await expect(page.getByRole('button', { name: 'tentar de novo' }).first()).toBeVisible() + // O mock antigo tinha essa linha fixa — não pode sobreviver por trás do erro. + await expect(page.getByText('ledger não inicializado')).toHaveCount(0) +})