From fee34b6bd1116155d8b1b1aed69d60be4f090762 Mon Sep 17 00:00:00 2001 From: zyycn Date: Sun, 26 Jul 2026 03:24:04 +0800 Subject: [PATCH] feat(xai): expose CLI release check status Record successful and failed official npm checks, retain the last successful version on failure, and publish the checked timestamp through the dashboard wire profile. Co-authored-by: Codex --- backend/crates/providers/xai/src/admin.rs | 65 +++++-- backend/crates/providers/xai/src/lib.rs | 2 + .../providers/xai/src/transport/profile.rs | 160 +++++++++++++++++- backend/crates/providers/xai/tests/admin.rs | 8 +- 4 files changed, 218 insertions(+), 17 deletions(-) diff --git a/backend/crates/providers/xai/src/admin.rs b/backend/crates/providers/xai/src/admin.rs index f47ae86f1..7676d4920 100644 --- a/backend/crates/providers/xai/src/admin.rs +++ b/backend/crates/providers/xai/src/admin.rs @@ -11,8 +11,9 @@ use gateway_admin::model::accounts::{ AccountAvailability as AdminAccountAvailability, AccountRecord, }; use gateway_admin::model::observability::{ - CalculatedBillingBreakdown, CurrencyCost, DashboardWireAttribute, DashboardWireProfile, - DashboardWireTarget, DecimalAmount, ProviderBillingInput, + CalculatedBillingBreakdown, CurrencyCost, DashboardDesktopRelease, DashboardWireAttribute, + DashboardWireProfile, DashboardWireTarget, DecimalAmount, DesktopReleaseStatus, + ProviderBillingInput, }; use gateway_admin::model::provider_credentials::{ AuthorizationMutationTarget, AuthorizationOwner, AuthorizationOwnerBinding, @@ -57,6 +58,7 @@ use crate::credential::{ PreparedGrokCredentialRotation, PreparedGrokCredentialRotationGuard, RedirectUriAllowlist, RotateManagedGrokCredential, SecretValue, VerifiedGrokAccount, VerifiedTokenSet, }; +use crate::transport::profile::{GrokCliReleaseSnapshot, GrokCliReleaseStatus}; use crate::transport::{GROK_CLI_BASE_URL, XAI_PROVIDER_NAME, grok_billing_breakdown}; const PENDING_SCHEMA_VERSION: u64 = 2; @@ -66,6 +68,7 @@ const MAX_PENDING_TEXT_BYTES: usize = 512; pub(crate) struct XaiAdminProvider { provider_kind: ProviderKind, wire_profile: XaiWireProfileState, + cli_release: GrokCliReleaseStatus, accounts: Arc, repository: GrokCredentialRepository, oauth_config: GrokOAuthConfig, @@ -95,10 +98,12 @@ impl XaiAdminProvider { wire_profile: XaiWireProfileState, accounts: Arc, services: XaiAdminServices, + cli_release: GrokCliReleaseStatus, ) -> Self { Self { provider_kind, wire_profile, + cli_release, accounts, repository: services.repository, oauth_config: services.oauth_config, @@ -216,34 +221,42 @@ impl ProviderAdmin for XaiAdminProvider { } fn dashboard_wire_profile(&self) -> Option { + let profile = self.wire_profile.snapshot(); + let release = dashboard_cli_release(&profile.client_version, self.cli_release.snapshot()); Some(DashboardWireProfile { provider: self.provider_kind.as_str().to_owned(), product: "Grok Build".to_owned(), - version: self.wire_profile.client_version(), + version: profile.client_version.clone(), build: None, target: DashboardWireTarget { - os_type: self.wire_profile.target_os(), + os_type: profile.target_os.clone(), os_version: "—".to_owned(), - arch: self.wire_profile.target_arch(), - terminal: self.wire_profile.client_mode(), + arch: profile.target_arch.clone(), + terminal: profile.client_mode.clone(), }, - user_agent: self.wire_profile.user_agent(), + user_agent: format!( + "{}/{} ({}; {})", + profile.client_identifier, + profile.client_version, + profile.target_os, + profile.target_arch + ), attributes: vec![ DashboardWireAttribute { label: "客户端标识".to_owned(), - value: self.wire_profile.client_identifier(), + value: profile.client_identifier, }, DashboardWireAttribute { label: "运行模式".to_owned(), - value: self.wire_profile.client_mode(), + value: profile.client_mode, }, DashboardWireAttribute { label: "Token 认证".to_owned(), value: "xai-grok-cli".to_owned(), }, ], - verified_at: Some(self.wire_profile.verified_at()), - release: None, + verified_at: Some(profile.verified_at), + release: Some(release), }) } @@ -771,6 +784,36 @@ fn validate_account_record( Ok(()) } +fn dashboard_cli_release( + profile_version: &str, + snapshot: GrokCliReleaseSnapshot, +) -> DashboardDesktopRelease { + let status = if snapshot.checked_at.is_none() { + DesktopReleaseStatus::Unchecked + } else if snapshot.last_error.is_some() { + DesktopReleaseStatus::Failed + } else if snapshot.latest_version.as_deref() == Some(profile_version) { + DesktopReleaseStatus::Current + } else if snapshot.latest_version.is_some() { + DesktopReleaseStatus::UpdateAvailable + } else { + DesktopReleaseStatus::Failed + }; + DashboardDesktopRelease { + status, + checked_at: snapshot.checked_at, + latest_version: snapshot.latest_version, + latest_build: None, + published_at: None, + minimum_system_version: None, + hardware_requirements: None, + download_url: None, + download_size: None, + signature_present: None, + error: snapshot.last_error, + } +} + fn account_from_record(account: &AccountRecord) -> Result { let account_id = ProviderAccountId::new(account.id.clone()) .map_err(|_| provider_error(ProviderAdminErrorKind::Invalid))?; diff --git a/backend/crates/providers/xai/src/lib.rs b/backend/crates/providers/xai/src/lib.rs index 5430e39d5..9addecbad 100644 --- a/backend/crates/providers/xai/src/lib.rs +++ b/backend/crates/providers/xai/src/lib.rs @@ -100,6 +100,7 @@ pub async fn initialize( OfficialGrokCliReleaseTransport::new().map_err(|_| XaiInitializeError::Transport)?, ), )); + let cli_release_status = cli_release.status(); let provider_kind = ProviderKind::new(XAI_PROVIDER_NAME).map_err(|_| XaiInitializeError::ProviderKind)?; let accounts: Arc = ports.accounts(); @@ -194,6 +195,7 @@ pub async fn initialize( catalog: Arc::clone(&catalog), runtime_policy, }, + cli_release_status, )); let worker_contributions = provider::worker_contributions( refresh, diff --git a/backend/crates/providers/xai/src/transport/profile.rs b/backend/crates/providers/xai/src/transport/profile.rs index b81062893..62b13621a 100644 --- a/backend/crates/providers/xai/src/transport/profile.rs +++ b/backend/crates/providers/xai/src/transport/profile.rs @@ -145,18 +145,82 @@ impl GrokCliReleaseTransport for OfficialGrokCliReleaseTransport { pub struct GrokCliReleaseService { profile: XaiWireProfileState, transport: Arc, + status: GrokCliReleaseStatus, } impl GrokCliReleaseService { #[must_use] pub fn new(profile: XaiWireProfileState, transport: Arc) -> Self { - Self { profile, transport } + Self { + profile, + transport, + status: GrokCliReleaseStatus::default(), + } + } + + #[must_use] + pub(crate) fn status(&self) -> GrokCliReleaseStatus { + self.status.clone() } pub async fn refresh(&self) -> Result { - let version = self.transport.fetch().await?; - self.profile.update_client_version(&version); - Ok(version) + let checked_at = Utc::now(); + let result = self.transport.fetch().await; + match result { + Ok(version) => { + self.profile.update_client_version(&version); + self.status.record_success(checked_at, version.clone()); + Ok(version) + } + Err(error) => { + self.status.record_failure(checked_at, &error); + Err(error) + } + } + } +} + +/// 最近一次官方 Grok CLI 发布检查结果。 +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub(crate) struct GrokCliReleaseSnapshot { + pub(crate) checked_at: Option>, + pub(crate) latest_version: Option, + pub(crate) last_error: Option, +} + +/// Provider 内共享的 Grok CLI 发布检查观察状态。 +#[derive(Debug, Clone, Default)] +pub(crate) struct GrokCliReleaseStatus { + snapshot: Arc>, +} + +impl GrokCliReleaseStatus { + #[must_use] + pub(crate) fn snapshot(&self) -> GrokCliReleaseSnapshot { + self.snapshot + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone() + } + + fn record_success(&self, checked_at: DateTime, latest_version: String) { + *self + .snapshot + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner) = GrokCliReleaseSnapshot { + checked_at: Some(checked_at), + latest_version: Some(latest_version), + last_error: None, + }; + } + + fn record_failure(&self, checked_at: DateTime, error: &GrokCliReleaseError) { + let mut snapshot = self + .snapshot + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner); + snapshot.checked_at = Some(checked_at); + snapshot.last_error = Some(error.to_string()); } } @@ -191,3 +255,91 @@ pub enum GrokCliReleaseError { #[error("Grok CLI release version is invalid")] InvalidVersion, } + +#[cfg(test)] +mod tests { + use std::collections::VecDeque; + use std::sync::Mutex; + + use super::*; + + struct ReleaseTransport { + outcomes: Mutex>>, + } + + impl ReleaseTransport { + fn new(outcomes: impl IntoIterator>) -> Self { + Self { + outcomes: Mutex::new(outcomes.into_iter().collect()), + } + } + } + + impl GrokCliReleaseTransport for ReleaseTransport { + fn fetch(&self) -> BoxFuture<'_, Result> { + let outcome = self + .outcomes + .lock() + .expect("release outcomes lock") + .pop_front() + .expect("release outcome"); + Box::pin(async move { outcome }) + } + } + + #[tokio::test] + async fn release_refresh_should_update_profile_and_observation() { + let profile = wire_profile(); + let service = GrokCliReleaseService::new( + profile.clone(), + Arc::new(ReleaseTransport::new([Ok("0.2.112".to_owned())])), + ); + let status = service.status(); + + assert_eq!(service.refresh().await.expect("release"), "0.2.112"); + assert_eq!(profile.client_version(), "0.2.112"); + let snapshot = status.snapshot(); + assert!(snapshot.checked_at.is_some()); + assert_eq!(snapshot.latest_version.as_deref(), Some("0.2.112")); + assert!(snapshot.last_error.is_none()); + } + + #[tokio::test] + async fn failed_release_refresh_should_keep_last_successful_version() { + let profile = wire_profile(); + let service = GrokCliReleaseService::new( + profile.clone(), + Arc::new(ReleaseTransport::new([ + Ok("0.2.112".to_owned()), + Err(GrokCliReleaseError::InvalidDocument), + ])), + ); + let status = service.status(); + + service.refresh().await.expect("first release"); + assert!(matches!( + service.refresh().await, + Err(GrokCliReleaseError::InvalidDocument) + )); + + assert_eq!(profile.client_version(), "0.2.112"); + let snapshot = status.snapshot(); + assert!(snapshot.checked_at.is_some()); + assert_eq!(snapshot.latest_version.as_deref(), Some("0.2.112")); + assert_eq!( + snapshot.last_error.as_deref(), + Some("Grok CLI release document is invalid") + ); + } + + fn wire_profile() -> XaiWireProfileState { + XaiWireProfileState::new(XaiWireProfileConfig { + client_identifier: "grok-shell".to_owned(), + client_version: "0.2.106".to_owned(), + client_mode: "headless".to_owned(), + target_os: "linux".to_owned(), + target_arch: "x86_64".to_owned(), + verified_at: Utc::now(), + }) + } +} diff --git a/backend/crates/providers/xai/tests/admin.rs b/backend/crates/providers/xai/tests/admin.rs index d70d7f904..720071b3c 100644 --- a/backend/crates/providers/xai/tests/admin.rs +++ b/backend/crates/providers/xai/tests/admin.rs @@ -9,7 +9,9 @@ use futures::{StreamExt as _, future::BoxFuture}; use gateway_admin::model::accounts::{ AccountAvailability as AdminAccountAvailability, AccountRecord, }; -use gateway_admin::model::observability::{CurrencyCost, ProviderBillingInput}; +use gateway_admin::model::observability::{ + CurrencyCost, DesktopReleaseStatus, ProviderBillingInput, +}; use gateway_admin::model::provider_credentials::{ CompleteAuthorization, PrepareCredentialImport, PrepareCredentialRefresh, PrepareCredentialRotation, ProviderDocument, ProviderExportCredentialInput, @@ -281,7 +283,9 @@ async fn xai_admin_provider_validates_known_billing_breakdown() { assert_eq!(profile.product, "Grok Build"); assert_eq!(profile.version, "0.2.106"); assert_eq!(profile.user_agent, "grok-shell/0.2.106 (linux; x86_64)"); - assert!(profile.release.is_none()); + let release = profile.release.expect("release status"); + assert_eq!(release.status, DesktopReleaseStatus::Unchecked); + assert!(release.checked_at.is_none()); let billing = admin .calculated_billing(&ProviderBillingInput {