use std::sync::{
Arc,
atomic::{AtomicU64, Ordering},
};
use axum::{Json, extract::State, http::StatusCode, response::IntoResponse};
use serde::{Deserialize, Serialize};
use tracing::{debug, info};
use crate::{
config::ReviewConfig,
integrations::{analyze_client::AnalyzeClient, github::RunMode, search_client::SearchClient},
llm::LlmProvider,
pipeline::{DiffSource, ReviewDeps, ReviewInput, TriggerDecision, run_review},
service::inference_probe::{InferenceProbe, InferenceStatus},
store::{DedupStore, InFlightRegistry},
};
#[derive(Clone)]
pub struct AppState {
pub config: ReviewConfig,
pub llm: Arc<dyn LlmProvider>,
pub verifier: Option<Arc<dyn LlmProvider>>,
pub search: Arc<dyn SearchClient>,
pub analyze: Option<Arc<dyn AnalyzeClient>>,
pub in_flight: Arc<AtomicU64>,
pub last_error: Arc<std::sync::Mutex<Option<String>>>,
pub dedup: Option<Arc<DedupStore>>,
pub in_flight_registry: InFlightRegistry,
pub inference_probe: InferenceProbe,
}
impl AppState {
pub fn new(
config: ReviewConfig,
llm: Arc<dyn LlmProvider>,
search: Arc<dyn SearchClient>,
analyze: Option<Arc<dyn AnalyzeClient>>,
) -> Self {
Self::with_dedup(config, llm, search, analyze, None)
}
pub fn with_dedup(
config: ReviewConfig,
llm: Arc<dyn LlmProvider>,
search: Arc<dyn SearchClient>,
analyze: Option<Arc<dyn AnalyzeClient>>,
dedup: Option<Arc<DedupStore>>,
) -> Self {
Self::with_verifier_and_dedup(config, llm, None, search, analyze, dedup)
}
pub fn with_verifier_and_dedup(
config: ReviewConfig,
llm: Arc<dyn LlmProvider>,
verifier: Option<Arc<dyn LlmProvider>>,
search: Arc<dyn SearchClient>,
analyze: Option<Arc<dyn AnalyzeClient>>,
dedup: Option<Arc<DedupStore>>,
) -> Self {
Self {
config,
llm,
verifier,
search,
analyze,
in_flight: Arc::new(AtomicU64::new(0)),
last_error: Arc::new(std::sync::Mutex::new(None)),
dedup,
in_flight_registry: InFlightRegistry::new(),
inference_probe: InferenceProbe::default(),
}
}
}
#[derive(Debug, Serialize)]
pub struct HealthResponse {
pub status: &'static str,
pub version: &'static str,
pub dry_run: bool,
pub reviewer_model: String,
pub inference: InferenceStatus,
pub deps: DepStatus,
}
#[derive(Debug, Serialize)]
pub struct DepStatus {
pub trusty_search: DepInfo,
pub trusty_analyze: DepInfo,
}
#[derive(Debug, Serialize)]
pub struct DepInfo {
pub required: bool,
pub reachable: bool,
}
#[derive(Debug, Serialize)]
pub struct StatusResponse {
pub in_flight: u64,
pub last_error: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct ReviewRequest {
pub owner: Option<String>,
pub repo: Option<String>,
pub pr: Option<u64>,
pub local_diff_text: Option<String>,
}
pub fn compute_status(inference: InferenceStatus, deps: &DepStatus) -> &'static str {
let required_deps_ok = deps.trusty_search.reachable || !deps.trusty_search.required;
let inference_ok = inference.is_ok() || inference == InferenceStatus::Unknown;
if inference_ok && required_deps_ok {
"ok"
} else {
"degraded"
}
}
pub async fn handle_health(State(state): State<AppState>) -> impl IntoResponse {
let search_reachable = state.search.health().await.is_ok_and(|r| r.is_healthy());
let analyze_reachable = match &state.analyze {
Some(a) => a.health().await.is_ok(),
None => false,
};
let reviewer_model = state.config.role_models.reviewer.model.clone();
let inference = state
.inference_probe
.probe(&state.llm, &reviewer_model)
.await;
let deps = DepStatus {
trusty_search: DepInfo {
required: true,
reachable: search_reachable,
},
trusty_analyze: DepInfo {
required: false,
reachable: analyze_reachable,
},
};
let status = compute_status(inference, &deps);
let body = HealthResponse {
status,
version: env!("CARGO_PKG_VERSION"),
dry_run: state.config.dry_run,
reviewer_model,
inference,
deps,
};
(StatusCode::OK, Json(body))
}
pub async fn handle_status(State(state): State<AppState>) -> impl IntoResponse {
let in_flight = state.in_flight.load(Ordering::Relaxed);
let last_error = state
.last_error
.lock()
.unwrap_or_else(|p| p.into_inner())
.clone();
(
StatusCode::OK,
Json(StatusResponse {
in_flight,
last_error,
}),
)
}
pub async fn handle_review(
State(state): State<AppState>,
Json(req): Json<ReviewRequest>,
) -> impl IntoResponse {
debug!("POST /review received");
let diff_source = match resolve_diff_source(&req) {
Ok(s) => s,
Err(msg) => {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({ "error": msg })),
)
.into_response();
}
};
let reviewer_model = state.config.role_models.reviewer.model.clone();
let deps = ReviewDeps {
llm: Arc::clone(&state.llm),
verifier: state.verifier.clone(),
search: Arc::clone(&state.search),
analyze: state.analyze.clone(),
dedup: None,
};
let input = ReviewInput {
diff_source,
reviewer_model,
write_log: false, print_result: false,
trigger: TriggerDecision::ForceDryRun,
run_mode: RunMode::Serve,
allow_posting: false,
};
state.in_flight.fetch_add(1, Ordering::Relaxed);
let result = run_review(&state.config, input, deps).await;
state.in_flight.fetch_sub(1, Ordering::Relaxed);
info!(
verdict = %result.verdict,
findings = result.findings.len(),
model = %result.model,
"POST /review complete"
);
(StatusCode::OK, Json(result)).into_response()
}
fn resolve_diff_source(req: &ReviewRequest) -> Result<DiffSource, String> {
if let Some(ref diff_text) = req.local_diff_text {
use std::io::Write as _;
let mut tmp = tempfile::NamedTempFile::new().map_err(|e| format!("tempfile error: {e}"))?;
tmp.write_all(diff_text.as_bytes())
.map_err(|e| format!("tempfile write error: {e}"))?;
let path = tmp
.into_temp_path()
.keep()
.map_err(|e| format!("keep tempfile: {e}"))?;
return Ok(DiffSource::LocalFile {
path: path.to_path_buf(),
});
}
let owner = req
.owner
.as_deref()
.ok_or_else(|| "owner is required (or provide local_diff_text)".to_string())?
.to_string();
let repo = req
.repo
.as_deref()
.ok_or_else(|| "repo is required (or provide local_diff_text)".to_string())?
.to_string();
let pr = req
.pr
.ok_or_else(|| "pr is required (or provide local_diff_text)".to_string())?;
Ok(DiffSource::Github {
owner,
repo,
pr,
token: String::new(),
})
}
#[cfg(test)]
#[path = "handlers_tests.rs"]
mod tests;
#[cfg(test)]
#[path = "handlers_status_tests.rs"]
mod status_tests;