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    report["shell"] = match crate::utils::executable::bash_executable() {
49        Some(path) => json!({"available": true, "bash": path.display().to_string()}),
50        None => json!({
51            "available": false,
52            "hint": if cfg!(windows) {
53                "no bash.exe found: install Git for Windows (the System32 WSL launcher is not used), or set WINX_SHELL to an absolute bash.exe"
54            } else {
55                "bash not found on PATH"
56            }
57        }),
58    };
59
60    add_runtime_report(&mut report).await;
61    report
62}
63
64#[allow(clippy::too_many_lines)] // one bounded pass builds the complete per-socket doctor report
65async fn add_runtime_report(report: &mut Value) {
66    use std::time::Duration;
67
68    use crate::daemon::{socket_candidates, DaemonClient};
69
70    // `doctor` is observational and must never wait indefinitely on a stale
71    // socket that accepts a connection but no longer serves frames.
72    const PROBE_TIMEOUT: Duration = Duration::from_secs(2);
73
74    let expected = crate::build_info::BuildIdentity::current();
75    let candidates = socket_candidates();
76    let selected = candidates
77        .iter()
78        .find(|candidate| candidate.selected)
79        .map(|candidate| candidate.path.display().to_string());
80    let mut entries = Vec::with_capacity(candidates.len());
81    let mut reachable = 0_u64;
82    let mut compatible = 0_u64;
83    let mut mixed_guardians = 0_u64;
84
85    for candidate in candidates {
86        let client = DaemonClient::new(&candidate.path);
87        let entry = match tokio::time::timeout(PROBE_TIMEOUT, client.hello()).await {
88            Ok(Ok(hello)) => {
89                reachable += 1;
90                let build_compatible = hello.build_matches(&expected);
91                compatible += u64::from(build_compatible);
92                let sessions =
93                    match tokio::time::timeout(PROBE_TIMEOUT, client.list_sessions()).await {
94                        Ok(Ok(sessions)) => sessions,
95                        Ok(Err(error)) => {
96                            entries.push(json!({
97                                "path": candidate.path.display().to_string(),
98                                "sources": candidate.sources,
99                                "selected": candidate.selected,
100                                "reachable": true,
101                                "build_compatible": build_compatible,
102                                "hello": hello,
103                                "sessions_error": error.to_string()
104                            }));
105                            continue;
106                        }
107                        Err(_) => {
108                            entries.push(json!({
109                                "path": candidate.path.display().to_string(),
110                                "sources": candidate.sources,
111                                "selected": candidate.selected,
112                                "reachable": true,
113                                "build_compatible": build_compatible,
114                                "hello": hello,
115                                "sessions_error": "timed out waiting for winxd"
116                            }));
117                            continue;
118                        }
119                    };
120                let session_entries = sessions
121                    .into_iter()
122                    .map(|session| {
123                        let guardian_build_compatible = session
124                            .runtime
125                            .as_ref()
126                            .is_some_and(|runtime| runtime.build_matches(&expected));
127                        if session.runtime.is_some() && !guardian_build_compatible {
128                            mixed_guardians += 1;
129                        }
130                        json!({
131                            "thread_id": session.thread_id,
132                            "cwd": session.cwd,
133                            "running": session.running,
134                            "guardian_build_compatible": guardian_build_compatible,
135                            "runtime": session.runtime
136                        })
137                    })
138                    .collect::<Vec<_>>();
139                json!({
140                    "path": candidate.path.display().to_string(),
141                    "sources": candidate.sources,
142                    "selected": candidate.selected,
143                    "reachable": true,
144                    "build_compatible": build_compatible,
145                    "hello": hello,
146                    "session_count": session_entries.len(),
147                    "sessions": session_entries
148                })
149            }
150            Ok(Err(error)) => json!({
151                "path": candidate.path.display().to_string(),
152                "sources": candidate.sources,
153                "selected": candidate.selected,
154                "reachable": false,
155                "error": error.to_string()
156            }),
157            Err(_) => json!({
158                "path": candidate.path.display().to_string(),
159                "sources": candidate.sources,
160                "selected": candidate.selected,
161                "reachable": false,
162                "error": "timed out waiting for winxd"
163            }),
164        };
165        entries.push(entry);
166    }
167
168    let last_exit = selected
169        .as_deref()
170        .map(std::path::Path::new)
171        .and_then(crate::daemon::exit::read_record)
172        .map_or(Value::Null, |record| serde_json::to_value(record).unwrap_or(Value::Null));
173    report["daemon_topology"] = json!({
174        "selected_socket": selected,
175        "last_exit": last_exit,
176        "reachable_count": reachable,
177        "compatible_count": compatible,
178        "split_brain": reachable > 1,
179        "mixed_guardian_builds": mixed_guardians,
180        "healthy": reachable == 1 && mixed_guardians == 0 && compatible == reachable,
181        "candidates": entries
182    });
183    report["daemon_binary"] = match crate::runtime::configured_daemon_binary() {
184        Ok(path) => json!({"available": true, "path": path.display().to_string()}),
185        Err(error) => json!({"available": false, "error": error.to_string()}),
186    };
187}
188
189#[cfg(test)]
190mod tests {
191    #[tokio::test]
192    async fn doctor_report_never_exposes_secret_values() {
193        let report = super::doctor_report().await.to_string();
194        assert!(!report.contains("WINX_HTTP_TOKEN="));
195        assert!(report.contains("http_token_configured"));
196        assert!(report.contains("daemon_topology"));
197    }
198}