1use std::collections::HashMap;
4use std::ffi::OsString;
5use std::fmt;
6use std::net::{IpAddr, Ipv4Addr, SocketAddr};
7use std::path::PathBuf;
8use std::sync::Arc;
9use std::time::{Duration, Instant};
10
11use axum::Router;
12use axum::extract::rejection::JsonRejection;
13use axum::extract::{ConnectInfo, DefaultBodyLimit, Json, Request, State};
14use axum::http::header::{
15 AUTHORIZATION, CACHE_CONTROL, CONTENT_LENGTH, CONTENT_SECURITY_POLICY, HeaderName, HeaderValue, REFERRER_POLICY, WWW_AUTHENTICATE, X_CONTENT_TYPE_OPTIONS, X_FRAME_OPTIONS
16};
17use axum::http::{Method, StatusCode};
18use axum::middleware::{self, Next};
19use axum::response::{IntoResponse, Response};
20use axum::routing::{get, post};
21use code_system_graph_core::{
22 ChangeAnalysisOptions, ImpactReport, ImpactRequest, LocalContextResult, SearchReport
23};
24use code_system_graph_model::{
25 FreshnessSummary, NodeKind, OverallFreshness, RepoId, ToolEnvelope, ToolStatus
26};
27use schemars::JsonSchema;
28use serde::{Deserialize, Serialize};
29use subtle::ConstantTimeEq;
30use thiserror::Error;
31use tokio::net::TcpListener;
32use tokio::sync::{Mutex, Semaphore};
33use tokio_util::sync::CancellationToken;
34use tower_http::sensitive_headers::SetSensitiveRequestHeadersLayer;
35
36use crate::{
37 ApplicationError, CODEGRAPH_DISABLED_CODE, CODEGRAPH_DISABLED_MESSAGE, ChangesInput, CommunityInput, CommunityReport, ExploreInput, SearchInput, TraceInput, analyze_workspace_changes, communities_workspace, explore_repository, impact_workspace, impact_workspace_with_codegraph, search_workspace, status_workspace, trace_workspace
38};
39
40pub const DEFAULT_HTTP_BIND: SocketAddr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 4767);
42
43const BODY_LIMIT_BYTES: usize = 1024 * 1024;
44const BODY_LIMIT_BYTES_U64: u64 = 1024 * 1024;
45const CONCURRENCY_LIMIT: usize = 32;
46const REQUEST_TIMEOUT: Duration = Duration::from_secs(15);
47const RATE_LIMIT_REQUESTS: u32 = 60;
48const RATE_LIMIT_WINDOW: Duration = Duration::from_mins(1);
49const MAX_RATE_LIMIT_CLIENTS: usize = 4096;
50const MAX_TOKEN_BYTES: usize = 4096;
51const SCHEMA_VERSION: u32 = 1;
52
53const VERSION_HEADER: HeaderName = HeaderName::from_static("x-code-system-graph-version");
54const SCHEMA_HEADER: HeaderName = HeaderName::from_static("x-code-system-graph-schema-version");
55const PERMISSIONS_POLICY: HeaderName = HeaderName::from_static("permissions-policy");
56
57pub struct BearerToken {
59 bytes: Box<[u8; MAX_TOKEN_BYTES]>,
60 len: usize,
61}
62
63impl BearerToken {
64 pub fn new(value: impl AsRef<str>) -> Result<Self, BearerTokenError> {
71 let value = value.as_ref();
72 if value.trim().is_empty() {
73 return Err(BearerTokenError::Invalid);
74 }
75 let source = value.as_bytes();
76 if source.len() > MAX_TOKEN_BYTES {
77 return Err(BearerTokenError::Invalid);
78 }
79 let mut bytes = Box::new([0_u8; MAX_TOKEN_BYTES]);
80 bytes[..source.len()].copy_from_slice(source);
81 Ok(Self {
82 bytes,
83 len: source.len(),
84 })
85 }
86
87 fn matches(&self, candidate: &[u8]) -> bool {
88 let mut padded = [0_u8; MAX_TOKEN_BYTES];
89 let candidate_fits = candidate.len() <= MAX_TOKEN_BYTES;
90 if candidate_fits {
91 padded[..candidate.len()].copy_from_slice(candidate);
92 }
93 let content_matches = self.bytes.as_ref().ct_eq(&padded);
94 let length_matches = self.len.ct_eq(&candidate.len());
95 bool::from(content_matches & length_matches) && candidate_fits
96 }
97}
98
99impl Clone for BearerToken {
100 fn clone(&self) -> Self {
101 Self {
102 bytes: self.bytes.clone(),
103 len: self.len,
104 }
105 }
106}
107
108impl fmt::Debug for BearerToken {
109 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
110 formatter.write_str("BearerToken([REDACTED])")
111 }
112}
113
114impl Drop for BearerToken {
115 fn drop(&mut self) {
116 self.bytes.fill(0);
117 self.len = 0;
118 }
119}
120
121#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
123pub enum BearerTokenError {
124 #[error("bearer token must be non-empty and within the supported size limit")]
126 Invalid,
127}
128
129#[derive(Debug, Clone)]
131pub struct HttpServerConfig {
132 pub bind: SocketAddr,
134 pub config_path: PathBuf,
136 pub database_path: PathBuf,
138 pub workspace: String,
140 pub bearer_token: Option<BearerToken>,
142 pub codegraph_enabled: bool,
144 pub codegraph_binary: Option<OsString>,
146}
147
148impl HttpServerConfig {
149 #[must_use]
151 pub fn new(
152 config_path: impl Into<PathBuf>,
153 database_path: impl Into<PathBuf>,
154 workspace: impl Into<String>,
155 ) -> Self {
156 Self {
157 bind: DEFAULT_HTTP_BIND,
158 config_path: config_path.into(),
159 database_path: database_path.into(),
160 workspace: workspace.into(),
161 bearer_token: None,
162 codegraph_enabled: false,
163 codegraph_binary: None,
164 }
165 }
166
167 #[must_use]
169 pub const fn with_bind(mut self, bind: SocketAddr) -> Self {
170 self.bind = bind;
171 self
172 }
173
174 #[must_use]
176 pub fn with_bearer_token(mut self, token: BearerToken) -> Self {
177 self.bearer_token = Some(token);
178 self
179 }
180
181 #[must_use]
183 pub fn with_codegraph(mut self, enabled: bool, binary: Option<OsString>) -> Self {
184 self.codegraph_enabled = enabled;
185 self.codegraph_binary = binary;
186 self
187 }
188
189 fn validate_for(&self, bind: SocketAddr) -> Result<(), HttpServerError> {
190 if self.workspace.trim().is_empty() {
191 return Err(HttpServerError::InvalidConfiguration(
192 "HTTP workspace must be non-empty",
193 ));
194 }
195 if !bind.ip().is_loopback() && self.bearer_token.is_none() {
196 return Err(HttpServerError::InvalidConfiguration(
197 "non-loopback HTTP bind requires a bearer token",
198 ));
199 }
200 Ok(())
201 }
202}
203
204#[derive(Debug, Error)]
206pub enum HttpServerError {
207 #[error("{0}")]
209 InvalidConfiguration(&'static str),
210 #[error("failed to bind HTTP server")]
212 Bind(#[source] std::io::Error),
213 #[error("HTTP server failed")]
215 Serve(#[source] std::io::Error),
216 #[error("failed to inspect HTTP listener")]
218 ListenerAddress(#[source] std::io::Error),
219}
220
221#[derive(Debug, Clone, Copy, Default, Deserialize, JsonSchema)]
223#[serde(deny_unknown_fields)]
224pub struct StatusInput {}
225
226#[derive(Debug, Clone, PartialEq, Eq, Deserialize, JsonSchema)]
228#[serde(deny_unknown_fields)]
229pub struct ContractsInput {
230 #[serde(default)]
232 pub query: String,
233 #[serde(default)]
235 pub repo_ids: Vec<RepoId>,
236 #[serde(default)]
238 pub offset: usize,
239 #[serde(default = "default_contract_limit")]
241 pub limit: usize,
242}
243
244const fn default_contract_limit() -> usize {
245 20
246}
247
248#[derive(Clone)]
249struct HttpState {
250 config: Arc<HttpServerConfig>,
251 limiter: Arc<RateLimiter>,
252 concurrency: Arc<Semaphore>,
253}
254
255#[derive(Debug)]
256struct RateWindow {
257 started: Instant,
258 requests: u32,
259}
260
261#[derive(Debug, Default)]
262struct RateLimiter {
263 clients: Mutex<HashMap<IpAddr, RateWindow>>,
264}
265
266impl RateLimiter {
267 async fn check(&self, client: IpAddr) -> bool {
268 let now = Instant::now();
269 let mut clients = self.clients.lock().await;
270 clients.retain(|_, window| now.duration_since(window.started) < RATE_LIMIT_WINDOW);
271
272 if let Some(window) = clients.get_mut(&client) {
273 if window.requests >= RATE_LIMIT_REQUESTS {
274 return false;
275 }
276 window.requests += 1;
277 return true;
278 }
279
280 if clients.len() >= MAX_RATE_LIMIT_CLIENTS
281 && let Some(oldest) = clients
282 .iter()
283 .min_by_key(|(_, window)| window.started)
284 .map(|(address, _)| *address)
285 {
286 clients.remove(&oldest);
287 }
288 clients.insert(
289 client,
290 RateWindow {
291 started: now,
292 requests: 1,
293 },
294 );
295 true
296 }
297}
298
299#[derive(Debug, Serialize)]
300struct HealthReport {
301 healthy: bool,
302 version: &'static str,
303}
304
305#[derive(Debug, Serialize)]
306struct ErrorReport {
307 code: &'static str,
308 message: &'static str,
309}
310
311pub fn create_router(config: HttpServerConfig) -> Result<Router, HttpServerError> {
321 config.validate_for(config.bind)?;
322 let state = HttpState {
323 config: Arc::new(config),
324 limiter: Arc::new(RateLimiter::default()),
325 concurrency: Arc::new(Semaphore::new(CONCURRENCY_LIMIT)),
326 };
327
328 Ok(Router::new()
329 .route("/health", get(health))
330 .route("/v1/status", get(system_status))
331 .route("/v1/tools/status", post(status))
332 .route("/v1/tools/query", post(query))
333 .route("/v1/tools/trace", post(trace))
334 .route("/v1/tools/explore", post(explore))
335 .route("/v1/tools/impact", post(impact))
336 .route("/v1/tools/analyze_changes", post(analyze_changes))
337 .route("/v1/tools/contracts", post(contracts))
338 .route("/v1/tools/communities", post(communities))
339 .fallback(unsupported_route)
340 .method_not_allowed_fallback(method_not_allowed)
341 .layer(DefaultBodyLimit::max(BODY_LIMIT_BYTES))
342 .layer(SetSensitiveRequestHeadersLayer::new(std::iter::once(
343 AUTHORIZATION,
344 )))
345 .layer(middleware::from_fn_with_state(state.clone(), request_guard))
346 .with_state(state))
347}
348
349pub async fn serve_http(
355 config: HttpServerConfig,
356 cancellation: CancellationToken,
357) -> Result<(), HttpServerError> {
358 config.validate_for(config.bind)?;
359 let listener = TcpListener::bind(config.bind)
360 .await
361 .map_err(HttpServerError::Bind)?;
362 serve_http_on_listener(listener, config, cancellation).await
363}
364
365pub async fn serve_http_on_listener(
375 listener: TcpListener,
376 mut config: HttpServerConfig,
377 cancellation: CancellationToken,
378) -> Result<(), HttpServerError> {
379 let address = listener
380 .local_addr()
381 .map_err(HttpServerError::ListenerAddress)?;
382 config.validate_for(address)?;
383 config.bind = address;
384 let router = create_router(config)?;
385 axum::serve(
386 listener,
387 router.into_make_service_with_connect_info::<SocketAddr>(),
388 )
389 .with_graceful_shutdown(cancellation.cancelled_owned())
390 .await
391 .map_err(HttpServerError::Serve)
392}
393
394async fn request_guard(State(state): State<HttpState>, request: Request, next: Next) -> Response {
395 if request
396 .headers()
397 .get(CONTENT_LENGTH)
398 .and_then(|value| value.to_str().ok())
399 .and_then(|value| value.parse::<u64>().ok())
400 .is_some_and(|length| length > BODY_LIMIT_BYTES_U64)
401 {
402 return secure_response(error_response(
403 StatusCode::PAYLOAD_TOO_LARGE,
404 "payload_too_large",
405 "The request body exceeds the 1 MiB limit.",
406 ));
407 }
408
409 let client = request
410 .extensions()
411 .get::<ConnectInfo<SocketAddr>>()
412 .map_or(IpAddr::V4(Ipv4Addr::LOCALHOST), |address| address.ip());
413 if !state.limiter.check(client).await {
414 let mut response = error_response(
415 StatusCode::TOO_MANY_REQUESTS,
416 "rate_limited",
417 "The per-client request limit has been exceeded.",
418 );
419 response
420 .headers_mut()
421 .insert("retry-after", HeaderValue::from_static("60"));
422 return secure_response(response);
423 }
424
425 if !authorized(&state.config, &request) {
426 let mut response = error_response(
427 StatusCode::UNAUTHORIZED,
428 "unauthorized",
429 "A valid bearer token is required.",
430 );
431 response
432 .headers_mut()
433 .insert(WWW_AUTHENTICATE, HeaderValue::from_static("Bearer"));
434 return secure_response(response);
435 }
436
437 let Ok(_permit) = state.concurrency.clone().try_acquire_owned() else {
438 return secure_response(error_response(
439 StatusCode::SERVICE_UNAVAILABLE,
440 "busy",
441 "The server concurrency limit has been reached.",
442 ));
443 };
444
445 match tokio::time::timeout(REQUEST_TIMEOUT, next.run(request)).await {
446 Ok(response) => secure_response(response),
447 Err(_) => secure_response(error_response(
448 StatusCode::GATEWAY_TIMEOUT,
449 "timeout",
450 "The request exceeded the server time limit.",
451 )),
452 }
453}
454
455fn authorized(config: &HttpServerConfig, request: &Request) -> bool {
456 let Some(expected) = config.bearer_token.as_ref() else {
457 return true;
458 };
459 request
460 .headers()
461 .get(AUTHORIZATION)
462 .and_then(|header| header.as_bytes().strip_prefix(b"Bearer "))
463 .is_some_and(|candidate| expected.matches(candidate))
464}
465
466fn secure_response(mut response: Response) -> Response {
467 let headers = response.headers_mut();
468 headers.insert(
469 VERSION_HEADER,
470 HeaderValue::from_static(env!("CARGO_PKG_VERSION")),
471 );
472 headers.insert(SCHEMA_HEADER, HeaderValue::from_static("1"));
473 headers.insert(X_CONTENT_TYPE_OPTIONS, HeaderValue::from_static("nosniff"));
474 headers.insert(X_FRAME_OPTIONS, HeaderValue::from_static("DENY"));
475 headers.insert(REFERRER_POLICY, HeaderValue::from_static("no-referrer"));
476 headers.insert(CACHE_CONTROL, HeaderValue::from_static("no-store"));
477 headers.insert(
478 CONTENT_SECURITY_POLICY,
479 HeaderValue::from_static("default-src 'none'; frame-ancestors 'none'"),
480 );
481 headers.insert(
482 PERMISSIONS_POLICY,
483 HeaderValue::from_static(
484 "accelerometer=(), camera=(), geolocation=(), microphone=(), payment=(), usb=()",
485 ),
486 );
487 response
488}
489
490async fn health() -> Response {
491 success_response(ToolEnvelope {
492 schema_version: SCHEMA_VERSION,
493 status: ToolStatus::Ok,
494 data: Some(HealthReport {
495 healthy: true,
496 version: env!("CARGO_PKG_VERSION"),
497 }),
498 freshness: unknown_freshness(),
499 warnings: Vec::new(),
500 })
501}
502
503async fn system_status(State(state): State<HttpState>) -> Response {
504 status_response(state).await
505}
506
507async fn status(
508 State(state): State<HttpState>,
509 payload: Result<Json<StatusInput>, JsonRejection>,
510) -> Response {
511 if let Err(rejection) = payload {
512 return json_rejection_response(&rejection);
513 }
514 status_response(state).await
515}
516
517async fn status_response(state: HttpState) -> Response {
518 let config = Arc::clone(&state.config);
519 run_blocking(move || status_workspace(&config.config_path, &config.database_path))
520 .await
521 .map_or_else(tool_failure_response, |status| {
522 if status.workspace != state.config.workspace {
523 return error_response(
524 StatusCode::UNPROCESSABLE_ENTITY,
525 "workspace_mismatch",
526 "The configured manifest does not match the fixed HTTP workspace.",
527 );
528 }
529 let freshness = status.freshness.clone();
530 success_response(ToolEnvelope {
531 schema_version: SCHEMA_VERSION,
532 status: tool_status_from_freshness(&freshness),
533 data: Some(status),
534 freshness,
535 warnings: Vec::new(),
536 })
537 })
538}
539
540async fn query(
541 State(state): State<HttpState>,
542 payload: Result<Json<SearchInput>, JsonRejection>,
543) -> Response {
544 let Json(input) = match payload {
545 Ok(input) => input,
546 Err(rejection) => return json_rejection_response(&rejection),
547 };
548 let config = Arc::clone(&state.config);
549 tool_service_response(
550 run_blocking(move || search_workspace(&config.database_path, &config.workspace, &input))
551 .await,
552 )
553}
554
555async fn trace(
556 State(state): State<HttpState>,
557 payload: Result<Json<TraceInput>, JsonRejection>,
558) -> Response {
559 let Json(input) = match payload {
560 Ok(input) => input,
561 Err(rejection) => return json_rejection_response(&rejection),
562 };
563 let config = Arc::clone(&state.config);
564 tool_service_response(
565 run_blocking(move || trace_workspace(&config.database_path, &config.workspace, &input))
566 .await,
567 )
568}
569
570async fn explore(
571 State(state): State<HttpState>,
572 payload: Result<Json<ExploreInput>, JsonRejection>,
573) -> Response {
574 if !state.config.codegraph_enabled {
575 return error_response(
576 StatusCode::FORBIDDEN,
577 CODEGRAPH_DISABLED_CODE,
578 CODEGRAPH_DISABLED_MESSAGE,
579 );
580 }
581 let Json(input) = match payload {
582 Ok(input) => input,
583 Err(rejection) => return json_rejection_response(&rejection),
584 };
585 let envelope: ToolEnvelope<LocalContextResult> = explore_repository(
586 &state.config.database_path,
587 &state.config.workspace,
588 &input,
589 state.config.codegraph_binary.clone(),
590 )
591 .await;
592 success_response(envelope)
593}
594
595async fn impact(
596 State(state): State<HttpState>,
597 payload: Result<Json<ImpactRequest>, JsonRejection>,
598) -> Response {
599 let Json(input) = match payload {
600 Ok(input) => input,
601 Err(rejection) => return json_rejection_response(&rejection),
602 };
603 let config = Arc::clone(&state.config);
604 let result: Result<ToolEnvelope<ImpactReport>, ToolFailure> = if config.codegraph_enabled {
605 impact_workspace_with_codegraph(
606 &config.database_path,
607 &config.workspace,
608 &input,
609 config.codegraph_binary.clone(),
610 )
611 .await
612 .map_err(|_| ToolFailure::Application)
613 } else {
614 run_blocking(move || impact_workspace(&config.database_path, &config.workspace, &input))
615 .await
616 };
617 tool_service_response(result)
618}
619
620async fn analyze_changes(
621 State(state): State<HttpState>,
622 payload: Result<Json<ChangesInput>, JsonRejection>,
623) -> Response {
624 let Json(input) = match payload {
625 Ok(input) => input,
626 Err(rejection) => return json_rejection_response(&rejection),
627 };
628 tool_service_response(
629 analyze_workspace_changes(
630 &state.config.database_path,
631 &state.config.workspace,
632 &input,
633 &ChangeAnalysisOptions::default(),
634 None,
635 )
636 .await
637 .map_err(|_| ToolFailure::Application),
638 )
639}
640
641async fn contracts(
642 State(state): State<HttpState>,
643 payload: Result<Json<ContractsInput>, JsonRejection>,
644) -> Response {
645 let Json(input) = match payload {
646 Ok(input) => input,
647 Err(rejection) => return json_rejection_response(&rejection),
648 };
649 let search = SearchInput {
650 query: input.query,
651 node_kinds: vec![
652 NodeKind::HttpOperation,
653 NodeKind::GraphqlOperation,
654 NodeKind::RpcMethod,
655 NodeKind::EventChannel,
656 NodeKind::EventSchema,
657 ],
658 repo_ids: input.repo_ids,
659 service_ids: Vec::new(),
660 community_ids: Vec::new(),
661 offset: input.offset,
662 limit: input.limit,
663 };
664 let config = Arc::clone(&state.config);
665 let result: Result<ToolEnvelope<SearchReport>, ToolFailure> =
666 run_blocking(move || search_workspace(&config.database_path, &config.workspace, &search))
667 .await;
668 tool_service_response(result)
669}
670
671async fn communities(
672 State(state): State<HttpState>,
673 payload: Result<Json<CommunityInput>, JsonRejection>,
674) -> Response {
675 let Json(input) = match payload {
676 Ok(input) => input,
677 Err(rejection) => return json_rejection_response(&rejection),
678 };
679 let config = Arc::clone(&state.config);
680 let result: Result<ToolEnvelope<CommunityReport>, ToolFailure> = run_blocking(move || {
681 communities_workspace(&config.database_path, &config.workspace, &input)
682 })
683 .await;
684 tool_service_response(result)
685}
686
687async fn unsupported_route(method: Method) -> Response {
688 let message = if matches!(
689 method,
690 Method::POST | Method::PUT | Method::PATCH | Method::DELETE
691 ) {
692 "Mutating and administrative HTTP routes are disabled."
693 } else {
694 "The requested HTTP route is not supported."
695 };
696 error_response(StatusCode::NOT_FOUND, "not_found", message)
697}
698
699async fn method_not_allowed() -> Response {
700 error_response(
701 StatusCode::METHOD_NOT_ALLOWED,
702 "method_not_allowed",
703 "The HTTP method is not supported for this read-only route.",
704 )
705}
706
707#[derive(Debug, Clone, Copy)]
708enum ToolFailure {
709 Application,
710 Worker,
711}
712
713async fn run_blocking<T, F>(operation: F) -> Result<T, ToolFailure>
714where
715 T: Send + 'static,
716 F: FnOnce() -> Result<T, ApplicationError> + Send + 'static,
717{
718 tokio::task::spawn_blocking(operation)
719 .await
720 .map_err(|_| ToolFailure::Worker)?
721 .map_err(|_| ToolFailure::Application)
722}
723
724fn tool_service_response<T: Serialize>(result: Result<ToolEnvelope<T>, ToolFailure>) -> Response {
725 result.map_or_else(tool_failure_response, success_response)
726}
727
728fn tool_failure_response(failure: ToolFailure) -> Response {
729 let status = match failure {
730 ToolFailure::Application => StatusCode::UNPROCESSABLE_ENTITY,
731 ToolFailure::Worker => StatusCode::INTERNAL_SERVER_ERROR,
732 };
733 error_response(
734 status,
735 "tool_failed",
736 "The application service could not complete the request.",
737 )
738}
739
740fn json_rejection_response(rejection: &JsonRejection) -> Response {
741 let status = rejection.status();
742 let (code, message) = if status == StatusCode::PAYLOAD_TOO_LARGE {
743 (
744 "payload_too_large",
745 "The request body exceeds the 1 MiB limit.",
746 )
747 } else {
748 ("invalid_json", "The JSON request body is invalid.")
749 };
750 error_response(status, code, message)
751}
752
753fn success_response<T: Serialize>(envelope: ToolEnvelope<T>) -> Response {
754 (StatusCode::OK, Json(envelope)).into_response()
755}
756
757fn error_response(status: StatusCode, code: &'static str, message: &'static str) -> Response {
758 (
759 status,
760 Json(ToolEnvelope {
761 schema_version: SCHEMA_VERSION,
762 status: ToolStatus::Error,
763 data: Some(ErrorReport { code, message }),
764 freshness: unknown_freshness(),
765 warnings: Vec::new(),
766 }),
767 )
768 .into_response()
769}
770
771fn unknown_freshness() -> FreshnessSummary {
772 FreshnessSummary {
773 overall: OverallFreshness::Unknown,
774 stale_repositories: Vec::new(),
775 reasons: Vec::new(),
776 }
777}
778
779const fn tool_status_from_freshness(freshness: &FreshnessSummary) -> ToolStatus {
780 match freshness.overall {
781 OverallFreshness::Fresh => ToolStatus::Ok,
782 OverallFreshness::Stale | OverallFreshness::Partial | OverallFreshness::Unknown => {
783 ToolStatus::Degraded
784 }
785 }
786}