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)]
54async fn add_unix_runtime_report(report: &mut Value) {
55    use crate::daemon::{socket_candidates, DaemonClient};
56
57    let expected = crate::build_info::BuildIdentity::current();
58    let candidates = socket_candidates();
59    let selected = candidates
60        .iter()
61        .find(|candidate| candidate.selected)
62        .map(|candidate| candidate.path.display().to_string());
63    let mut entries = Vec::with_capacity(candidates.len());
64    let mut reachable = 0_u64;
65    let mut compatible = 0_u64;
66    let mut mixed_guardians = 0_u64;
67
68    for candidate in candidates {
69        let client = DaemonClient::new(&candidate.path);
70        let entry = match client.hello().await {
71            Ok(hello) => {
72                reachable += 1;
73                let build_compatible = hello.build_matches(&expected);
74                compatible += u64::from(build_compatible);
75                let sessions = match client.list_sessions().await {
76                    Ok(sessions) => sessions,
77                    Err(error) => {
78                        entries.push(json!({
79                            "path": candidate.path.display().to_string(),
80                            "sources": candidate.sources,
81                            "selected": candidate.selected,
82                            "reachable": true,
83                            "build_compatible": build_compatible,
84                            "hello": hello,
85                            "sessions_error": error.to_string()
86                        }));
87                        continue;
88                    }
89                };
90                let session_entries = sessions
91                    .into_iter()
92                    .map(|session| {
93                        let guardian_build_compatible = session
94                            .runtime
95                            .as_ref()
96                            .is_some_and(|runtime| runtime.build_matches(&expected));
97                        if session.runtime.is_some() && !guardian_build_compatible {
98                            mixed_guardians += 1;
99                        }
100                        json!({
101                            "thread_id": session.thread_id,
102                            "cwd": session.cwd,
103                            "running": session.running,
104                            "guardian_build_compatible": guardian_build_compatible,
105                            "runtime": session.runtime
106                        })
107                    })
108                    .collect::<Vec<_>>();
109                json!({
110                    "path": candidate.path.display().to_string(),
111                    "sources": candidate.sources,
112                    "selected": candidate.selected,
113                    "reachable": true,
114                    "build_compatible": build_compatible,
115                    "hello": hello,
116                    "session_count": session_entries.len(),
117                    "sessions": session_entries
118                })
119            }
120            Err(error) => json!({
121                "path": candidate.path.display().to_string(),
122                "sources": candidate.sources,
123                "selected": candidate.selected,
124                "reachable": false,
125                "error": error.to_string()
126            }),
127        };
128        entries.push(entry);
129    }
130
131    report["daemon_topology"] = json!({
132        "selected_socket": selected,
133        "reachable_count": reachable,
134        "compatible_count": compatible,
135        "split_brain": reachable > 1,
136        "mixed_guardian_builds": mixed_guardians,
137        "healthy": reachable == 1 && mixed_guardians == 0 && compatible == reachable,
138        "candidates": entries
139    });
140    report["daemon_binary"] = match crate::runtime::configured_daemon_binary() {
141        Ok(path) => json!({"available": true, "path": path.display().to_string()}),
142        Err(error) => json!({"available": false, "error": error.to_string()}),
143    };
144}
145
146#[cfg(test)]
147mod tests {
148    #[tokio::test]
149    async fn doctor_report_never_exposes_secret_values() {
150        let report = super::doctor_report().await.to_string();
151        assert!(!report.contains("WINX_HTTP_TOKEN="));
152        assert!(report.contains("http_token_configured"));
153        assert!(report.contains("daemon_topology"));
154    }
155}