diff --git a/codex-rs/cli/src/status.rs b/codex-rs/cli/src/status.rs new file mode 100644 index 0000000000..c40b933ccc --- /dev/null +++ b/codex-rs/cli/src/status.rs @@ -0,0 +1,116 @@ +use clap::Args; +use codex_status::CodexStatusReport; +use codex_status::StatusClient; +use std::io::Write; +use std::io::{self}; + +#[derive(Debug, Args)] +pub struct StatusCommand { + /// Emit the Codex-only status as prettified JSON instead of a human summary. + #[arg(long = "json", default_value_t = false)] + pub json: bool, +} + +pub async fn run_status(cmd: StatusCommand) -> anyhow::Result<()> { + let client = StatusClient::new()?; + let report = client.fetch_codex_status().await?; + + if cmd.json { + let json = serde_json::to_string_pretty(&report)?; + println!("{json}"); + } else { + write_human(&report, &mut io::stdout())?; + } + + Ok(()) +} + +fn write_human(report: &CodexStatusReport, writer: &mut W) -> anyhow::Result<()> { + writeln!( + writer, + "overall: {} ({})", + report.overall_description, report.overall_indicator + )?; + writeln!(writer, "updated_at: {}", report.updated_at)?; + + writeln!(writer, "codex components:")?; + if report.components.is_empty() { + writeln!(writer, " none")?; + } else { + for component in &report.components { + writeln!(writer, " {}: {}", component.name, component.status)?; + } + } + + writeln!(writer, "codex incidents:")?; + if report.incidents.is_empty() { + writeln!(writer, " none")?; + } else { + for incident in &report.incidents { + writeln!( + writer, + " {}: status={} impact={} updated={}", + incident.name, incident.status, incident.impact, incident.updated_at + )?; + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use codex_status::ComponentStatus; + use codex_status::IncidentStatus; + + #[test] + fn human_output_handles_empty_lists() -> anyhow::Result<()> { + let report = CodexStatusReport { + overall_description: "All Systems Operational".to_string(), + overall_indicator: "none".to_string(), + updated_at: "2025-11-07T21:55:20Z".to_string(), + components: Vec::new(), + incidents: Vec::new(), + }; + + let mut buffer = Vec::new(); + write_human(&report, &mut buffer)?; + + let output = String::from_utf8(buffer).expect("utf8"); + assert!(output.contains("overall: All Systems Operational (none)")); + assert!(output.contains("updated_at: 2025-11-07T21:55:20Z")); + assert!(output.contains("codex components:\n none")); + assert!(output.contains("codex incidents:\n none")); + Ok(()) + } + + #[test] + fn human_output_lists_components_and_incidents() -> anyhow::Result<()> { + let report = CodexStatusReport { + overall_description: "Degraded".to_string(), + overall_indicator: "minor".to_string(), + updated_at: "2025-11-07T21:55:20Z".to_string(), + components: vec![ComponentStatus { + name: "Codex".to_string(), + status: "degraded_performance".to_string(), + }], + incidents: vec![IncidentStatus { + name: "Codex degraded".to_string(), + status: "investigating".to_string(), + impact: "minor".to_string(), + updated_at: "2025-11-07T21:45:00Z".to_string(), + }], + }; + + let mut buffer = Vec::new(); + write_human(&report, &mut buffer)?; + + let output = String::from_utf8(buffer).expect("utf8"); + assert!(output.contains("codex components:\n Codex: degraded_performance")); + assert!(output.contains( + "codex incidents:\n Codex degraded: status=investigating impact=minor updated=2025-11-07T21:45:00Z" + )); + Ok(()) + } +} diff --git a/codex-rs/status/Cargo.toml b/codex-rs/status/Cargo.toml new file mode 100644 index 0000000000..5a17295767 --- /dev/null +++ b/codex-rs/status/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "codex-status" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lints] +workspace = true + +[dependencies] +anyhow = { workspace = true } +reqwest = { workspace = true, features = ["json"] } +serde = { workspace = true, features = ["derive"] } + +[dev-dependencies] +pretty_assertions = { workspace = true } +serde_json = { workspace = true } diff --git a/codex-rs/status/src/lib.rs b/codex-rs/status/src/lib.rs new file mode 100644 index 0000000000..edfa715156 --- /dev/null +++ b/codex-rs/status/src/lib.rs @@ -0,0 +1,230 @@ +use anyhow::Context; +use reqwest::Url; +use serde::Deserialize; +use serde::Serialize; +use std::time::Duration; + +pub const STATUS_SUMMARY_URL: &str = "https://status.openai.com/api/v2/summary.json"; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct CodexStatusReport { + pub overall_description: String, + pub overall_indicator: String, + pub updated_at: String, + pub components: Vec, + pub incidents: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ComponentStatus { + pub name: String, + pub status: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct IncidentStatus { + pub name: String, + pub status: String, + pub impact: String, + pub updated_at: String, +} + +#[derive(Debug, Clone)] +pub struct StatusClient { + client: reqwest::Client, + summary_url: Url, +} + +impl StatusClient { + pub fn new() -> anyhow::Result { + Self::with_summary_url(Url::parse(STATUS_SUMMARY_URL)?) + } + + pub fn with_summary_url(summary_url: Url) -> anyhow::Result { + let user_agent = format!("codex-status/{}", env!("CARGO_PKG_VERSION")); + let client = reqwest::Client::builder() + .user_agent(user_agent) + .connect_timeout(Duration::from_secs(5)) + .timeout(Duration::from_secs(10)) + .build() + .context("building HTTP client")?; + + Ok(Self { + client, + summary_url, + }) + } + + pub async fn fetch_codex_status(&self) -> anyhow::Result { + let response = self + .client + .get(self.summary_url.clone()) + .send() + .await + .context("requesting status summary")?; + + let summary: StatusSummary = response + .error_for_status() + .context("status summary returned error")? + .json() + .await + .context("parsing status summary JSON")?; + + Ok(CodexStatusReport::from_summary(summary)) + } +} + +impl CodexStatusReport { + fn from_summary(summary: StatusSummary) -> Self { + let components = summary + .components + .into_iter() + .filter(|component| is_codex_name(&component.name)) + .map(ComponentStatus::from) + .collect(); + + let incidents = summary + .incidents + .into_iter() + .filter(|incident| is_codex_name(&incident.name)) + .map(IncidentStatus::from) + .collect(); + + CodexStatusReport { + overall_description: summary.status.description, + overall_indicator: summary.status.indicator, + updated_at: summary.page.updated_at, + components, + incidents, + } + } +} + +fn is_codex_name(name: &str) -> bool { + name.to_ascii_lowercase().contains("codex") +} + +#[derive(Debug, Deserialize)] +struct StatusSummary { + #[serde(default)] + page: Page, + #[serde(default)] + status: OverallStatus, + #[serde(default)] + components: Vec, + #[serde(default)] + incidents: Vec, +} + +#[derive(Debug, Deserialize, Default)] +struct Page { + #[serde(default)] + updated_at: String, +} + +#[derive(Debug, Deserialize, Default)] +struct OverallStatus { + #[serde(default)] + indicator: String, + #[serde(default)] + description: String, +} + +#[derive(Debug, Deserialize)] +struct Component { + name: String, + status: String, +} + +#[derive(Debug, Deserialize)] +struct Incident { + name: String, + status: String, + #[serde(default = "default_impact")] + impact: String, + #[serde(default)] + updated_at: String, +} + +fn default_impact() -> String { + "unknown".to_string() +} + +impl From for ComponentStatus { + fn from(value: Component) -> Self { + Self { + name: value.name, + status: value.status, + } + } +} + +impl From for IncidentStatus { + fn from(value: Incident) -> Self { + Self { + name: value.name, + status: value.status, + impact: value.impact, + updated_at: value.updated_at, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use serde_json::json; + + #[test] + fn filters_non_codex_components_and_incidents() { + let summary = serde_json::from_value::(json!({ + "page": {"updated_at": "2025-11-07T21:55:20Z"}, + "status": {"description": "All Systems Operational", "indicator": "none"}, + "components": [ + {"name": "Codex", "status": "operational"}, + {"name": "Chat Completions", "status": "operational"} + ], + "incidents": [ + {"name": "Codex degraded performance", "status": "investigating", "impact": "minor", "updated_at": "2025-11-07T21:50:00Z"}, + {"name": "Chat downtime", "status": "resolved", "impact": "critical", "updated_at": "2025-11-07T21:00:00Z"} + ] + })) + .expect("valid summary"); + + let report = CodexStatusReport::from_summary(summary); + + assert_eq!(report.overall_description, "All Systems Operational"); + assert_eq!(report.overall_indicator, "none"); + assert_eq!(report.updated_at, "2025-11-07T21:55:20Z"); + assert_eq!(report.components.len(), 1); + assert_eq!(report.components[0].name, "Codex"); + assert_eq!(report.components[0].status, "operational"); + assert_eq!(report.incidents.len(), 1); + assert_eq!(report.incidents[0].name, "Codex degraded performance"); + assert_eq!(report.incidents[0].status, "investigating"); + assert_eq!(report.incidents[0].impact, "minor"); + assert_eq!(report.incidents[0].updated_at, "2025-11-07T21:50:00Z"); + } + + #[test] + fn handles_missing_fields_with_defaults() { + let summary = + serde_json::from_value::(json!({})).expect("valid empty summary"); + + let report = CodexStatusReport::from_summary(summary); + + assert_eq!(report.overall_description, ""); + assert_eq!(report.overall_indicator, ""); + assert_eq!(report.updated_at, ""); + assert!(report.components.is_empty()); + assert!(report.incidents.is_empty()); + } + + #[test] + fn is_codex_name_matches_case_insensitive() { + assert!(is_codex_name("Codex")); + assert!(is_codex_name("my-codex-component")); + assert!(!is_codex_name("Chat")); + } +}