Skip to main content

systemprompt_cli/commands/infrastructure/services/
status.rs

1//! `infra services status` command rendering verified per-service health.
2//!
3//! Copyright (c) systemprompt.io — Business Source License 1.1.
4//! See <https://systemprompt.io> for licensing details.
5
6use 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(
166        Arc::clone(ctx.db_pool()),
167        systemprompt_identifiers::InstanceId::new(&ctx.config().instance_id),
168    );
169    let states = state_manager.get_verified_states(&configs).await?;
170
171    let mcp_statuses = mcp_service_statuses(&ctx).await?;
172    let mcp_health: HashMap<String, HealthStatus> = mcp_statuses
173        .iter()
174        .filter(|s| s.server_type == McpServerType::Internal)
175        .map(|s| (s.name.clone(), s.health))
176        .collect();
177    let external: Vec<ServiceStatusRow> = mcp_statuses
178        .iter()
179        .filter(|s| s.server_type == McpServerType::External)
180        .map(external_row)
181        .collect();
182
183    let output = build_status_output(&states, &mcp_health, &external, health);
184
185    let result = CommandOutput::table_of(
186        vec![
187            "name",
188            "service_type",
189            "status",
190            "pid",
191            "endpoint",
192            "action",
193        ],
194        &output.services,
195    )
196    .with_title("Service Status");
197
198    if json || config.is_json_output() {
199        return Ok(result);
200    }
201
202    if detailed {
203        output_detailed(&states, &mcp_health, &external, health);
204    } else {
205        render_table_output(&output);
206    }
207
208    Ok(result.with_skip_render())
209}
210
211async fn mcp_service_statuses(ctx: &Arc<AppContext>) -> Result<Vec<McpServiceStatus>> {
212    let manager = McpOrchestrator::new(
213        Arc::clone(ctx.db_pool()),
214        (**ctx.service_repository()).clone(),
215        Arc::clone(ctx.app_paths_arc()),
216        ctx.mcp_registry().clone(),
217    )?;
218    Ok(manager.service_statuses().await?)
219}
220
221fn render_table_output(output: &ServiceStatusOutput) {
222    CliService::section("Service Status");
223
224    for service in &output.services {
225        let pid_str = service
226            .pid
227            .map_or_else(|| "-".to_owned(), |p| p.to_string());
228        let locator = service
229            .endpoint
230            .as_deref()
231            .map_or_else(|| format!("PID: {pid_str}"), |e| format!("endpoint: {e}"));
232        CliService::key_value(
233            &service.name,
234            &format!(
235                "{} | {} | {} | {}",
236                service.service_type, service.status, locator, service.action
237            ),
238        );
239    }
240
241    CliService::info(&format!(
242        "{}/{} services running",
243        output.summary.running, output.summary.total
244    ));
245}
246
247fn output_detailed(
248    states: &[VerifiedServiceState],
249    mcp_health: &HashMap<String, HealthStatus>,
250    external: &[ServiceStatusRow],
251    include_health: bool,
252) {
253    for state in states {
254        CliService::section(&state.name);
255        CliService::key_value("Type", &state.service_type.to_string());
256        CliService::key_value("Status", state.status_display());
257        CliService::key_value("Port", &state.port.to_string());
258
259        if let Some(pid) = state.pid {
260            CliService::key_value("PID", &pid.to_string());
261        }
262
263        CliService::key_value("Desired", &format!("{:?}", state.desired_status));
264        CliService::key_value("Action", state.action_display());
265
266        if let Some(error) = &state.error {
267            CliService::error(&format!("Error: {}", error));
268        }
269
270        if include_health {
271            CliService::key_value("Health", &managed_health_label(state, mcp_health));
272        }
273    }
274
275    for row in external {
276        CliService::section(&row.name);
277        CliService::key_value("Type", &row.service_type);
278        CliService::key_value("Status", &row.status);
279        if let Some(endpoint) = &row.endpoint {
280            CliService::key_value("Endpoint", endpoint);
281        }
282        if let Some(health) = &row.health {
283            CliService::key_value("Health", health);
284        }
285    }
286}