Skip to main content

winx_code_agent/
diagnostics.rs

1//! Read-only runtime diagnostics shared by the CLI and future health endpoints.
2
3use serde_json::{json, Value};
4
5/// Build a redacted snapshot of configuration and live runtime topology.
6pub async fn doctor_report() -> Value {
7    let runtime = crate::runtime::configured_runtime_mode().map_or_else(
8        |error| format!("invalid: {error}"),
9        |mode| format!("{mode:?}").to_ascii_lowercase(),
10    );
11    let build = crate::build_info::BuildIdentity::current();
12    let mut report = json!({
13        "version": crate::build_info::package_version(),
14        "build": build,
15        "platform": {
16            "os": std::env::consts::OS,
17            "arch": std::env::consts::ARCH
18        },
19        "cwd": std::env::current_dir().ok().map(|path| path.display().to_string()),
20        "runtime": runtime,
21        "environment": {
22            "sandbox_requested": crate::config::env_flag("WINX_SANDBOX"),
23            "redaction_disabled": crate::config::env_flag("WINX_NO_REDACT"),
24            "compression_disabled": crate::config::env_flag("WINX_NO_COMPRESS"),
25            "http_token_configured": crate::config::env_text("WINX_HTTP_TOKEN").is_some(),
26            "usage_log_configured": crate::config::env_text("WINX_USAGE_LOG").is_some()
27        }
28    });
29
30    let allowed_roots = crate::utils::path::configured_allowed_roots();
31    let unconfined =
32        cfg!(unix) && allowed_roots.iter().any(|root| root == std::path::Path::new("/"));
33    let containment_mode = if unconfined {
34        "unconfined"
35    } else if allowed_roots.is_empty() {
36        "workspace"
37    } else {
38        "extended"
39    };
40    report["file_tool_containment"] = json!({
41        "mode": containment_mode,
42        "extra_roots": allowed_roots
43            .iter()
44            .map(|path| path.display().to_string())
45            .collect::<Vec<_>>()
46    });
47
48    #[cfg(unix)]
49    add_unix_runtime_report(&mut report).await;
50    report
51}
52
53#[cfg(unix)]
54#[allow(clippy::too_many_lines)] // one bounded pass builds the complete per-socket doctor report
55async fn add_unix_runtime_report(report: &mut Value) {
56    use std::time::Duration;
57
58    use crate::daemon::{socket_candidates, DaemonClient};
59
60    // `doctor` is observational and must never wait indefinitely on a stale
61    // socket that accepts a connection but no longer serves frames.
62    const PROBE_TIMEOUT: Duration = Duration::from_secs(2);
63
64    let expected = crate::build_info::BuildIdentity::current();
65    let candidates = socket_candidates();
66    let selected = candidates
67        .iter()
68        .find(|candidate| candidate.selected)
69        .map(|candidate| candidate.path.display().to_string());
70    let mut entries = Vec::with_capacity(candidates.len());
71    let mut reachable = 0_u64;
72    let mut compatible = 0_u64;
73    let mut mixed_guardians = 0_u64;
74
75    for candidate in candidates {
76        let client = DaemonClient::new(&candidate.path);
77        let entry = match tokio::time::timeout(PROBE_TIMEOUT, client.hello()).await {
78            Ok(Ok(hello)) => {
79                reachable += 1;
80                let build_compatible = hello.build_matches(&expected);
81                compatible += u64::from(build_compatible);
82                let sessions =
83                    match tokio::time::timeout(PROBE_TIMEOUT, client.list_sessions()).await {
84                        Ok(Ok(sessions)) => sessions,
85                        Ok(Err(error)) => {
86                            entries.push(json!({
87                                "path": candidate.path.display().to_string(),
88                                "sources": candidate.sources,
89                                "selected": candidate.selected,
90                                "reachable": true,
91                                "build_compatible": build_compatible,
92                                "hello": hello,
93                                "sessions_error": error.to_string()
94                            }));
95                            continue;
96                        }
97                        Err(_) => {
98                            entries.push(json!({
99                                "path": candidate.path.display().to_string(),
100                                "sources": candidate.sources,
101                                "selected": candidate.selected,
102                                "reachable": true,
103                                "build_compatible": build_compatible,
104                                "hello": hello,
105                                "sessions_error": "timed out waiting for winxd"
106                            }));
107                            continue;
108                        }
109                    };
110                let session_entries = sessions
111                    .into_iter()
112                    .map(|session| {
113                        let guardian_build_compatible = session
114                            .runtime
115                            .as_ref()
116                            .is_some_and(|runtime| runtime.build_matches(&expected));
117                        if session.runtime.is_some() && !guardian_build_compatible {
118                            mixed_guardians += 1;
119                        }
120                        json!({
121                            "thread_id": session.thread_id,
122                            "cwd": session.cwd,
123                            "running": session.running,
124                            "guardian_build_compatible": guardian_build_compatible,
125                            "runtime": session.runtime
126                        })
127                    })
128                    .collect::<Vec<_>>();
129                json!({
130                    "path": candidate.path.display().to_string(),
131                    "sources": candidate.sources,
132                    "selected": candidate.selected,
133                    "reachable": true,
134                    "build_compatible": build_compatible,
135                    "hello": hello,
136                    "session_count": session_entries.len(),
137                    "sessions": session_entries
138                })
139            }
140            Ok(Err(error)) => json!({
141                "path": candidate.path.display().to_string(),
142                "sources": candidate.sources,
143                "selected": candidate.selected,
144                "reachable": false,
145                "error": error.to_string()
146            }),
147            Err(_) => json!({
148                "path": candidate.path.display().to_string(),
149                "sources": candidate.sources,
150                "selected": candidate.selected,
151                "reachable": false,
152                "error": "timed out waiting for winxd"
153            }),
154        };
155        entries.push(entry);
156    }
157
158    report["daemon_topology"] = json!({
159        "selected_socket": selected,
160        "reachable_count": reachable,
161        "compatible_count": compatible,
162        "split_brain": reachable > 1,
163        "mixed_guardian_builds": mixed_guardians,
164        "healthy": reachable == 1 && mixed_guardians == 0 && compatible == reachable,
165        "candidates": entries
166    });
167    report["daemon_binary"] = match crate::runtime::configured_daemon_binary() {
168        Ok(path) => json!({"available": true, "path": path.display().to_string()}),
169        Err(error) => json!({"available": false, "error": error.to_string()}),
170    };
171}
172
173#[cfg(test)]
174mod tests {
175    #[tokio::test]
176    async fn doctor_report_never_exposes_secret_values() {
177        let report = super::doctor_report().await.to_string();
178        assert!(!report.contains("WINX_HTTP_TOKEN="));
179        assert!(report.contains("http_token_configured"));
180        assert!(report.contains("daemon_topology"));
181    }
182}