systemprompt_cli/commands/infrastructure/services/
status.rs1use crate::context::CommandContext;
7use crate::shared::CommandOutput;
8use anyhow::Result;
9use schemars::JsonSchema;
10use serde::{Deserialize, Serialize};
11use std::collections::HashMap;
12use std::hash::BuildHasher;
13use std::sync::Arc;
14use systemprompt_logging::CliService;
15use systemprompt_mcp::services::McpOrchestrator;
16use systemprompt_mcp::{HealthStatus, McpServiceStatus};
17use systemprompt_models::mcp::McpServerType;
18use systemprompt_runtime::{AppContext, StartupValidator, display_validation_report};
19use systemprompt_scheduler::{
20 RuntimeStatus, ServiceStateVerifier, ServiceType, VerifiedServiceState,
21};
22
23#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
24pub struct ServiceStatusOutput {
25 pub services: Vec<ServiceStatusRow>,
26 pub summary: StatusSummary,
27}
28
29#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
30pub struct ServiceStatusRow {
31 pub name: String,
32 pub service_type: String,
33 pub status: String,
34 pub pid: Option<u32>,
35 pub port: u16,
36 pub action: String,
37 #[serde(skip_serializing_if = "Option::is_none")]
38 pub error: Option<String>,
39 #[serde(skip_serializing_if = "Option::is_none")]
40 pub health: Option<String>,
41 #[serde(skip_serializing_if = "Option::is_none")]
42 pub endpoint: Option<String>,
43}
44
45#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema)]
46pub struct StatusSummary {
47 pub total: usize,
48 pub running: usize,
49 pub stopped: usize,
50}
51
52impl From<&VerifiedServiceState> for ServiceStatusRow {
53 fn from(state: &VerifiedServiceState) -> Self {
54 Self {
55 name: state.name.clone(),
56 service_type: state.service_type.to_string(),
57 status: state.status_display().to_owned(),
58 pid: state.pid,
59 port: state.port,
60 action: state.action_display().to_owned(),
61 error: state.error.clone(),
62 health: None,
63 endpoint: None,
64 }
65 }
66}
67
68pub fn health_label(health: HealthStatus) -> String {
69 if matches!(health, HealthStatus::Healthy | HealthStatus::Degraded) {
70 "OK".to_owned()
71 } else {
72 "DEGRADED".to_owned()
73 }
74}
75
76pub fn external_row(status: &McpServiceStatus) -> ServiceStatusRow {
77 ServiceStatusRow {
78 name: status.name.clone(),
79 service_type: "mcp".to_owned(),
80 status: "remote".to_owned(),
81 pid: None,
82 port: 0,
83 action: "none".to_owned(),
84 error: None,
85 health: Some(health_label(status.health)),
86 endpoint: status.endpoint.clone(),
87 }
88}
89
90pub fn build_status_output<S: BuildHasher>(
91 states: &[VerifiedServiceState],
92 mcp_health: &HashMap<String, HealthStatus, S>,
93 external: &[ServiceStatusRow],
94 include_health: bool,
95) -> ServiceStatusOutput {
96 let mut services: Vec<ServiceStatusRow> = states
97 .iter()
98 .map(|state| {
99 let mut row = ServiceStatusRow::from(state);
100 if include_health {
101 row.health = Some(managed_health_label(state, mcp_health));
102 }
103 row
104 })
105 .collect();
106
107 services.extend(external.iter().cloned());
108
109 let managed_running = states
110 .iter()
111 .filter(|s| s.runtime_status == RuntimeStatus::Running)
112 .count();
113 let external_running = external
114 .iter()
115 .filter(|r| r.health.as_deref() == Some("OK"))
116 .count();
117 let running = managed_running + external_running;
118 let total = states.len() + external.len();
119
120 ServiceStatusOutput {
121 services,
122 summary: StatusSummary {
123 total,
124 running,
125 stopped: total - running,
126 },
127 }
128}
129
130pub fn managed_health_label<S: BuildHasher>(
131 state: &VerifiedServiceState,
132 mcp_health: &HashMap<String, HealthStatus, S>,
133) -> String {
134 if state.service_type == ServiceType::Mcp {
135 return mcp_health
136 .get(&state.name)
137 .map_or_else(|| "DEGRADED".to_owned(), |h| health_label(*h));
138 }
139 if state.is_healthy() {
140 "OK".to_owned()
141 } else {
142 "DEGRADED".to_owned()
143 }
144}
145
146pub(super) async fn execute(
147 detailed: bool,
148 json: bool,
149 health: bool,
150 cmd_ctx: &CommandContext,
151) -> Result<CommandOutput> {
152 let config = &cmd_ctx.cli;
153 let ctx = Arc::clone(cmd_ctx.app_context().await?);
154
155 let Ok(configs) = super::load_service_configs() else {
156 let mut validator = StartupValidator::new();
157 let report = validator.validate(ctx.config());
158 if report.has_errors() {
159 display_validation_report(&report);
160 return Err(anyhow::anyhow!("Startup validation failed"));
161 }
162 return Err(anyhow::anyhow!("Failed to load service configs"));
163 };
164
165 let state_manager = ServiceStateVerifier::new(Arc::clone(ctx.db_pool()));
166 let states = state_manager.get_verified_states(&configs).await?;
167
168 let mcp_statuses = mcp_service_statuses(&ctx).await?;
169 let mcp_health: HashMap<String, HealthStatus> = mcp_statuses
170 .iter()
171 .filter(|s| s.server_type == McpServerType::Internal)
172 .map(|s| (s.name.clone(), s.health))
173 .collect();
174 let external: Vec<ServiceStatusRow> = mcp_statuses
175 .iter()
176 .filter(|s| s.server_type == McpServerType::External)
177 .map(external_row)
178 .collect();
179
180 let output = build_status_output(&states, &mcp_health, &external, health);
181
182 let result = CommandOutput::table_of(
183 vec![
184 "name",
185 "service_type",
186 "status",
187 "pid",
188 "endpoint",
189 "action",
190 ],
191 &output.services,
192 )
193 .with_title("Service Status");
194
195 if json || config.is_json_output() {
196 return Ok(result);
197 }
198
199 if detailed {
200 output_detailed(&states, &mcp_health, &external, health);
201 } else {
202 render_table_output(&output);
203 }
204
205 Ok(result.with_skip_render())
206}
207
208async fn mcp_service_statuses(ctx: &Arc<AppContext>) -> Result<Vec<McpServiceStatus>> {
209 let manager = McpOrchestrator::new(
210 Arc::clone(ctx.db_pool()),
211 (**ctx.service_repository()).clone(),
212 Arc::clone(ctx.app_paths_arc()),
213 ctx.mcp_registry().clone(),
214 )?;
215 Ok(manager.service_statuses().await?)
216}
217
218fn render_table_output(output: &ServiceStatusOutput) {
219 CliService::section("Service Status");
220
221 for service in &output.services {
222 let pid_str = service
223 .pid
224 .map_or_else(|| "-".to_owned(), |p| p.to_string());
225 let locator = service
226 .endpoint
227 .as_deref()
228 .map_or_else(|| format!("PID: {pid_str}"), |e| format!("endpoint: {e}"));
229 CliService::key_value(
230 &service.name,
231 &format!(
232 "{} | {} | {} | {}",
233 service.service_type, service.status, locator, service.action
234 ),
235 );
236 }
237
238 CliService::info(&format!(
239 "{}/{} services running",
240 output.summary.running, output.summary.total
241 ));
242}
243
244fn output_detailed(
245 states: &[VerifiedServiceState],
246 mcp_health: &HashMap<String, HealthStatus>,
247 external: &[ServiceStatusRow],
248 include_health: bool,
249) {
250 for state in states {
251 CliService::section(&state.name);
252 CliService::key_value("Type", &state.service_type.to_string());
253 CliService::key_value("Status", state.status_display());
254 CliService::key_value("Port", &state.port.to_string());
255
256 if let Some(pid) = state.pid {
257 CliService::key_value("PID", &pid.to_string());
258 }
259
260 CliService::key_value("Desired", &format!("{:?}", state.desired_status));
261 CliService::key_value("Action", state.action_display());
262
263 if let Some(error) = &state.error {
264 CliService::error(&format!("Error: {}", error));
265 }
266
267 if include_health {
268 CliService::key_value("Health", &managed_health_label(state, mcp_health));
269 }
270 }
271
272 for row in external {
273 CliService::section(&row.name);
274 CliService::key_value("Type", &row.service_type);
275 CliService::key_value("Status", &row.status);
276 if let Some(endpoint) = &row.endpoint {
277 CliService::key_value("Endpoint", endpoint);
278 }
279 if let Some(health) = &row.health {
280 CliService::key_value("Health", health);
281 }
282 }
283}