- 1
//! `vak doctor` (docs/design/29-personal-os.md P3): CLI parity with the - 2
//! TUI's `/doctor`, rendered from the same `vak_core::health::collect` - 3
//! report — checks first (failures last), facts, then the frozen route - 4
//! ladder when one is on record. - 5
//! - 6
//! `--repair` (docs/design/32-release-engineering.md) acts on the small - 7
//! subset of checks that have a mechanical fix — today, self version - 8
//! parity via `self install --force` — then re-collects and re-prints the - 9
//! report. Checks with no mechanical fix (provider auth, config warnings, - 10
//! an unwritable sessions home) are left for the operator; doctor never - 11
//! guesses at those. - 12
- 13
use std::path::PathBuf; - 14
- 15
use vak_core::{Core, health}; - 16
- 17
/// The runtime's health report, plus one check per managed service that is - 18
/// failing. A service that keeps exiting with an error used to leave every - 19
/// check passing, because nothing read how its process last ended. - 20
fn collect(core: &Core) -> health::HealthReport { - 21
let mut report = health::collect(core, None); - 22
for (name, status) in crate::install::failing_services() { - 23
report.checks.push(health::HealthCheck { - 24
label: format!("service {name}"), - 25
detail: Err(format!( - 26
"last exited with status {status}; its log is in {}", - 27
vak_config::paths::logs_dir().display() - 28
)), - 29
}); - 30
report.failures += 1; - 31
} - 32
report - 33
} - 34
- 35
pub fn run_doctor(cwd: PathBuf, trusted: bool, repair: bool) -> i32 { - 36
let core = match Core::new_with_trust(cwd.clone(), trusted) { - 37
Ok(c) => c, - 38
Err(e) => { - 39
eprintln!("error: {e}"); - 40
return 2; - 41
} - 42
}; - 43
crate::print_config_warnings(&core); - 44
- 45
let mut report = collect(&core); - 46
- 47
if repair { - 48
let repaired = repair_known_failures(&core, &report); - 49
if !repaired.is_empty() { - 50
for line in &repaired { - 51
println!(" ↻ {line}"); - 52
} - 53
println!(); - 54
report = collect(&core); - 55
} - 56
} - 57
- 58
println!("doctor:"); - 59
// Passing checks first so failures read as one block at the bottom. - 60
let mut ordered: Vec<&health::HealthCheck> = report.checks.iter().collect(); - 61
ordered.sort_by_key(|c| c.detail.is_err()); - 62
let width = ordered.iter().map(|c| c.label.len()).max().unwrap_or(0); - 63
for check in &ordered { - 64
match &check.detail { - 65
Ok(detail) => { - 66
println!(" ✓ {:<width$} {}", check.label, detail, width = width) - 67
} - 68
Err(detail) => { - 69
println!(" ✗ {:<width$} {}", check.label, detail, width = width) - 70
} - 71
} - 72
} - 73
- 74
for fact in &report.facts { - 75
println!(" · {fact}"); - 76
} - 77
if let Some(ladder) = &report.ladder { - 78
println!( - 79
" · route ladder (frozen at admission): {}", - 80
if ladder.rendered.is_empty() { - 81
"(none recorded)" - 82
} else { - 83
&ladder.rendered - 84
} - 85
); - 86
if !ladder.objective.is_empty() || ladder.fallback_legs > 0 { - 87
println!( - 88
" · route objective: {} · fallback legs: {}", - 89
ladder.objective, ladder.fallback_legs - 90
); - 91
} - 92
for note in &ladder.annotations { - 93
println!(" · route: {note}"); - 94
} - 95
} - 96
if report.failures == 0 { - 97
println!("all checks passed"); - 98
0 - 99
} else { - 100
println!("{} check(s) failed", report.failures); - 101
if !repair { - 102
println!("run `vak doctor --repair` to act on the ones with a known fix"); - 103
} - 104
1 - 105
} - 106
} - 107
- 108
/// Act on the checks in `report` that have a mechanical fix. Returns one - 109
/// human-readable line per repair attempted, success or failure — never - 110
/// silent. Checks with no known fix (provider auth, config warnings) are - 111
/// left untouched. - 112
pub(crate) fn repair_known_failures(core: &Core, report: &health::HealthReport) -> Vec<String> { - 113
let mut lines = Vec::new(); - 114
for check in &report.checks { - 115
let Err(detail) = &check.detail else { - 116
continue; - 117
}; - 118
// docs/design/34: an expired pending channel request auto-denies — - 119
// mechanical, no judgment call. An `allowed` entry with an - 120
// unreachable workspace is left alone on purpose: re-pointing it is - 121
// an operator decision (`PATCH .../allowlist/{key}` or the Admin - 122
// UI), not something doctor may guess at. - 123
if check.label == health::GATEWAY_CHANNELS_LABEL { - 124
let days = core.config().gateway.pending_expiry_days; - 125
let denied = health::expire_pending_entries(&core.shared_data_home(), days); - 126
lines.push(if denied.is_empty() { - 127
format!( - 128
"gateway channels ({detail}): nothing mechanically repairable \ - 129
— re-point unreachable workspaces from the Admin UI" - 130
) - 131
} else { - 132
format!( - 133
"gateway channels: auto-denied {} pending request(s) older than {days}d \ - 134
(added_by=expiry, still visible): {}", - 135
denied.len(), - 136
denied.join(", ") - 137
) - 138
}); - 139
continue; - 140
} - 141
if check.label == "self version parity" { - 142
lines.push(format!("self version parity ({detail}): reinstalling…")); - 143
// Reinstalling from a unit test would rewrite the developer's - 144
// own installed binary, so the call itself is compiled out - 145
// there; the gateway-channel repair above is what the tests - 146
// exercise. - 147
#[cfg(not(test))] - 148
{ - 149
let code = crate::install::run_install(None, true); - 150
lines.push(if code == 0 { - 151
"self version parity: reinstalled".to_string() - 152
} else { - 153
format!("self version parity: reinstall failed (exit {code})") - 154
}); - 155
} - 156
} - 157
if check.label == "retired plugins" { - 158
lines.push(format!("retired plugins ({detail}): running cleanup…")); - 159
match vak_core::seed::seed_shared_capabilities() { - 160
Ok(()) => lines.push( - 161
"retired plugins: cleanup pass complete (run `vak doctor` to verify)" - 162
.to_string(), - 163
), - 164
Err(error) => lines.push(format!("retired plugins: cleanup failed ({error})")), - 165
} - 166
} - 167
} - 168
lines - 169
} - 170
- 171
#[cfg(test)] - 172
#[allow(clippy::unwrap_used, clippy::expect_used)] - 173
mod tests { - 174
use super::*; - 175
- 176
fn core_with_channels(entries: serde_json::Value) -> (tempfile::TempDir, Core) { - 177
let dir = tempfile::tempdir().unwrap(); - 178
let core = Core::new(dir.path().to_path_buf()).unwrap(); - 179
core.set_sessions_home(dir.path().join("home")); - 180
let path = health::allowlist_path(&core.shared_data_home()); - 181
std::fs::create_dir_all(path.parent().unwrap()).unwrap(); - 182
std::fs::write( - 183
path, - 184
serde_json::json!({ "schema": 1, "entries": entries }).to_string(), - 185
) - 186
.unwrap(); - 187
(dir, core) - 188
} - 189
- 190
fn ago(days: i64) -> String { - 191
(chrono::Utc::now() - chrono::Duration::days(days)).to_rfc3339() - 192
} - 193
- 194
#[test] - 195
fn repair_acts_on_an_expired_pending_channel_and_says_what_it_did() { - 196
let (_dir, core) = core_with_channels(serde_json::json!([ - 197
{ "key": "telegram:9", "status": "pending", "added_at": ago(30), "added_by": "gateway" }, - 198
])); - 199
let report = health::collect(&core, None); - 200
let lines = repair_known_failures(&core, &report); - 201
let line = lines - 202
.iter() - 203
.find(|l| l.starts_with("gateway channels")) - 204
.expect("the expired pending entry must be reported as repaired"); - 205
assert!(line.contains("telegram:9"), "{line}"); - 206
assert!(line.contains("added_by=expiry"), "{line}"); - 207
// Re-collecting proves the repair actually landed in the store. - 208
assert!( - 209
health::collect(&core, None) - 210
.checks - 211
.iter() - 212
.find(|c| c.label == health::GATEWAY_CHANNELS_LABEL) - 213
.unwrap() - 214
.detail - 215
.is_ok() - 216
); - 217
} - 218
- 219
#[test] - 220
fn repair_defers_an_unreachable_workspace_to_the_operator() { - 221
let (_dir, core) = core_with_channels(serde_json::json!([ - 222
{ "key": "telegram:9", "status": "allowed", "workspace": "/definitely/not/here", - 223
"added_at": ago(1), "added_by": "admin" }, - 224
])); - 225
let report = health::collect(&core, None); - 226
let lines = repair_known_failures(&core, &report); - 227
let line = lines - 228
.iter() - 229
.find(|l| l.starts_with("gateway channels")) - 230
.expect("doctor must still say something, not repair silently"); - 231
assert!(line.contains("nothing mechanically repairable"), "{line}"); - 232
// And the failure survives: re-pointing is a judgment call. - 233
assert!( - 234
health::collect(&core, None) - 235
.checks - 236
.iter() - 237
.find(|c| c.label == health::GATEWAY_CHANNELS_LABEL) - 238
.unwrap() - 239
.detail - 240
.is_err() - 241
); - 242
} - 243
- 244
#[test] - 245
fn healthy_channels_are_not_touched_by_repair() { - 246
let (_dir, core) = core_with_channels(serde_json::json!([ - 247
{ "key": "telegram:9", "status": "pending", "added_at": ago(1), "added_by": "gateway" }, - 248
])); - 249
let report = health::collect(&core, None); - 250
assert!( - 251
!repair_known_failures(&core, &report) - 252
.iter() - 253
.any(|l| l.starts_with("gateway channels")), - 254
"repair must act only on failing checks" - 255
); - 256
} - 257
} - 258
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.