1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
//! `tm doctor` — the standalone, one-shot diagnostic (#6336).
//!
//! Why: doctor used to be a daemon round-trip. `CommandExecutor` called
//! `GET /api/v1/doctor`, so when the daemon was not running the command printed
//! `doctor failed: daemon unreachable: …` and ran none of the 27 purely local
//! checks — the exact moment an operator most needs a diagnostic is the moment
//! it refused to produce one. Doctor never needed the daemon for those checks:
//! they read the filesystem, `gh`, launchd, and this process's own binary.
//! What: builds a READ-ONLY [`SessionManager`] over the on-disk session store,
//! runs [`run_doctor_for_manager`] in-process — the same battery
//! `GET /api/v1/doctor` serves — then appends the daemon-reachability row
//! (`doctor_daemon_row`) and, only when a daemon actually answered, the two
//! client-side checks that reason about that answer: `doctor_stale` (#2332) and
//! `doctor_orphan` (#4230). It never starts, restarts, or requires a daemon.
//! This lives beside `misc` rather than inside it because `misc.rs` had 24 SLOC
//! of headroom under the 500-SLOC production cap.
//! Test: `tests/tm_doctor_standalone.rs` drives the real binary against an
//! address nothing listens on.
use std::path::{Path, PathBuf};
use std::sync::Arc;
use trusty_mpm::core::doctor::{CheckStatus, DoctorCheck, DoctorReport};
use trusty_mpm::core::paths::FrameworkPaths;
use trusty_mpm::daemon::doctor::run_doctor_for_manager;
use trusty_mpm::session_manager::real_tmux::{NoopTmuxDriver, RealTmuxDriver};
use trusty_mpm::session_manager::{ManagedTmuxDriver, SessionManager};
use super::doctor_daemon_row::{self, DaemonReachability};
/// `doctor` subcommand — run and print the full system diagnostic.
///
/// Why: a misconfigured trusty-mpm stack fails confusingly; `tm doctor` runs
/// every health probe in one command and prints a formatted verdict so the
/// operator can confirm — or fix — a broken install at a glance. It is a
/// one-shot CLI diagnostic: it never runs inside the daemon, and a missing
/// daemon costs it exactly one row rather than the whole report (#6336).
/// What: runs the check battery in-process via [`local_report`], prints one
/// status-tagged line per check, appends the daemon rows from
/// [`daemon_rows`] and the `gcloud_auth` row (#8371, which spawns `gcloud`
/// only under `--network`), and folds every status into one overall verdict. When
/// `prune_stale_skills` is set (hidden `--prune-stale-skills` flag), also runs
/// `prune_stale_skills_locally` as a manual troubleshooting escape hatch —
/// normal operation cleans up pre-rename `mpm-*` skill directories
/// automatically and silently via the one-time
/// `core::stale_skills::run_stale_mpm_skills_migration_once` migration at `tm`
/// startup (#1905), so this flag should rarely be needed.
/// Test: `cli_parses_doctor` / `cli_parses_doctor_prune_stale_skills` cover
/// parsing; `tm_doctor_reports_every_local_check_with_no_daemon` covers the
/// daemonless path end to end; `tm_doctor_never_spawns_gcloud_without_network`
/// covers the opt-in gate; the stale-daemon comparison logic is covered by
/// `core::version_staleness`'s own unit tests.
pub(crate) async fn doctor(
daemon: &trusty_mpm::client::DaemonClient,
flags: &crate::cli::DoctorFlags,
) -> anyhow::Result<()> {
// #7757: resolve before printing anything, so a bad `--dir` fails closed.
let project_dir = resolve_project_dir(flags.dir.as_deref())?;
let report = local_report(project_dir.as_deref()).await?;
println!("trusty-mpm doctor");
let mut overall = report.overall;
for check in &report.checks {
print_check(check);
}
for check in daemon_rows(daemon).await {
overall = overall.worst(check.status);
print_check(&check);
}
// #8371: opt-in; without `--network` this spawns nothing.
let gcloud = super::doctor_gcloud::gcloud_auth_row(flags.network).await;
overall = overall.worst(gcloud.status);
print_check(&gcloud);
println!(
"\noverall: {} {}",
status_icon(overall),
match overall {
CheckStatus::Ok => "all checks passed",
CheckStatus::Warn => "passed with warnings",
// Issue #4005: deliberately not phrased as a pass. A check that
// could not be determined has not passed.
CheckStatus::Unknown =>
"one or more checks could not be determined — health is UNKNOWN",
CheckStatus::Fail => "one or more checks failed",
},
);
// #4948: every opt-in local action the report can be followed by lives in
// `doctor_repair`, so this file stays the report and the actions stay
// together.
super::doctor_repair::run_post_report_actions(flags);
Ok(())
}
/// Run the daemon's own check battery in this process.
///
/// Why (#6336): the battery reads the filesystem, `gh`, launchd, and this
/// binary — none of it needs a server. Routing it through
/// [`run_doctor_for_manager`] rather than re-deriving the fleet inputs here is
/// what guarantees `tm doctor` and `GET /api/v1/doctor` report the same checks.
/// What: opens the managed-session store read-only over a real tmux driver
/// (falling back to a no-op driver when tmux is absent, as `tm supervisor`
/// does) and delegates. `SessionStore::load` returns an empty store when the
/// file is absent, so a machine that has never run a managed session needs no
/// daemon, no store, and no tmux.
/// Test: `tm_doctor_reports_every_local_check_with_no_daemon`.
async fn local_report(project_dir: Option<&Path>) -> anyhow::Result<DoctorReport> {
let data_dir = FrameworkPaths::default().root.join("session-manager");
let tmux: Arc<dyn ManagedTmuxDriver> = match RealTmuxDriver::discover() {
Ok(driver) => Arc::new(driver),
// Not an error for a diagnostic: without tmux the worktree probe simply
// observes no live panes, which its own classification already reports
// as UNKNOWN rather than as a reclaimable orphan.
Err(_) => Arc::new(NoopTmuxDriver),
};
let mgr = SessionManager::new(&data_dir, tmux).await?;
Ok(run_doctor_for_manager(&mgr, project_dir).await)
}
/// The project directory the report is scoped to.
///
/// Why (#7757): `--dir` must never degrade to the cwd; a typo would otherwise
/// report on the wrong project and read as a clean bill of health.
/// What: `None` keeps the historical cwd scope. `Some(path)` is canonicalized
/// and must be a directory, else an error naming the path.
/// Test: `resolve_project_dir_*` below; `tm_doctor_dir_*` in
/// `tests/tm_doctor_standalone.rs`.
fn resolve_project_dir(dir: Option<&Path>) -> anyhow::Result<Option<PathBuf>> {
let Some(dir) = dir else {
return Ok(std::env::current_dir().ok());
};
let resolved = dir
.canonicalize()
.map_err(|e| anyhow::anyhow!("--dir {}: cannot resolve: {e}", dir.display()))?;
if !resolved.is_dir() {
anyhow::bail!("--dir {}: not a directory", dir.display());
}
Ok(Some(resolved))
}
/// The rows that depend on a daemon answering, in the order they print.
///
/// Why: exactly one row reports whether the daemon is there (#6336). The other
/// two — #2332 staleness and #4230 orphan detection — are comparisons ABOUT a
/// daemon's answer, so with no answer there is nothing to compare and they are
/// SKIPPED rather than reported as a warning or an unknown. Reporting them
/// anyway is what used to drag a perfectly healthy daemonless run to an overall
/// verdict of UNKNOWN, restating the daemon row twice in worse words.
/// What: one bounded `/health` probe, shared by all three rows so they can
/// never straddle a restart and describe two different daemons (#4230 review).
/// Test: `tm_doctor_reports_every_local_check_with_no_daemon` covers the
/// skip; the two comparisons keep their own unit tests.
async fn daemon_rows(daemon: &trusty_mpm::client::DaemonClient) -> Vec<DoctorCheck> {
let (reachability, snapshot) = doctor_daemon_row::probe_daemon(daemon).await;
// #6288: the row names the transport the probe used.
let mut rows = vec![doctor_daemon_row::daemon_check(
reachability,
&daemon.transport_label(),
)];
if let Some(snapshot) = snapshot.as_ref() {
debug_assert_eq!(reachability, DaemonReachability::Reachable);
// #4230: the restart hint is resolved from this host's launchd state,
// because `tm restart` is a hard error where launchd owns the daemon.
let restart_hint = super::launchd_probe::daemon_restart_command();
rows.push(super::doctor_stale::stale_daemon_check(
snapshot,
&restart_hint,
));
rows.push(super::doctor_orphan::orphan_daemon_check(Some(snapshot)));
}
// #8261: build leases are local flocks, so this row needs no daemon answer.
rows.push(super::doctor_builder_cap::builder_cap_row());
// #8451: the slot pool's volume against the daemon's eviction threshold.
rows.push(super::doctor_slot_pool::slot_pool_budget_row());
rows
}
/// Print one check as an icon, a padded name, and its message.
fn print_check(check: &DoctorCheck) {
println!(
" {} {:<21} {}",
status_icon(check.status),
check.name,
check.message,
);
}
/// Map a check status to its one-glance icon.
fn status_icon(status: CheckStatus) -> &'static str {
match status {
CheckStatus::Ok => "\u{2705}",
CheckStatus::Warn => "\u{26a0}\u{fe0f}",
// Never ✅ (issue #4005): an indeterminate check must not read as
// healthy at a glance.
CheckStatus::Unknown => "\u{2754}",
CheckStatus::Fail => "\u{274c}",
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resolve_project_dir_canonicalizes_an_existing_directory() {
let tmp = tempfile::tempdir().unwrap();
let got = resolve_project_dir(Some(tmp.path())).unwrap().unwrap();
assert_eq!(got, tmp.path().canonicalize().unwrap());
}
#[test]
fn resolve_project_dir_rejects_a_missing_path() {
let tmp = tempfile::tempdir().unwrap();
let err = resolve_project_dir(Some(&tmp.path().join("nope"))).unwrap_err();
assert!(err.to_string().contains("cannot resolve"), "{err}");
}
#[test]
fn resolve_project_dir_rejects_a_file() {
let tmp = tempfile::tempdir().unwrap();
let file = tmp.path().join("f");
std::fs::write(&file, "x").unwrap();
let err = resolve_project_dir(Some(&file)).unwrap_err();
assert!(err.to_string().contains("not a directory"), "{err}");
}
}