Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
194 changes: 194 additions & 0 deletions crates/forge-cli/src/doctor_console.rs
Original file line number Diff line number Diff line change
@@ -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<DoctorCheck>,
}

/// 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<String> = 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());
}
}
}
5 changes: 4 additions & 1 deletion crates/forge-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
8 changes: 8 additions & 0 deletions crates/forge-cli/src/squad.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()),
Expand Down
4 changes: 4 additions & 0 deletions crates/forge-cli/src/squad_agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
117 changes: 117 additions & 0 deletions crates/forge-cli/src/web_agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
Loading
Loading