1use std::io::Write;
4use std::path::{Path, PathBuf};
5use std::time::{Duration, SystemTime, UNIX_EPOCH};
6
7use anyhow::Result;
8use serde::Serialize;
9
10use crate::controller::{
11 WorkerBinaryAvailability, ssh_worker_binary_prerequisite, worker_binary_prerequisite_for_arch,
12};
13use crate::setup::{
14 DiscoveredHome, discover_harness_homes_with_executor, harness_is_authenticated_with_executor,
15};
16use crate::targets::{
17 BoundedProcessExecutor, CommandExecutor, CommandSpec, CommandTimedOut,
18 ContainerTemplate as RuntimeContainerTemplate, DockerUnavailable, PODMAN_DOCUMENTATION_URL,
19 PodmanPostcondition, ProcessExecutor, SshTarget as RuntimeSshTarget,
20 TargetTemplate as RuntimeTargetTemplate, failed_podman_postcondition, local_docker_vm_share,
21 podman_probe_observation, run_setup_smoke_test, ssh_command, ssh_connectivity_probe,
22 ssh_validation_command, verify_local_docker, verify_local_podman, verify_ssh_docker,
23 verify_ssh_podman,
24};
25use mj_core::config::{
26 Config, ContainerTemplate, HarnessKind, HarnessProfile, TargetTemplate, config_path,
27};
28use mj_core::credentials::login_command;
29
30const DEFAULT_CONTAINER_IMAGE: &str = "ubuntu:24.04";
36const APPLE_CONTAINER_INSTALL_URL: &str = "https://github.com/apple/container#initial-install";
37
38pub const PROBE_TIMEOUT: Duration = Duration::from_secs(15);
45
46pub const fn probe_executor() -> BoundedProcessExecutor {
49 BoundedProcessExecutor::new(PROBE_TIMEOUT)
50}
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
53#[serde(rename_all = "lowercase")]
54pub enum CheckStatus {
55 Ready,
56 Warning,
57 Fixable,
58 Unsupported,
59}
60
61impl CheckStatus {
62 pub const fn label(self) -> &'static str {
63 match self {
64 Self::Ready => "ready",
65 Self::Warning => "warning",
66 Self::Fixable => "fixable",
67 Self::Unsupported => "unsupported",
68 }
69 }
70}
71
72#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
73pub struct DoctorCheck {
74 pub id: String,
75 pub title: String,
76 pub status: CheckStatus,
77 pub detail: String,
78 pub remediation: Option<String>,
79}
80
81impl DoctorCheck {
82 fn ready(id: impl Into<String>, title: impl Into<String>, detail: impl Into<String>) -> Self {
83 Self {
84 id: id.into(),
85 title: title.into(),
86 status: CheckStatus::Ready,
87 detail: detail.into(),
88 remediation: None,
89 }
90 }
91
92 fn warning(
93 id: impl Into<String>,
94 title: impl Into<String>,
95 detail: impl Into<String>,
96 remediation: impl Into<String>,
97 ) -> Self {
98 Self {
99 id: id.into(),
100 title: title.into(),
101 status: CheckStatus::Warning,
102 detail: detail.into(),
103 remediation: Some(remediation.into()),
104 }
105 }
106
107 pub(crate) fn fixable(
108 id: impl Into<String>,
109 title: impl Into<String>,
110 detail: impl Into<String>,
111 remediation: impl Into<String>,
112 ) -> Self {
113 Self {
114 id: id.into(),
115 title: title.into(),
116 status: CheckStatus::Fixable,
117 detail: detail.into(),
118 remediation: Some(remediation.into()),
119 }
120 }
121
122 fn unsupported(
123 id: impl Into<String>,
124 title: impl Into<String>,
125 detail: impl Into<String>,
126 ) -> Self {
127 Self {
128 id: id.into(),
129 title: title.into(),
130 status: CheckStatus::Unsupported,
131 detail: detail.into(),
132 remediation: None,
133 }
134 }
135}
136
137#[derive(Debug, Clone, Copy, PartialEq, Eq)]
138pub struct DoctorOptions {
139 pub smoke: bool,
140}
141
142#[derive(Debug, Clone, PartialEq, Eq)]
143pub enum ApplePlatform {
144 Linux,
145 Macos {
146 architecture: String,
147 major_version: u32,
148 },
149 Other(String),
150}
151
152#[derive(Debug, Clone, Copy, PartialEq, Eq)]
153pub enum InstructionsPlatform {
154 Linux,
155 Macos,
156}
157
158pub fn run_current(options: DoctorOptions) -> Vec<DoctorCheck> {
159 if options.smoke {
160 return run_with(
164 &ProcessExecutor,
165 current_apple_platform(&ProcessExecutor),
166 options,
167 );
168 }
169 let executor = probe_executor();
170 run_with(&executor, current_apple_platform(&executor), options)
171}
172
173pub fn run_with(
174 executor: &impl CommandExecutor,
175 apple_platform: ApplePlatform,
176 options: DoctorOptions,
177) -> Vec<DoctorCheck> {
178 run_with_config_path(&config_path(), executor, apple_platform, options)
179}
180
181pub fn run_with_config_path(
187 config_path: &Path,
188 executor: &impl CommandExecutor,
189 apple_platform: ApplePlatform,
190 options: DoctorOptions,
191) -> Vec<DoctorCheck> {
192 let (loaded, mut checks) = configuration_checks(config_path);
193 let config: ConfigStatus<'_> = loaded.as_ref().map_err(|gap| *gap);
194 checks.push(harness_discovery_check(config, executor));
195 checks.extend(harness_checks(config, executor));
196 checks.extend(subagent_eligibility_checks(config));
197 checks.extend(secret_checks(config, config_path));
198 let podman = podman_checks(config, executor, options.smoke, &apple_platform);
199 let docker = docker_checks(config, executor, options.smoke);
200 let apple_container = apple_container_check(
201 &apple_platform,
202 executor,
203 options.smoke,
204 apple_container_image(config),
205 );
206 let offered = config.map(|config| {
207 offered_targets(
208 config,
209 podman.iter().chain(&docker).chain([&apple_container]),
210 )
211 });
212 let offered = offered.as_ref().map_err(|gap| *gap);
213 checks.extend(podman);
214 checks.extend(docker);
215 checks.extend(ssh_bare_checks(config, executor));
216 checks.extend(ssh_podman_checks(config, executor, options.smoke));
217 checks.extend(ssh_docker_checks(config, executor, options.smoke));
218 checks.extend(storage_checks(config, executor));
219 checks.extend(build_cache_checks(offered, executor));
220 checks.extend(build_cache_release_check(
221 &crate::controller::recent_release_failures(),
222 ));
223 checks.extend(aws_checks(config, executor));
224 checks.extend(worker_binary_checks(offered));
225 checks.extend(ssh_bare_worker_checks(config, executor));
226 checks.push(daemon_build_check());
227 checks.extend(worker_freshness_checks(offered));
228 checks.extend(review_residue_checks(config));
229 checks.push(apple_container);
231 checks
232}
233
234fn build_cache_checks(
235 config: ConfigStatus<'_>,
236 executor: &impl CommandExecutor,
237) -> Vec<DoctorCheck> {
238 let Ok(config) = config else {
239 return Vec::new();
240 };
241 crate::controller::doctor_host_mbx(config, executor)
242 .into_iter()
243 .map(|host| {
244 let id = format!("build-cache.{}", host.host);
245 let title = format!("Build cache on {}", host.host);
246 let targets = host.targets.join(", ");
247 match host.status {
248 crate::controller::DoctorHostMbxStatus::Unsupported(reason) =>
249 DoctorCheck::unsupported(id, title, reason),
250 crate::controller::DoctorHostMbxStatus::Absent => DoctorCheck::warning(
251 id,
252 title,
253 format!(
254 "No native mbx is installed; targets {targets} run without the shared build cache."
255 ),
256 format!(
257 "Install mbx on {} from Settings › Setup › Machines to enable the shared build cache.",
258 host.host
259 ),
260 ),
261 crate::controller::DoctorHostMbxStatus::Compatible(version) => DoctorCheck::ready(
262 id,
263 title,
264 format!(
265 "Host mbx {version} is compatible with Mjolnir's mbx {} for targets {targets}.",
266 crate::controller::MBX_VERSION
267 ),
268 ),
269 crate::controller::DoctorHostMbxStatus::TooOld(version) => DoctorCheck::warning(
270 id,
271 title,
272 format!(
273 "Host mbx {version} is older than the minimum supported version {}; sessions on targets {targets} run without the shared build cache.",
274 crate::controller::MBX_VERSION
275 ),
276 format!(
277 "Upgrade mbx on {} to {} or newer from Settings › Setup › Machines, then rerun `mj doctor`.",
278 host.host,
279 crate::controller::MBX_VERSION
280 ),
281 ),
282 crate::controller::DoctorHostMbxStatus::Unknown(error) => DoctorCheck::warning(
283 id,
284 title,
285 format!("Could not check host mbx for targets {targets}: {error}"),
286 format!(
287 "Check access to {} and install or upgrade mbx from Settings › Setup › Machines, then rerun `mj doctor`.",
288 host.host
289 ),
290 ),
291 }
292 })
293 .collect()
294}
295
296fn build_cache_release_check(
299 failures: &[crate::controller::ReleaseFailure],
300) -> Option<DoctorCheck> {
301 let latest = failures.last()?;
302 let days = crate::controller::FAILURE_REPORT_SECS / (24 * 60 * 60);
303 let count = failures.len();
304 let releases = if count == 1 { "release" } else { "releases" };
305 Some(DoctorCheck::warning(
306 "build-cache.release",
307 "Build output of removed sessions",
308 format!(
309 "{count} build cache {releases} failed in the last {days} days, so mbx still holds \
310 build output for workspaces Mjolnir removed. Latest, on {}: {}",
311 latest.host, latest.error
312 ),
313 format!(
314 "On {}, run: {}. mbx also removes this output once it reaches its target.max_age.",
315 latest.host, latest.remediation
316 ),
317 ))
318}
319
320fn harness_discovery_check(
321 config: ConfigStatus<'_>,
322 executor: &impl CommandExecutor,
323) -> DoctorCheck {
324 let home = dirs::home_dir();
325 let overrides = HarnessKind::ALL.into_iter().filter_map(|kind| {
326 std::env::var_os(kind.home_env()).map(|path| (kind, kind.home_from_environment(path)))
327 });
328 let discovered = discover_harness_homes_with_executor(home.as_deref(), overrides, executor);
329 harness_discovery_check_from(
330 &discovered,
331 config.is_ok_and(|config| !config.profiles.is_empty()),
332 &settings_key(config.ok()),
333 )
334}
335
336fn harness_discovery_check_from(
337 discovered: &[DiscoveredHome],
338 has_configured_profiles: bool,
339 settings_key: &str,
340) -> DoctorCheck {
341 if discovered.is_empty() {
342 return if has_configured_profiles {
343 DoctorCheck::ready(
344 "harness.discovery",
345 "Harness home discovery",
346 "No default or environment-overridden harness homes were found; configured profile homes are checked below.",
347 )
348 } else {
349 DoctorCheck::fixable(
350 "harness.discovery",
351 "Harness home discovery",
352 format!(
353 "No {} home was found in the default or environment-overridden locations.",
354 HarnessKind::every_display_name_or()
355 ),
356 format!(
357 "Install and sign in to a supported harness, then open Mjolnir, press {settings_key} for Settings, and choose Agent Profiles."
358 ),
359 )
360 };
361 }
362
363 let homes = discovered
364 .iter()
365 .map(|home| {
366 let authentication = if home.authenticated {
367 "authenticated"
368 } else {
369 "not authenticated"
370 };
371 format!(
372 "{} at {} ({authentication})",
373 home.kind.display_name(),
374 home.path.display()
375 )
376 })
377 .collect::<Vec<_>>()
378 .join("; ");
379 DoctorCheck::ready(
380 "harness.discovery",
381 "Harness home discovery",
382 format!("Discovered {homes}. Configured profile authentication is checked below."),
383 )
384}
385
386fn settings_key(config: Option<&Config>) -> String {
389 let keybinds = config.map_or_else(mj_core::config::Keybinds::default, Config::keybinds);
390 keybinds
391 .labels(mj_core::config::KeyAction::OpenSettings)
392 .into_iter()
393 .next()
394 .unwrap_or_else(|| "the Settings command in the command palette".to_owned())
395}
396
397pub fn all_ready(checks: &[DoctorCheck]) -> bool {
398 checks
399 .iter()
400 .all(|check| check.status != CheckStatus::Fixable)
401}
402
403pub fn render_human(checks: &[DoctorCheck], output: &mut impl Write) -> Result<()> {
404 for check in checks {
405 writeln!(
406 output,
407 "{} {}: {}",
408 check.status.label(),
409 check.title,
410 check.detail
411 )?;
412 if let Some(remediation) = &check.remediation {
413 writeln!(output, " remediation: {remediation}")?;
414 }
415 }
416 Ok(())
417}
418
419pub fn setup_instructions(platform: InstructionsPlatform) -> String {
420 match platform {
421 InstructionsPlatform::Linux => format!(
422 "# Mjolnir setup instructions for Linux\n\n\
423This page is self-contained. Follow this exact loop as the user who will run `mj`:\n\n\
4241. Run `mj doctor --json`.\n\
4252. Follow every `fixable` remediation from its JSON output.\n\
4263. Run `mj doctor --json` again. Repeat until no check is `fixable`.\n\
4274. Finish with `mj doctor --json --smoke` to verify every configured container\n\
428 image end to end, and resolve anything it reports as `fixable`.\n\n\
429For a coding-agent handoff, provide this entire instructions page together with\n\
430the latest `mj doctor --json` output.\n\n\
431## Local bare runtime\n\n\
432A local bare runtime runs the agent directly on this machine. It needs the\n\
433native `mj-worker` installed beside `mj`; the release installer and the npm\n\
434package include it. The worker installs the pinned harness version itself.\n\
435Codex and Claude need Node.js 22 or newer and npm on `PATH`. Kimi and Grok\n\
436need curl and Bash. Muse needs curl and tar. Mjolnir does not install these\n\
437prerequisites.\n\n\
438## Linux container-runtime postconditions\n\n{}\n\n{}",
439 crate::targets::PODMAN_DOCUMENTATION,
440 crate::targets::DOCKER_DOCUMENTATION
441 ),
442 InstructionsPlatform::Macos => format!(
443 "# Mjolnir setup instructions for macOS\n\n\
444Run these commands as the user who will run `mj`:\n\n\
4451. Install and sign in to a supported coding agent, then run `mj setup`.\n\
446 Setup discovers agent homes and local runtimes, offers AWS when the AWS CLI\n\
447 has credentials, and offers SSH hosts from `~/.ssh/config`. Review its\n\
448 proposed configuration and confirm writing `config.toml`. It runs smoke\n\
449 tests for newly added container targets and ends with a doctor report.\n\
4502. Run `mj doctor --json` and follow every `fixable` remediation. Repeat until\n\
451 no check is `fixable`. Review warnings for any target you intend to use;\n\
452 `unsupported` runtimes are unavailable, not ready.\n\
4533. If you intend to use containers, start the runtime and run\n\
454 `mj doctor --json --smoke`. Do this for Docker Desktop as well as Apple\n\
455 container. Resolve failures for the target you intend to use.\n\
4564. Run `mj`, then press n in the Sessions pane to start your first session.\n\
457 If no profile was discovered, open Settings (Ctrl-B then s by default)\n\
458 to add one first.\n\n\
459You can also configure profiles and targets in Settings instead of `mj setup`.\n\
460For a named instance, use the same `--instance <name>` on every command above.\n\n\
461For a coding-agent handoff, provide this entire instructions page together with\n\
462the latest `mj doctor --json` output.\n\n\
463## Local bare runtime\n\n\
464A local bare runtime runs the agent directly on this machine. It needs the\n\
465native `mj-worker` installed beside `mj`; the release installer, npm package,\n\
466and Homebrew formula include it (Homebrew keeps both binaries in libexec).\n\
467The worker installs the pinned harness version itself. Codex and Claude need\n\
468Node.js 22 or newer and npm on `PATH`. Kimi and Grok need curl and Bash.\n\
469Muse needs curl and tar. Mjolnir does not install these prerequisites.\n\n\
470## Docker Desktop\n\n\
471Start Docker Desktop and wait for its Linux daemon to be ready before setup\n\
472or the smoke test. `mj doctor --json --smoke` checks the container image and\n\
473a host directory attachment, including disposable container cleanup. Docker\n\
474Desktop's VM cannot overlay host directories, so attachments mount read-only\n\
475there. A connected daemon alone does not establish that these operations work.\n\n\
476## Apple container runtime\n\n\
477Mjolnir's Apple container target requires Apple silicon and macOS 26 or newer.\n\
478On an Intel Mac or an older macOS release, the target is unsupported; use the\n\
479local bare runtime, an SSH target, or an AWS target instead.\n\n\
480On a supported Mac, if `container` is absent, install only the official signed package:\n\n\
481<https://github.com/apple/container#initial-install>\n\n\
482Mjolnir never downloads or installs that package. If doctor reports a stopped\n\
483daemon, run exactly:\n\n```console\ncontainer system start\n```\n\n\
484Finish with the opt-in disposable runtime test in JSON mode:\n\n```console\nmj doctor --json --smoke\n```\n\n\
485Apple container is ready only when that smoke test creates a disposable\n\
486container, executes `true` in it, and removes it successfully. Use the image\n\
487configured by an `apple-container` target; without one, doctor uses\n\
488`{DEFAULT_CONTAINER_IMAGE}` for the smoke test.\n\n\
489## Shared Mjolnir prerequisites\n\n\
490`mj doctor --json` also checks the configuration, each configured harness home\n\
491and authentication marker, selected container worker binaries, and any relevant\n\
492Podman prerequisites. Resolve every `fixable` status before starting a session.\n"
493 ),
494 }
495}
496
497#[derive(Debug, Clone, Copy, PartialEq, Eq)]
505enum ConfigGap {
506 NewerVersion(u32),
508 Missing,
511 Unreadable,
513}
514
515impl ConfigGap {
516 fn awaited(self) -> &'static str {
519 match self {
520 ConfigGap::NewerVersion(_) => "this Mjolnir can read config.toml",
521 ConfigGap::Missing => "config.toml exists",
522 ConfigGap::Unreadable => "config.toml is valid",
523 }
524 }
525
526 fn remediation(self) -> &'static str {
532 match self {
533 ConfigGap::NewerVersion(_) => {
534 "Update Mjolnir to the build that wrote config.toml or newer, then rerun `mj doctor`."
535 }
536 ConfigGap::Missing => {
537 "Run `mj` and add an agent profile, or run `mj setup`; either one writes config.toml. Then rerun `mj doctor`."
538 }
539 ConfigGap::Unreadable => "Fix config.toml, then rerun `mj doctor`.",
540 }
541 }
542}
543
544type ConfigStatus<'a> = std::result::Result<&'a Config, ConfigGap>;
547
548fn newer_config_skip(id: &str, title: &str, version: u32) -> DoctorCheck {
553 DoctorCheck::unsupported(
554 id,
555 title,
556 format!(
557 "Skipped: config.toml was written by a newer Mjolnir (config version {version}; this build supports {}). Update Mjolnir to that build or newer.",
558 mj_core::config::CONFIG_VERSION
559 ),
560 )
561}
562
563fn configuration_checks(path: &Path) -> (std::result::Result<Config, ConfigGap>, Vec<DoctorCheck>) {
564 if !path.exists() {
565 return (
566 Err(ConfigGap::Missing),
567 vec![DoctorCheck::fixable(
568 "config",
569 "Mjolnir configuration",
570 format!("{} does not exist", path.display()),
571 format!(
572 "Open Mjolnir and press {} for Settings to add an agent profile.",
573 settings_key(None)
574 ),
575 )],
576 );
577 }
578 if let Some(found) = mj_core::config::newer_version_on_disk(path) {
582 return (
583 Err(ConfigGap::NewerVersion(found)),
584 vec![DoctorCheck::fixable(
585 "config",
586 "Mjolnir configuration",
587 format!(
588 "{} was written by a newer Mjolnir (config version {found}; this build supports {})",
589 path.display(),
590 mj_core::config::CONFIG_VERSION
591 ),
592 "Update Mjolnir to that build or newer. Do not lower the version value by hand or replace the file.",
593 )],
594 );
595 }
596 match Config::load_from(path) {
597 Ok(config) => {
598 let mut checks = vec![DoctorCheck::ready(
599 "config",
600 "Mjolnir configuration",
601 format!("{} is valid", path.display()),
602 )];
603 if config.enabled_profiles().next().is_none() {
607 checks.push(DoctorCheck::fixable(
608 "config.session-prerequisites",
609 "Session configuration",
610 "No agent profile is enabled, so no session can start. Local targets are supplied automatically.",
611 format!(
612 "Open Mjolnir and press {} for Settings to add or enable an agent profile.",
613 settings_key(Some(&config))
614 ),
615 ));
616 } else if config.bundles.is_empty() {
617 checks.push(DoctorCheck::ready(
618 "config.session-prerequisites",
619 "Session configuration",
620 "An agent profile is enabled. No project bundle is configured; sessions start from a project directory, and a bundle is only needed to start from a saved set of repositories.",
621 ));
622 } else {
623 checks.push(DoctorCheck::ready(
624 "config.session-prerequisites",
625 "Session configuration",
626 "At least one profile, bundle, and target are configured.",
627 ));
628 }
629 (Ok(config), checks)
630 }
631 Err(error) => (
632 Err(ConfigGap::Unreadable),
633 vec![DoctorCheck::fixable(
634 "config",
635 "Mjolnir configuration",
636 format!("{} is invalid: {error:#}", path.display()),
637 "Fix the reported TOML error in config.toml, or run `mj setup` to replace it.",
638 )],
639 ),
640 }
641}
642
643fn harness_checks(config: ConfigStatus<'_>, executor: &impl CommandExecutor) -> Vec<DoctorCheck> {
644 let config = match config {
645 Ok(config) => config,
646 Err(ConfigGap::NewerVersion(version)) => {
647 return vec![newer_config_skip(
648 "harness.profiles",
649 "Harness profiles",
650 version,
651 )];
652 }
653 Err(gap @ (ConfigGap::Missing | ConfigGap::Unreadable)) => {
654 return vec![DoctorCheck::fixable(
655 "harness.profiles",
656 "Harness profiles",
657 format!("Harness homes cannot be checked until {}.", gap.awaited()),
658 gap.remediation(),
659 )];
660 }
661 };
662 if config.profiles.is_empty() {
663 return vec![DoctorCheck::fixable(
664 "harness.profiles",
665 "Harness profiles",
666 "No harness profiles are configured.",
667 format!(
668 "Open Mjolnir, press {} for Settings, and choose Agent Profiles to detect accounts or add a profile.",
669 settings_key(Some(config))
670 ),
671 )];
672 }
673 config
674 .profiles
675 .iter()
676 .map(|(id, profile)| {
677 let mut check = harness_profile_check(id, profile, executor);
678 check.detail = format!("{} {}", profile_summary(config, id, profile), check.detail);
679 check
680 })
681 .collect()
682}
683
684fn harness_profile_check(
686 id: &str,
687 profile: &HarnessProfile,
688 executor: &impl CommandExecutor,
689) -> DoctorCheck {
690 let title = format!("Harness profile {id}");
691 if !profile.enabled {
692 return DoctorCheck::ready(
693 format!("harness.{id}"),
694 title,
695 "Profile is disabled; home and authentication checks were skipped.",
696 );
697 }
698 if !profile.home.is_dir() {
699 return DoctorCheck::fixable(
700 format!("harness.{id}"),
701 title,
702 format!("{} does not exist", profile.home.display()),
703 format!(
704 "{} If this profile should use an existing installation, select its home in Setup.",
705 harness_login_remediation(id, profile)
706 ),
707 );
708 }
709 if let Err(error) = profile.ensure_ready(id) {
710 return DoctorCheck::fixable(
711 format!("harness.{id}"),
712 title,
713 format!("{error:#}"),
714 "Make the change named above. Sessions on this profile are refused until then; the rest of Mjolnir keeps working.",
715 );
716 }
717 if !harness_is_authenticated_with_executor(profile, executor) {
718 return DoctorCheck::fixable(
719 format!("harness.{id}"),
720 title,
721 format!(
722 "No usable authentication was detected for {}",
723 profile.home.display()
724 ),
725 harness_login_remediation(id, profile),
726 );
727 }
728 DoctorCheck::ready(
729 format!("harness.{id}"),
730 title,
731 format!(
732 "{} is present and authentication is available",
733 profile.home.display()
734 ),
735 )
736}
737
738fn profile_summary(config: &Config, id: &str, profile: &HarnessProfile) -> String {
743 let delegation = if config
744 .subagents
745 .eligible_profiles
746 .get(id)
747 .copied()
748 .unwrap_or(false)
749 {
750 "any session's sub-agents may use it"
751 } else {
752 "only its own sessions' sub-agents may use it"
753 };
754 format!(
755 "{}; {}; {delegation}.",
756 profile.kind.display_name(),
757 profile_quota_source(profile)
758 )
759}
760
761fn profile_quota_source(profile: &HarnessProfile) -> String {
765 match profile.kind {
766 HarnessKind::Claude => "Claude subscription quota".to_owned(),
767 HarnessKind::Codex => match crate::quota::provider_credential(profile) {
768 Some(provider) if crate::zai_usage::serves_quota(&provider.host) => {
769 format!("quota from {}", provider.host)
770 }
771 Some(provider) => format!(
772 "pay-per-use through {}, counted as 100% left when choosing a sub-agent's profile",
773 provider.host
774 ),
775 None => match profile.codex_provider() {
776 Ok(None) => "ChatGPT subscription quota".to_owned(),
777 Ok(Some(provider)) => format!(
778 "no quota report, because custom provider {:?} has no API key in this profile's environment",
779 provider.id
780 ),
781 Err(error) => format!("its Codex config.toml could not be read ({error:#})"),
782 },
783 },
784 kind => format!("quota as {} reports it", kind.display_name()),
785 }
786}
787
788fn secret_checks(config: ConfigStatus<'_>, config_path: &Path) -> Vec<DoctorCheck> {
796 use mj_core::config::{secrets_path_beside, secrets_permission_problem};
797
798 let mut checks = Vec::new();
799 let secrets = secrets_path_beside(config_path);
800 if secrets.exists() {
801 checks.push(match secrets_permission_problem(&secrets) {
802 Ok(None) => DoctorCheck::ready(
803 "secrets.file",
804 "Secrets file",
805 format!("{} is readable only by its owner", secrets.display()),
806 ),
807 Ok(Some(problem)) => DoctorCheck::warning(
808 "secrets.file",
809 "Secrets file",
810 problem,
811 format!("Run `chmod 600 {}`.", secrets.display()),
812 ),
813 Err(error) => DoctorCheck::warning(
814 "secrets.file",
815 "Secrets file",
816 format!("{error:#}"),
817 "Make the file readable by the user running Mjolnir.",
818 ),
819 });
820 }
821 let Ok(config) = config else {
822 return checks;
823 };
824 for (id, target) in &config.targets {
827 if let Err(error) = target.ensure_ready(id) {
828 checks.push(DoctorCheck::fixable(
829 format!("targets.{id}.environment"),
830 format!("Target {id} environment"),
831 format!("{error:#}"),
832 "Make the change named above. Sessions on this target are refused until then.",
833 ));
834 }
835 }
836 let profiles = config
837 .profiles
838 .iter()
839 .map(|(id, profile)| (format!("profiles.{id}"), &profile.environment));
840 let targets = config.targets.iter().filter_map(|(id, target)| {
841 target
842 .container()
843 .map(|container| (format!("targets.{id}"), &container.environment))
844 });
845 for (owner, environment) in profiles.chain(targets) {
846 if let Some(check) = plain_text_credential_check(&owner, environment, &secrets) {
847 checks.push(check);
848 }
849 }
850 checks
851}
852
853fn plain_text_credential_check(
856 owner: &str,
857 environment: &mj_core::config::Environment,
858 secrets: &Path,
859) -> Option<DoctorCheck> {
860 let names = environment
861 .sources()
862 .iter()
863 .filter(|(name, value)| {
864 !value.is_reference() && mj_core::config::looks_like_credential(name)
865 })
866 .map(|(name, _)| name.as_str())
867 .collect::<Vec<_>>();
868 if names.is_empty() {
869 return None;
870 }
871 let example = names[0];
872 Some(DoctorCheck::warning(
873 format!("{owner}.secrets"),
874 "Credentials in config.toml",
875 format!(
876 "[{owner}.environment] holds {} as plain text; config.toml is copied into isolated instances and read as ordinary configuration",
877 names.join(", ")
878 ),
879 format!(
880 "Move each value into {} as `{example} = \"...\"` (mode 600) and refer to it as `{example} = {{ from_secret = \"{example}\" }}`, or use `{{ from_env = \"{example}\" }}` to read the daemon's environment.",
881 secrets.display()
882 ),
883 ))
884}
885
886fn subagent_eligibility_checks(config: ConfigStatus<'_>) -> Vec<DoctorCheck> {
896 let Ok(config) = config else {
897 return Vec::new();
898 };
899 let mut checks = vec![subagent_policy_check(config)];
900 checks.extend(
901 config
902 .subagents
903 .eligible_profiles
904 .iter()
905 .filter(|(_, eligible)| **eligible)
906 .filter_map(|(id, _)| match config.profiles.get(id) {
907 Some(profile) if !profile.enabled => Some(DoctorCheck::warning(
908 format!("subagents.{id}"),
909 format!("Sub-agent profile {id}"),
910 format!(
911 "Profile {id:?} is listed in [subagents.eligible_profiles] but is disabled, so it is not offered for sub-agent use."
912 ),
913 format!(
914 "Re-enable profile {id:?}, or remove it from [subagents.eligible_profiles]."
915 ),
916 )),
917 _ => None,
918 }),
919 );
920 checks
921}
922
923fn subagent_policy_check(config: &Config) -> DoctorCheck {
927 let subagents = &config.subagents;
928 let eligible = subagents
929 .eligible_profiles
930 .iter()
931 .filter(|(_, eligible)| **eligible)
932 .map(|(id, _)| id.as_str())
933 .collect::<Vec<_>>();
934 let others = if eligible.is_empty() {
935 "no other profile".to_owned()
936 } else {
937 eligible.join(", ")
938 };
939 let detail = format!(
940 "Claude and Codex sessions may opt in, up to {} sub-agents at once per session. A session's sub-agents may use its own profile and: {others}.",
941 subagents.max_concurrent
942 );
943 DoctorCheck::ready("subagents.policy", "Sub-agent policy", detail)
944}
945
946fn harness_login_remediation(id: &str, profile: &HarnessProfile) -> String {
953 let (program, arguments) = match login_command(profile) {
954 Ok(command) => command,
955 Err(error) => return format!("{error} Check {}.", profile.home.display()),
959 };
960 format!(
961 "Run `mj login --profile {id}`; it runs `{program} {}` against {}.",
962 arguments.join(" "),
963 profile.home.display()
964 )
965}
966
967fn podman_checks(
972 config: ConfigStatus<'_>,
973 executor: &impl CommandExecutor,
974 smoke: bool,
975 platform: &ApplePlatform,
976) -> Vec<DoctorCheck> {
977 let effective = config.map(|config| config.clone().with_local_targets());
978 let effective = effective.as_ref().map_err(|gap| *gap);
979 let explicit = config.is_ok_and(|config| {
980 local_podman_targets(config)
981 .iter()
982 .any(|(id, _)| config.configures_target(id))
983 });
984 let preflight = builtin_target_availability(
985 podman_check(effective, executor, platform),
986 explicit,
987 "Podman",
988 "podman",
989 );
990 let preflight_passed = preflight.status == CheckStatus::Ready;
991 let mut checks = vec![preflight];
992 if preflight_passed {
993 checks.extend(
994 podman_image_checks(effective, executor, smoke)
995 .into_iter()
996 .map(|(id, check)| builtin_image_check(config, &id, check, smoke)),
997 );
998 }
999 checks
1000}
1001
1002fn builtin_image_check(
1006 config: ConfigStatus<'_>,
1007 target_id: &str,
1008 check: DoctorCheck,
1009 smoke: bool,
1010) -> DoctorCheck {
1011 let explicit = config.is_ok_and(|config| config.configures_target(target_id));
1012 if smoke || explicit || check.status != CheckStatus::Fixable {
1015 return check;
1016 }
1017 DoctorCheck::warning(
1018 check.id,
1019 check.title,
1020 format!(
1021 "{} (built-in `{target_id}` target; the dashboard downloads its image when it starts)",
1022 check.detail
1023 ),
1024 check.remediation.unwrap_or_default(),
1025 )
1026}
1027
1028fn builtin_target_availability(
1037 check: DoctorCheck,
1038 explicit: bool,
1039 engine: &str,
1040 target_id: &str,
1041) -> DoctorCheck {
1042 if explicit || check.status != CheckStatus::Fixable {
1043 return check;
1044 }
1045 DoctorCheck::unsupported(
1046 check.id,
1047 check.title,
1048 format!(
1049 "{engine} is not available and no configured target uses it, so the built-in `{target_id}` target is marked unavailable, which does not fail the check: {}",
1050 check.detail
1051 ),
1052 )
1053}
1054
1055fn podman_check(
1056 config: ConfigStatus<'_>,
1057 executor: &impl CommandExecutor,
1058 platform: &ApplePlatform,
1059) -> DoctorCheck {
1060 let config = match config {
1061 Ok(config) => config,
1062 Err(ConfigGap::NewerVersion(version)) => {
1063 return newer_config_skip("runtime.podman", "Rootless Podman", version);
1064 }
1065 Err(gap @ (ConfigGap::Missing | ConfigGap::Unreadable)) => {
1066 return DoctorCheck::unsupported(
1067 "runtime.podman",
1068 "Rootless Podman",
1069 format!(
1070 "Podman prerequisites cannot be evaluated until {}.",
1071 gap.awaited()
1072 ),
1073 );
1074 }
1075 };
1076 if local_podman_targets(config).is_empty() {
1077 return DoctorCheck::unsupported(
1078 "runtime.podman",
1079 "Rootless Podman",
1080 "No local-podman target is configured.",
1081 );
1082 }
1083 local_podman_runtime_check(executor, platform)
1084}
1085
1086pub fn local_podman_runtime_check(
1093 executor: &impl CommandExecutor,
1094 platform: &ApplePlatform,
1095) -> DoctorCheck {
1096 if !matches!(platform, ApplePlatform::Linux) {
1097 return DoctorCheck::unsupported(
1098 "runtime.podman",
1099 "Rootless Podman",
1100 "Mjolnir's local Podman target requires a Linux host; Podman machine is not supported. Use localhost or an SSH target on a Linux host.",
1101 );
1102 }
1103 match verify_local_podman(executor) {
1104 Ok(preflight) => DoctorCheck::ready(
1105 "runtime.podman",
1106 "Rootless Podman",
1107 format!("Podman {} has a valid rootless UID map.", preflight.version),
1108 ),
1109 Err(error) => DoctorCheck::fixable(
1110 "runtime.podman",
1111 "Rootless Podman",
1112 podman_failure_detail(&error),
1113 podman_remediation(&error),
1114 ),
1115 }
1116}
1117
1118fn local_podman_targets(config: &Config) -> Vec<(&String, &ContainerTemplate)> {
1119 config
1120 .targets
1121 .iter()
1122 .filter_map(|(id, target)| match target {
1123 TargetTemplate::LocalPodman { container } => Some((id, container)),
1124 _ => None,
1125 })
1126 .collect()
1127}
1128
1129fn podman_image_checks(
1130 config: ConfigStatus<'_>,
1131 executor: &impl CommandExecutor,
1132 smoke: bool,
1133) -> Vec<(String, DoctorCheck)> {
1134 let Ok(config) = config else {
1135 return Vec::new();
1136 };
1137 local_podman_targets(config)
1138 .into_iter()
1139 .map(|(id, container)| {
1140 (
1141 id.clone(),
1142 podman_image_check(id, &container.image, executor, smoke),
1143 )
1144 })
1145 .collect()
1146}
1147
1148fn podman_image_check(
1149 id: &str,
1150 image: &str,
1151 executor: &impl CommandExecutor,
1152 smoke: bool,
1153) -> DoctorCheck {
1154 let check_id = format!("runtime.podman.image.{id}");
1155 let title = format!("Podman image for target {id}");
1156 if smoke {
1157 let target = RuntimeTargetTemplate::LocalPodman(RuntimeContainerTemplate {
1158 build_cache: None,
1159 image: image.to_owned(),
1160 pull_policy: Default::default(),
1161 extra_run_args: vec![],
1162 workspace_storage: Default::default(),
1163 });
1164 return match run_setup_smoke_test(&target, &doctor_smoke_id(), executor) {
1165 Ok(()) => DoctorCheck::ready(
1166 check_id,
1167 title,
1168 format!("Disposable run/exec/remove smoke test passed for image {image}."),
1169 ),
1170 Err(error) => DoctorCheck::fixable(
1171 check_id,
1172 title,
1173 format!(
1174 "Disposable run/exec/remove smoke test failed for image {image}: {error:#}"
1175 ),
1176 "Fix the configured image or Podman runtime, then run `mj doctor --json --smoke` again.",
1177 ),
1178 };
1179 }
1180
1181 let command = CommandSpec::new("podman", ["image", "exists", image])
1182 .purpose("check Podman image presence");
1183 match executor.execute(&command) {
1184 Ok(output) if output.status == 0 => DoctorCheck::ready(
1185 check_id,
1186 title,
1187 format!("Image {image} is present in local Podman storage."),
1188 ),
1189 Ok(_) => DoctorCheck::fixable(
1190 check_id,
1191 title,
1192 format!("Image {image} is not present in local Podman storage."),
1193 missing_image_remediation(image),
1194 ),
1195 Err(error) => DoctorCheck::fixable(
1196 check_id,
1197 title,
1198 format!(
1199 "Could not check whether image {image} is present in local Podman storage: {error}"
1200 ),
1201 missing_image_remediation(image),
1202 ),
1203 }
1204}
1205
1206fn missing_image_remediation(image: &str) -> String {
1207 format!(
1208 "Pull it with `podman pull {image}`, build it from containers/Containerfile.agent-dev, or run `mj doctor --json --smoke` to verify the full pull-and-run path."
1209 )
1210}
1211
1212fn docker_checks(
1214 config: ConfigStatus<'_>,
1215 executor: &impl CommandExecutor,
1216 smoke: bool,
1217) -> Vec<DoctorCheck> {
1218 let config = match config {
1219 Ok(config) => config,
1220 Err(ConfigGap::NewerVersion(version)) => {
1221 return vec![newer_config_skip("runtime.docker", "Docker", version)];
1222 }
1223 Err(gap @ (ConfigGap::Missing | ConfigGap::Unreadable)) => {
1224 return vec![DoctorCheck::unsupported(
1225 "runtime.docker",
1226 "Docker",
1227 format!(
1228 "Docker prerequisites cannot be evaluated until {}.",
1229 gap.awaited()
1230 ),
1231 )];
1232 }
1233 };
1234 let explicit = local_docker_targets(config)
1235 .iter()
1236 .any(|(id, _)| config.configures_target(id));
1237 let effective = config.clone().with_local_targets();
1238 let targets = local_docker_targets(&effective);
1239 if targets.is_empty() {
1240 return vec![DoctorCheck::unsupported(
1241 "runtime.docker",
1242 "Docker",
1243 "No local-docker target is configured.",
1244 )];
1245 }
1246 let preflight = builtin_target_availability(
1247 local_docker_runtime_check(executor),
1248 explicit,
1249 "Docker",
1250 "docker",
1251 );
1252 if preflight.status != CheckStatus::Ready {
1253 return vec![preflight];
1254 }
1255 let mut checks = vec![preflight];
1256 checks.extend(targets.into_iter().map(|(id, container)| {
1257 builtin_image_check(
1258 Ok(config),
1259 id,
1260 docker_image_check(id, &container.image, executor, smoke),
1261 smoke,
1262 )
1263 }));
1264 checks
1265}
1266
1267pub fn local_docker_runtime_check(executor: &impl CommandExecutor) -> DoctorCheck {
1268 match verify_local_docker(executor) {
1269 Ok(preflight) => DoctorCheck::ready(
1270 "runtime.docker",
1271 "Docker",
1272 format!(
1273 "Docker {} is connected to a Linux daemon.",
1274 preflight.version
1275 ),
1276 ),
1277 Err(error) => match error.downcast_ref::<DockerUnavailable>() {
1278 Some(problem) => DoctorCheck::fixable(
1279 "runtime.docker",
1280 "Docker",
1281 problem.to_string(),
1282 problem.remediation(),
1283 ),
1284 None => DoctorCheck::fixable(
1285 "runtime.docker",
1286 "Docker",
1287 format!("{error:#}"),
1288 "Install and start Docker, then make sure `docker info` succeeds as the user running mj.",
1289 ),
1290 },
1291 }
1292}
1293
1294fn local_docker_targets(config: &Config) -> Vec<(&String, &ContainerTemplate)> {
1295 config
1296 .targets
1297 .iter()
1298 .filter_map(|(id, target)| match target {
1299 TargetTemplate::LocalDocker { container } => Some((id, container)),
1300 _ => None,
1301 })
1302 .collect()
1303}
1304
1305fn docker_image_check(
1306 id: &str,
1307 image: &str,
1308 executor: &impl CommandExecutor,
1309 smoke: bool,
1310) -> DoctorCheck {
1311 let check_id = format!("runtime.docker.image.{id}");
1312 let title = format!("Docker image for target {id}");
1313 if smoke {
1314 let target = RuntimeTargetTemplate::LocalDocker(RuntimeContainerTemplate {
1315 build_cache: None,
1316 image: image.to_owned(),
1317 pull_policy: Default::default(),
1318 extra_run_args: vec![],
1319 workspace_storage: Default::default(),
1320 });
1321 return match run_setup_smoke_test(&target, &doctor_smoke_id(), executor) {
1322 Ok(()) => DoctorCheck::ready(
1323 check_id,
1324 title,
1325 match local_docker_vm_share(executor) {
1326 Ok(Some(reason)) => format!(
1327 "Disposable run/exec/remove and read-only attachment smoke test passed for image {image}. {reason}, so attached directories mount read-only instead of copy-on-write."
1328 ),
1329 _ => format!(
1330 "Disposable run/exec/remove and OverlayFS attachment smoke test passed for image {image}."
1331 ),
1332 },
1333 ),
1334 Err(error) => DoctorCheck::fixable(
1335 check_id,
1336 title,
1337 format!(
1338 "Disposable run/exec/remove smoke test failed for image {image}: {error:#}"
1339 ),
1340 "Fix the configured image or Docker runtime, then run `mj doctor --json --smoke` again.",
1341 ),
1342 };
1343 }
1344 let command = CommandSpec::new("docker", ["image", "inspect", image])
1345 .purpose("check Docker image presence");
1346 match executor.execute(&command) {
1347 Ok(output) if output.status == 0 => DoctorCheck::ready(
1348 check_id,
1349 title,
1350 format!("Image {image} is present in Docker storage."),
1351 ),
1352 Ok(_) => DoctorCheck::fixable(
1353 check_id,
1354 title,
1355 format!("Image {image} is not present in Docker storage."),
1356 format!("Pull it with `docker pull {image}`, or run `mj doctor --json --smoke`."),
1357 ),
1358 Err(error) => DoctorCheck::fixable(
1359 check_id,
1360 title,
1361 format!("Could not inspect Docker image {image}: {error}"),
1362 format!("Make sure `docker info` succeeds, then run `docker pull {image}`."),
1363 ),
1364 }
1365}
1366
1367enum SshConnectivity {
1372 Reachable,
1373 Failed { detail: String, remediation: String },
1374}
1375
1376fn ssh_connectivity(ssh: &RuntimeSshTarget, executor: &impl CommandExecutor) -> SshConnectivity {
1381 let destination = &ssh.destination;
1382 let command = ssh_connectivity_probe(ssh);
1383 match executor.execute(&command) {
1384 Err(error) => SshConnectivity::Failed {
1385 detail: format!("Could not run `ssh {destination} true`: {error:#}"),
1386 remediation: ssh_launch_failure_remediation(&error, ssh),
1387 },
1388 Ok(output) if output.status != 0 => {
1389 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned();
1390 SshConnectivity::Failed {
1391 detail: format!("`ssh {destination} true` failed: {stderr}"),
1392 remediation: ssh_failure_remediation(&stderr, ssh),
1393 }
1394 }
1395 Ok(_) => SshConnectivity::Reachable,
1396 }
1397}
1398
1399const SSH_MISSING_REMEDIATION: &str = "Install an OpenSSH client and put `ssh` on PATH: `sudo apt update && sudo apt install -y openssh-client` (Debian/Ubuntu) or `sudo dnf install -y openssh-clients` (Fedora).";
1400
1401#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1407enum SshFailure {
1408 UntrustedHostKey,
1409 Unauthenticated,
1410 ClientMissing,
1411 Unreachable,
1413 Unrecognized,
1414}
1415
1416fn classify_ssh_stderr(stderr: &str) -> SshFailure {
1417 const UNTRUSTED_HOST_KEY: [&str; 3] = [
1418 "Host key verification failed",
1419 "No ECDSA host key is known",
1420 "REMOTE HOST IDENTIFICATION HAS CHANGED",
1421 ];
1422 const UNAUTHENTICATED: [&str; 4] = [
1423 "Permission denied",
1424 "Too many authentication failures",
1425 "no matching host key",
1426 "Authentication failed",
1427 ];
1428 const CLIENT_MISSING: [&str; 2] = ["ssh: command not found", "No such file or directory"];
1429 const UNREACHABLE: [&str; 3] = [
1430 "Connection timed out",
1431 "No route to host",
1432 "Network is unreachable",
1433 ];
1434
1435 let reported = |signatures: &[&str]| signatures.iter().any(|text| stderr.contains(text));
1436 if reported(&UNTRUSTED_HOST_KEY) {
1437 SshFailure::UntrustedHostKey
1438 } else if reported(&UNAUTHENTICATED) {
1439 SshFailure::Unauthenticated
1440 } else if reported(&CLIENT_MISSING) {
1441 SshFailure::ClientMissing
1442 } else if reported(&UNREACHABLE) {
1443 SshFailure::Unreachable
1444 } else {
1445 SshFailure::Unrecognized
1446 }
1447}
1448
1449fn ssh_launch_failure_remediation(error: &anyhow::Error, ssh: &RuntimeSshTarget) -> String {
1452 if error.downcast_ref::<CommandTimedOut>().is_some() {
1453 return ssh_unreachable_remediation(ssh);
1454 }
1455 let missing_binary = error.chain().any(|cause| {
1456 cause
1457 .downcast_ref::<std::io::Error>()
1458 .is_some_and(|io| io.kind() == std::io::ErrorKind::NotFound)
1459 });
1460 if missing_binary {
1461 return SSH_MISSING_REMEDIATION.to_owned();
1462 }
1463 format!(
1464 "Run `ssh {} true` by hand and resolve the error it reports: {error:#}",
1465 ssh.destination
1466 )
1467}
1468
1469fn ssh_unreachable_remediation(ssh: &RuntimeSshTarget) -> String {
1472 let host = ssh_host_only(&ssh.destination);
1473 format!(
1474 "Check that {host} is up and reachable from this machine: wake it, bring up the VPN, or refresh the cloud session that exposes it, then run `ssh {} true` by hand.",
1475 ssh.destination
1476 )
1477}
1478
1479fn ssh_failure_remediation(stderr: &str, ssh: &RuntimeSshTarget) -> String {
1481 let destination = &ssh.destination;
1482 match classify_ssh_stderr(stderr) {
1483 SshFailure::UntrustedHostKey => {
1484 let host = ssh_host_only(destination);
1485 format!(
1486 "Add the host key with `ssh-keyscan -H {host} >> ~/.ssh/known_hosts`. Verify the fingerprint out of band before trusting it; if the key changed, remove the stale entry with `ssh-keygen -R {host}` first."
1487 )
1488 }
1489 SshFailure::Unauthenticated => match ssh_identity_file(ssh) {
1490 Some(identity) => format!(
1491 "Install your public key on the host with `ssh-copy-id -i {identity}.pub {destination}`."
1492 ),
1493 None => {
1494 format!("Install your public key on the host with `ssh-copy-id {destination}`.")
1495 }
1496 },
1497 SshFailure::ClientMissing => SSH_MISSING_REMEDIATION.to_owned(),
1498 SshFailure::Unreachable => ssh_unreachable_remediation(ssh),
1499 SshFailure::Unrecognized => {
1500 format!(
1501 "Run `ssh {destination} true` by hand and resolve the error it reports: {stderr}"
1502 )
1503 }
1504 }
1505}
1506
1507fn ssh_host_only(destination: &str) -> &str {
1509 destination
1510 .rsplit_once('@')
1511 .map_or(destination, |(_, host)| host)
1512}
1513
1514fn ssh_identity_file(ssh: &RuntimeSshTarget) -> Option<&str> {
1516 let position = ssh.ssh_args.iter().position(|arg| arg == "-i")?;
1517 ssh.ssh_args.get(position + 1).map(String::as_str)
1518}
1519
1520fn ssh_bare_checks(config: ConfigStatus<'_>, executor: &impl CommandExecutor) -> Vec<DoctorCheck> {
1522 let Ok(config) = config else {
1523 return Vec::new();
1524 };
1525 config
1526 .targets
1527 .iter()
1528 .filter_map(|(id, target)| match target {
1529 TargetTemplate::SshBare { ssh, .. } => {
1530 Some(ssh_bare_check(id, &RuntimeSshTarget::from(ssh), executor))
1531 }
1532 _ => None,
1533 })
1534 .collect()
1535}
1536
1537fn ssh_bare_check(
1538 id: &str,
1539 ssh: &RuntimeSshTarget,
1540 executor: &impl CommandExecutor,
1541) -> DoctorCheck {
1542 let check_id = format!("runtime.ssh-bare.{id}");
1543 let title = format!("SSH access for target {id}");
1544 match ssh_connectivity(ssh, executor) {
1545 SshConnectivity::Reachable => DoctorCheck::ready(
1546 check_id,
1547 title,
1548 format!(
1549 "`ssh {} true` succeeds noninteractively from this host.",
1550 ssh.destination
1551 ),
1552 ),
1553 SshConnectivity::Failed {
1554 detail,
1555 remediation,
1556 } => DoctorCheck::fixable(check_id, title, detail, remediation),
1557 }
1558}
1559
1560fn ssh_podman_checks(
1563 config: ConfigStatus<'_>,
1564 executor: &impl CommandExecutor,
1565 smoke: bool,
1566) -> Vec<DoctorCheck> {
1567 let Ok(config) = config else {
1568 return Vec::new();
1569 };
1570 config
1571 .targets
1572 .iter()
1573 .flat_map(|(id, target)| match target {
1574 TargetTemplate::SshPodman { ssh, container, .. } => {
1575 let ssh = RuntimeSshTarget::from(ssh);
1576 let (check, reachable) =
1577 ssh_podman_check(id, &ssh, &container.image, executor, smoke);
1578 let mut checks = vec![check];
1579 if reachable {
1581 checks.push(ssh_podman_limits_check(id, &ssh, executor));
1582 }
1583 checks
1584 }
1585 _ => Vec::new(),
1586 })
1587 .collect()
1588}
1589
1590fn ssh_podman_check(
1593 id: &str,
1594 ssh: &RuntimeSshTarget,
1595 image: &str,
1596 executor: &impl CommandExecutor,
1597 smoke: bool,
1598) -> (DoctorCheck, bool) {
1599 let check_id = format!("runtime.ssh-podman.{id}");
1600 let title = format!("Remote Podman for target {id}");
1601 if let SshConnectivity::Failed {
1604 detail,
1605 remediation,
1606 } = ssh_connectivity(ssh, executor)
1607 {
1608 return (
1609 DoctorCheck::fixable(check_id, title, detail, remediation),
1610 false,
1611 );
1612 }
1613 (
1614 ssh_podman_runtime_check(check_id, title, ssh, image, executor, smoke),
1615 true,
1616 )
1617}
1618
1619fn ssh_podman_runtime_check(
1621 check_id: String,
1622 title: String,
1623 ssh: &RuntimeSshTarget,
1624 image: &str,
1625 executor: &impl CommandExecutor,
1626 smoke: bool,
1627) -> DoctorCheck {
1628 let destination = &ssh.destination;
1629 let preflight = match verify_ssh_podman(ssh, executor) {
1630 Ok(preflight) => preflight,
1631 Err(error) => {
1632 let detail = podman_failure_detail(&error);
1633 let remediation = match podman_remediation_match(&error) {
1634 Some(remediation) => {
1635 format!("On {destination}: {remediation} See {PODMAN_DOCUMENTATION_URL}.")
1636 }
1637 None => format!(
1638 "Verify `ssh {destination}` succeeds noninteractively from this host, then install rootless Podman 4.0 or newer there. See {PODMAN_DOCUMENTATION_URL}."
1639 ),
1640 };
1641 return DoctorCheck::fixable(check_id, title, detail, remediation);
1642 }
1643 };
1644 let linger_warning = preflight.warnings.first();
1645 if !smoke && let Some(warning) = linger_warning {
1646 return DoctorCheck::warning(
1647 check_id,
1648 title,
1649 format!(
1650 "Remote rootless Podman {} is available via {destination}, but {}",
1651 preflight.version, warning.detail
1652 ),
1653 &warning.remediation,
1654 );
1655 }
1656 if !smoke {
1657 return DoctorCheck::ready(
1658 check_id,
1659 title,
1660 format!(
1661 "Remote rootless Podman {} is available via {destination}. Run `mj doctor --json --smoke` to verify the image end to end.",
1662 preflight.version
1663 ),
1664 );
1665 }
1666
1667 let target = RuntimeTargetTemplate::SshPodman {
1668 ssh: ssh.clone(),
1669 container: RuntimeContainerTemplate {
1670 build_cache: None,
1671 image: image.to_owned(),
1672 pull_policy: Default::default(),
1673 extra_run_args: vec![],
1674 workspace_storage: Default::default(),
1675 },
1676 };
1677 match run_setup_smoke_test(&target, &doctor_smoke_id(), executor) {
1678 Ok(()) => match linger_warning {
1679 Some(warning) => DoctorCheck::warning(
1680 check_id,
1681 title,
1682 format!(
1683 "Disposable run/exec/remove smoke test passed for image {image} on {destination}, but {}",
1684 warning.detail
1685 ),
1686 &warning.remediation,
1687 ),
1688 None => DoctorCheck::ready(
1689 check_id,
1690 title,
1691 format!(
1692 "Disposable run/exec/remove smoke test passed for image {image} on {destination}."
1693 ),
1694 ),
1695 },
1696 Err(error) => DoctorCheck::fixable(
1697 check_id,
1698 title,
1699 format!(
1700 "Disposable run/exec/remove smoke test failed for image {image} on {destination}: {error:#}"
1701 ),
1702 format!(
1703 "Fix the configured image or Podman runtime on {destination}, then run `mj doctor --json --smoke` again."
1704 ),
1705 ),
1706 }
1707}
1708
1709const SSH_PODMAN_HOST_LIMITS_SCRIPT: &str = r#"
1718if [ -r /proc/sys/kernel/keys/maxkeys ]; then
1719 printf 'keys.max=%s\n' "$(cat /proc/sys/kernel/keys/maxkeys)"
1720fi
1721if [ -r /proc/key-users ]; then
1722 awk -v uid="$(id -u)" '
1723 { user = $1; sub(/:$/, "", user) }
1724 user == uid {
1725 split($4, quota, "/")
1726 printf "keys.used=%s\nkeys.quota=%s\n", quota[1], quota[2]
1727 }
1728 ' /proc/key-users
1729fi
1730unreadable=0
1731maxstartups=
1732# A drop-in directory that cannot be listed hides any override it holds.
1733if [ -d /etc/ssh/sshd_config.d ] && ! [ -r /etc/ssh/sshd_config.d ]; then
1734 unreadable=1
1735fi
1736for file in /etc/ssh/sshd_config /etc/ssh/sshd_config.d/*.conf; do
1737 [ -e "$file" ] || continue
1738 if [ -r "$file" ]; then
1739 match=$(grep -i '^[[:space:]]*maxstartups[[:space:]]' "$file" 2>/dev/null | tail -n 1)
1740 [ -n "$match" ] && maxstartups=$(printf '%s\n' "$match" | awk '{ print $2 }')
1741 else
1742 unreadable=1
1743 fi
1744done
1745[ -n "$maxstartups" ] && printf 'maxstartups=%s\n' "$maxstartups"
1746[ "$unreadable" = 1 ] && printf 'maxstartups.unreadable=1\n'
1747exit 0
1748"#;
1749
1750const KEYRING_PRESSURE_PERCENT: u64 = 80;
1754
1755#[derive(Debug, Default, PartialEq, Eq)]
1758struct HostLimits {
1759 keys_used: Option<u64>,
1760 keys_quota: Option<u64>,
1761 keys_max: Option<u64>,
1762 max_startups: Option<String>,
1763 max_startups_unreadable: bool,
1764}
1765
1766fn parse_host_limits(stdout: &[u8]) -> HostLimits {
1767 let text = String::from_utf8_lossy(stdout);
1768 let mut limits = HostLimits::default();
1769 for line in text.lines() {
1770 let Some((name, value)) = line.split_once('=') else {
1771 continue;
1772 };
1773 let value = value.trim();
1774 match name.trim() {
1775 "keys.used" => limits.keys_used = value.parse().ok(),
1776 "keys.quota" => limits.keys_quota = value.parse().ok(),
1777 "keys.max" => limits.keys_max = value.parse().ok(),
1778 "maxstartups" if !value.is_empty() => limits.max_startups = Some(value.to_owned()),
1779 "maxstartups.unreadable" => limits.max_startups_unreadable = value == "1",
1780 _ => {}
1781 }
1782 }
1783 limits
1784}
1785
1786impl HostLimits {
1787 fn is_empty(&self) -> bool {
1789 self.keys_used.is_none()
1790 && self.keys_quota.is_none()
1791 && self.keys_max.is_none()
1792 && self.max_startups.is_none()
1793 && !self.max_startups_unreadable
1794 }
1795
1796 fn keyring_is_under_pressure(&self) -> bool {
1797 match (self.keys_used, self.keys_quota) {
1798 (Some(used), Some(quota)) if quota > 0 => {
1799 used.saturating_mul(100) >= quota.saturating_mul(KEYRING_PRESSURE_PERCENT)
1800 }
1801 _ => false,
1802 }
1803 }
1804
1805 fn keyring_sentence(&self, destination: &str) -> String {
1806 match (self.keys_used, self.keys_quota) {
1807 (Some(used), Some(quota)) => {
1808 let system = match self.keys_max {
1809 Some(max) => format!(", and `kernel.keys.maxkeys` is {max}"),
1810 None => String::new(),
1811 };
1812 format!(
1813 "The login user on {destination} holds {used} of its {quota} kernel keyring quota{system}."
1814 )
1815 }
1816 _ => format!(
1817 "The kernel keyring quota for the login user on {destination} could not be read."
1818 ),
1819 }
1820 }
1821
1822 fn max_startups_sentence(&self) -> String {
1823 match (&self.max_startups, self.max_startups_unreadable) {
1824 (Some(value), _) => format!("sshd MaxStartups is {value}."),
1825 (None, true) => "sshd MaxStartups is not set in a readable sshd_config file, so sshd's default applies unless an unreadable drop-in overrides it.".to_owned(),
1826 (None, false) => {
1827 "sshd MaxStartups is not set in sshd_config, so sshd's default applies.".to_owned()
1828 }
1829 }
1830 }
1831}
1832
1833fn ssh_podman_limits_check(
1837 id: &str,
1838 ssh: &RuntimeSshTarget,
1839 executor: &impl CommandExecutor,
1840) -> DoctorCheck {
1841 let check_id = format!("runtime.ssh-podman.{id}.limits");
1842 let title = format!("Host limits for target {id}");
1843 let destination = &ssh.destination;
1844 let manual = || {
1845 format!(
1846 "Read them by hand on {destination}: `cat /proc/key-users /proc/sys/kernel/keys/maxkeys` and `grep -ri maxstartups /etc/ssh/sshd_config /etc/ssh/sshd_config.d`."
1847 )
1848 };
1849 let command = ssh_validation_command(
1850 ssh,
1851 vec![
1852 "sh".to_owned(),
1853 "-c".to_owned(),
1854 SSH_PODMAN_HOST_LIMITS_SCRIPT.to_owned(),
1855 ],
1856 "read ssh-podman host limits",
1857 );
1858 let limits = match executor.execute(&command) {
1859 Ok(output) if output.status == 0 => parse_host_limits(&output.stdout),
1860 Ok(output) => {
1861 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned();
1862 return DoctorCheck::warning(
1863 check_id,
1864 title,
1865 format!(
1866 "Could not read the kernel keyring quota or sshd MaxStartups from {destination}: {stderr}"
1867 ),
1868 manual(),
1869 );
1870 }
1871 Err(error) => {
1872 return DoctorCheck::warning(
1873 check_id,
1874 title,
1875 format!(
1876 "Could not read the kernel keyring quota or sshd MaxStartups from {destination}: {error}"
1877 ),
1878 manual(),
1879 );
1880 }
1881 };
1882 if limits.is_empty() {
1883 return DoctorCheck::warning(
1884 check_id,
1885 title,
1886 format!("{destination} reported no readable kernel keyring or sshd limits."),
1887 manual(),
1888 );
1889 }
1890 let detail = format!(
1891 "{} {}",
1892 limits.keyring_sentence(destination),
1893 limits.max_startups_sentence()
1894 );
1895 if limits.keyring_is_under_pressure() {
1896 return DoctorCheck::warning(
1897 check_id,
1898 title,
1899 format!(
1900 "{detail} Every container takes a session keyring, so `podman run` fails with `crun: create keyring` once the quota is gone."
1901 ),
1902 format!(
1903 "Raise `kernel.keys.maxkeys` and `kernel.keys.maxbytes` with sysctl on {destination}, and close finished sessions promptly."
1904 ),
1905 );
1906 }
1907 DoctorCheck::ready(check_id, title, detail)
1908}
1909
1910fn storage_checks(config: ConfigStatus<'_>, executor: &impl CommandExecutor) -> Vec<DoctorCheck> {
1915 use mj_core::targets::storage::{StorageCondition, TargetStorageView};
1916 let Ok(config) = config else {
1917 return Vec::new();
1918 };
1919 let controller = crate::controller::Controller {
1920 config: config.clone(),
1921 state: mj_core::state::State::default(),
1922 };
1923 controller
1924 .deployment_capacity_targets()
1925 .into_iter()
1926 .filter(|target| target.kind == crate::targets::DeploymentCapacityKind::Host)
1927 .filter_map(|target| {
1928 let check_id = format!("storage.{}", target.id);
1929 let title = format!("Free space on {}", target.host);
1930 let unmeasured = |detail: String, remediation: &str| {
1931 Some(DoctorCheck::warning(
1932 check_id.clone(),
1933 title.clone(),
1934 format!("Could not measure free space: {detail}"),
1935 remediation,
1936 ))
1937 };
1938 let (home, filesystems) = if target.local {
1939 match crate::targets::measure_local_storage(&target.local_storage_paths, executor)
1940 {
1941 Ok(measured) => measured,
1942 Err(error) => {
1943 return unmeasured(format!("{error:#}"), "Check that `df` runs here.");
1944 }
1945 }
1946 } else {
1947 let probe = target.probes.first()?;
1948 match executor.execute(probe) {
1949 Ok(output) if output.status == 0 => {
1950 mj_core::targets::storage::parse_storage_lines(&output.stdout)
1951 }
1952 Ok(output) if probe.ssh_destination.is_some() && output.status == 255 => {
1954 return None;
1955 }
1956 Ok(output) => {
1957 return unmeasured(
1958 String::from_utf8_lossy(&output.stderr).trim().to_owned(),
1959 "Check that `df` runs on the host.",
1960 );
1961 }
1962 Err(error) => {
1963 return unmeasured(
1964 format!("{error:#}"),
1965 "Check that the host is reachable and `df` runs on it.",
1966 );
1967 }
1968 }
1969 };
1970 let view =
1971 TargetStorageView::evaluate(&target.host, home, &filesystems, None, |_| None, None);
1972 let lines = view.filesystem_lines().join("; ");
1975 let flagged = |condition| {
1976 view.filesystems
1977 .iter()
1978 .filter(|filesystem| filesystem.condition == condition)
1979 .map(|filesystem| filesystem.space.mount.clone())
1980 .collect::<Vec<_>>()
1981 .join(", ")
1982 };
1983 Some(match view.worst().map(|filesystem| filesystem.condition) {
1984 None => DoctorCheck::warning(
1985 check_id,
1986 title,
1987 "The probe reported no filesystem.",
1988 "Check that `df -Pk` runs on the host.",
1989 ),
1990 Some(StorageCondition::Ok) => DoctorCheck::ready(check_id, title, format!("{lines}.")),
1991 Some(StorageCondition::Low) => DoctorCheck::warning(
1992 check_id,
1993 title,
1994 format!("Disk low on {}: {lines}.", flagged(StorageCondition::Low)),
1995 format!("Free space on {} before it fills up.", target.host),
1996 ),
1997 Some(StorageCondition::Full) => DoctorCheck::fixable(
1998 check_id,
1999 title,
2000 format!(
2001 "Disk full on {}: {lines}. Mjolnir refuses writes there, and sessions that write there wait instead of restarting.",
2002 flagged(StorageCondition::Full)
2003 ),
2004 format!(
2005 "Free at least {} on {} of {}.",
2006 mj_core::move_workspace::format_bytes(
2007 mj_core::targets::storage::WRITE_RESERVE_BYTES
2008 ),
2009 flagged(StorageCondition::Full),
2010 target.host
2011 ),
2012 ),
2013 })
2014 })
2015 .collect()
2016}
2017
2018fn ssh_docker_checks(
2021 config: ConfigStatus<'_>,
2022 executor: &impl CommandExecutor,
2023 smoke: bool,
2024) -> Vec<DoctorCheck> {
2025 let Ok(config) = config else {
2026 return Vec::new();
2027 };
2028 config
2029 .targets
2030 .iter()
2031 .filter_map(|(id, target)| match target {
2032 TargetTemplate::SshDocker { ssh, container } => Some(ssh_docker_check(
2033 id,
2034 &RuntimeSshTarget::from(ssh),
2035 &container.image,
2036 executor,
2037 smoke,
2038 )),
2039 _ => None,
2040 })
2041 .collect()
2042}
2043
2044fn ssh_docker_check(
2045 id: &str,
2046 ssh: &RuntimeSshTarget,
2047 image: &str,
2048 executor: &impl CommandExecutor,
2049 smoke: bool,
2050) -> DoctorCheck {
2051 let check_id = format!("runtime.ssh-docker.{id}");
2052 let title = format!("Remote Docker for target {id}");
2053 let destination = &ssh.destination;
2054 if let SshConnectivity::Failed {
2055 detail,
2056 remediation,
2057 } = ssh_connectivity(ssh, executor)
2058 {
2059 return DoctorCheck::fixable(check_id, title, detail, remediation);
2060 }
2061
2062 let preflight = match verify_ssh_docker(ssh, executor) {
2063 Ok(preflight) => preflight,
2064 Err(error) => {
2065 let detail = format!("{error:#}");
2066 return DoctorCheck::fixable(
2067 check_id,
2068 title,
2069 detail,
2070 format!(
2071 "Verify `ssh {destination}` succeeds noninteractively from this host, then install and start Docker Engine there; make sure `docker info` succeeds for the configured SSH user."
2072 ),
2073 );
2074 }
2075 };
2076
2077 if smoke {
2078 let target = RuntimeTargetTemplate::SshDocker {
2079 ssh: ssh.clone(),
2080 container: RuntimeContainerTemplate {
2081 build_cache: None,
2082 image: image.to_owned(),
2083 pull_policy: Default::default(),
2084 extra_run_args: vec![],
2085 workspace_storage: Default::default(),
2086 },
2087 };
2088 return match run_setup_smoke_test(&target, &doctor_smoke_id(), executor) {
2089 Ok(()) => DoctorCheck::ready(
2090 check_id,
2091 title,
2092 format!(
2093 "Remote Docker {} is available via {destination}; disposable run/exec/remove and remote OverlayFS attachment smoke test passed for image {image}.",
2094 preflight.version
2095 ),
2096 ),
2097 Err(error) => DoctorCheck::fixable(
2098 check_id,
2099 title,
2100 format!(
2101 "Disposable run/exec/remove smoke test failed for image {image} on {destination}: {error:#}"
2102 ),
2103 format!(
2104 "Fix the configured image or Docker runtime on {destination}, then run `mj doctor --json --smoke` again."
2105 ),
2106 ),
2107 };
2108 }
2109
2110 let image_command = ssh_command(
2111 ssh,
2112 [
2113 "docker".to_owned(),
2114 "image".to_owned(),
2115 "inspect".to_owned(),
2116 image.to_owned(),
2117 ]
2118 .to_vec(),
2119 )
2120 .purpose("check remote Docker image presence");
2121 match executor.execute(&image_command) {
2122 Ok(output) if output.status == 0 => DoctorCheck::ready(
2123 check_id,
2124 title,
2125 format!(
2126 "Remote Docker {} is available via {destination}; image {image} is present. Run `mj doctor --json --smoke` to verify remote OverlayFS attachments.",
2127 preflight.version
2128 ),
2129 ),
2130 Ok(output) => DoctorCheck::fixable(
2131 check_id,
2132 title,
2133 format!(
2134 "Image {image} is not present in remote Docker storage on {destination}: {}",
2135 String::from_utf8_lossy(&output.stderr).trim()
2136 ),
2137 format!(
2138 "Pull it on {destination} with `ssh {destination} docker pull {image}`, or run `mj doctor --json --smoke`."
2139 ),
2140 ),
2141 Err(error) => DoctorCheck::fixable(
2142 check_id,
2143 title,
2144 format!("Could not inspect remote Docker image {image} on {destination}: {error}"),
2145 format!(
2146 "Verify `ssh {destination} docker info` succeeds, then pull {image} on that host."
2147 ),
2148 ),
2149 }
2150}
2151
2152fn doctor_smoke_id() -> String {
2154 format!(
2155 "doctor-{}-{:x}",
2156 std::process::id(),
2157 SystemTime::now()
2158 .duration_since(UNIX_EPOCH)
2159 .unwrap_or_default()
2160 .as_nanos()
2161 )
2162}
2163
2164fn podman_remediation(error: &anyhow::Error) -> String {
2165 let fix = podman_remediation_match(error).unwrap_or(
2166 "Install Podman with `sudo apt update && sudo apt install -y podman uidmap` (Debian/Ubuntu) or `sudo dnf install -y podman shadow-utils` (Fedora).",
2167 );
2168 format!("{fix} See {PODMAN_DOCUMENTATION_URL}.")
2169}
2170
2171fn podman_failure_detail(error: &anyhow::Error) -> String {
2173 podman_probe_observation(error).map_or_else(|| format!("{error:#}"), str::to_owned)
2174}
2175
2176fn podman_remediation_match(error: &anyhow::Error) -> Option<&'static str> {
2183 failed_podman_postcondition(error).map(PodmanPostcondition::remediation)
2184}
2185
2186const AWS_CLI_INSTALL_URL: &str =
2187 "https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html";
2188
2189fn aws_checks(config: ConfigStatus<'_>, executor: &impl CommandExecutor) -> Vec<DoctorCheck> {
2192 let Ok(config) = config else {
2193 return Vec::new();
2194 };
2195 config
2196 .targets
2197 .iter()
2198 .filter_map(|(id, target)| match target {
2199 TargetTemplate::AwsEc2 {
2200 aws_profile,
2201 region,
2202 launch_template,
2203 ..
2204 } => Some(aws_target_check(
2205 id,
2206 aws_profile.as_deref(),
2207 region,
2208 launch_template,
2209 executor,
2210 )),
2211 _ => None,
2212 })
2213 .collect()
2214}
2215
2216fn aws_global_args<'a>(profile: Option<&'a str>, region: &'a str) -> Vec<String> {
2219 vec![
2220 "--profile".to_owned(),
2221 profile.unwrap_or("default").to_owned(),
2222 "--region".to_owned(),
2223 region.to_owned(),
2224 ]
2225}
2226
2227fn aws_target_check(
2228 id: &str,
2229 profile: Option<&str>,
2230 region: &str,
2231 launch_template: &str,
2232 executor: &impl CommandExecutor,
2233) -> DoctorCheck {
2234 let check_id = format!("runtime.aws-ec2.{id}");
2235 let title = format!("AWS EC2 target {id}");
2236 let profile_label = profile.unwrap_or("default");
2237
2238 let version = CommandSpec::new("aws", ["--version"]).purpose("check AWS CLI installation");
2239 match executor.execute(&version) {
2240 Err(error) => {
2241 return DoctorCheck::fixable(
2242 check_id,
2243 title,
2244 format!("The `aws` command is not available: {error}"),
2245 format!("Install the AWS CLI and put `aws` on PATH: {AWS_CLI_INSTALL_URL}"),
2246 );
2247 }
2248 Ok(output) if output.status != 0 => {
2249 return DoctorCheck::fixable(
2250 check_id,
2251 title,
2252 format!(
2253 "`aws --version` failed: {}",
2254 String::from_utf8_lossy(&output.stderr).trim()
2255 ),
2256 format!("Reinstall the AWS CLI: {AWS_CLI_INSTALL_URL}"),
2257 );
2258 }
2259 Ok(_) => {}
2260 }
2261
2262 let mut identity_args = aws_global_args(profile, region);
2263 identity_args.extend(["sts".to_owned(), "get-caller-identity".to_owned()]);
2264 identity_args.extend(["--output".to_owned(), "json".to_owned()]);
2265 let identity =
2266 CommandSpec::new("aws", identity_args).purpose("check AWS credentials for a doctor target");
2267 match executor.execute(&identity) {
2268 Err(error) => {
2269 return DoctorCheck::fixable(
2270 check_id,
2271 title,
2272 format!("Could not run `aws sts get-caller-identity`: {error}"),
2273 format!(
2274 "Configure credentials with `aws configure --profile {profile_label}`, or sign in with `aws sso login --profile {profile_label}`."
2275 ),
2276 );
2277 }
2278 Ok(output) if output.status != 0 => {
2279 return DoctorCheck::fixable(
2280 check_id,
2281 title,
2282 format!(
2283 "AWS credentials for profile {profile_label} are not usable: {}",
2284 String::from_utf8_lossy(&output.stderr).trim()
2285 ),
2286 format!(
2287 "Configure credentials with `aws configure --profile {profile_label}`, or sign in with `aws sso login --profile {profile_label}`."
2288 ),
2289 );
2290 }
2291 Ok(_) => {}
2292 }
2293
2294 let by_id = launch_template.starts_with("lt-");
2297 let mut template_args = aws_global_args(profile, region);
2298 template_args.extend(["ec2".to_owned(), "describe-launch-templates".to_owned()]);
2299 template_args.extend([
2300 if by_id {
2301 "--launch-template-ids".to_owned()
2302 } else {
2303 "--launch-template-names".to_owned()
2304 },
2305 launch_template.to_owned(),
2306 ]);
2307 template_args.extend(["--output".to_owned(), "json".to_owned()]);
2308 let template =
2309 CommandSpec::new("aws", template_args).purpose("check the configured AWS launch template");
2310 let template_remediation = format!(
2311 "Create the launch template in {region}, or point this target at an existing one; `aws --profile {profile_label} --region {region} ec2 describe-launch-templates` lists them."
2312 );
2313 match executor.execute(&template) {
2314 Err(error) => DoctorCheck::fixable(
2315 check_id,
2316 title,
2317 format!("Could not query launch template {launch_template}: {error}"),
2318 template_remediation,
2319 ),
2320 Ok(output) if output.status != 0 => DoctorCheck::fixable(
2321 check_id,
2322 title,
2323 format!(
2324 "Launch template {launch_template} was not found in {region}: {}",
2325 String::from_utf8_lossy(&output.stderr).trim()
2326 ),
2327 template_remediation,
2328 ),
2329 Ok(_) => DoctorCheck::ready(
2330 check_id,
2331 title,
2332 format!(
2333 "The AWS CLI is installed, profile {profile_label} has valid credentials, and launch template {launch_template} exists in {region}."
2334 ),
2335 ),
2336 }
2337}
2338
2339fn daemon_build_check() -> DoctorCheck {
2347 const ID: &str = "daemon.build";
2348 const TITLE: &str = "Daemon build";
2349 let Ok(metadata) = mj_client::daemon::read_metadata_any() else {
2350 return DoctorCheck::ready(
2351 ID,
2352 TITLE,
2353 "No Mjolnir daemon is running; the next command starts one from this build.",
2354 );
2355 };
2356 let pid = metadata.pid;
2357 match mj_client::executable::process_runs_this_executable(pid) {
2358 Ok(Some(true)) => DoctorCheck::ready(
2359 ID,
2360 TITLE,
2361 format!(
2362 "Daemon {pid} runs this build (version {}).",
2363 mj_client::build_identity::this_build().describe()
2364 ),
2365 ),
2366 Ok(Some(false)) => DoctorCheck::warning(
2367 ID,
2368 TITLE,
2369 format!(
2370 "{}. Two builds can report the same version, so the files are what tell them apart. Code rebuilt since that daemon started is not running.",
2371 mj_client::executable::describe_running_daemon_and_client_builds(
2372 pid,
2373 &metadata.build_version,
2374 ),
2375 ),
2376 "Run `mj daemon restart` from this build. It now fails rather than reporting success if another client's build wins.",
2377 ),
2378 Ok(None) => DoctorCheck::ready(
2379 ID,
2380 TITLE,
2381 format!(
2382 "Daemon {pid} is recorded but not running; the next command starts one from this build."
2383 ),
2384 ),
2385 Err(error) => DoctorCheck::warning(
2386 ID,
2387 TITLE,
2388 format!("Could not tell which build daemon {pid} runs: {error:#}"),
2389 "Run `mj daemon restart` from this build if rebuilt code is not taking effect.",
2390 ),
2391 }
2392}
2393
2394fn worker_freshness_checks(config: ConfigStatus<'_>) -> Vec<DoctorCheck> {
2402 let mut checks = Vec::new();
2403 let daemon = mj_client::daemon::read_metadata_any()
2404 .ok()
2405 .filter(|metadata| mj_client::daemon::process_is_alive(metadata.pid));
2406 let pinned = pinned_worker_digests();
2407 let mut sources: Vec<(String, Result<WorkerBinaryAvailability>)> = Vec::new();
2408 if mj_core::targets::HOST_RUNS_WORKERS {
2411 sources.push((
2412 "this host".to_owned(),
2413 crate::controller::native_worker_binary_prerequisite(),
2414 ));
2415 }
2416 for arch in container_worker_architectures(config) {
2417 sources.push((
2418 format!("{arch} Linux targets"),
2419 worker_binary_prerequisite_for_arch(&arch),
2420 ));
2421 }
2422 for (label, availability) in sources {
2423 let id = format!("worker.freshness.{}", label.replace(' ', "-"));
2424 let title = format!("Worker binary for {label}");
2425 let path = match availability {
2426 Ok(WorkerBinaryAvailability::Local { path, .. }) => path,
2427 Ok(WorkerBinaryAvailability::Remote { .. }) => continue,
2430 Err(error) => {
2431 checks.push(DoctorCheck::unsupported(
2432 id,
2433 title,
2434 format!("No worker binary resolves for {label}: {error:#}"),
2435 ));
2436 continue;
2437 }
2438 };
2439 let digest = mj_core::worker_launch::worker_executable_digest(&path)
2440 .unwrap_or_else(|error| format!("unreadable ({error:#})"));
2441 let pinned_note = if pinned.is_empty() {
2442 "the daemon has pinned no worker".to_owned()
2443 } else if pinned.contains(&digest) {
2444 "this content is in the daemon's pinned worker cache".to_owned()
2445 } else {
2446 format!(
2447 "the pinned worker cache holds {} instead",
2448 pinned.join(", ")
2449 )
2450 };
2451 let detail = format!("{} has digest {digest}; {pinned_note}.", path.display());
2452 let Some(metadata) = daemon.as_ref() else {
2453 checks.push(DoctorCheck::ready(
2454 id,
2455 title,
2456 format!("{detail} No daemon is running, so the next session uses this file."),
2457 ));
2458 continue;
2459 };
2460 match worker_changed_since_daemon_start(&path, &metadata.started_at) {
2461 Ok(true) => checks.push(DoctorCheck::warning(
2462 id,
2463 title,
2464 format!("{detail} It was rebuilt after daemon {} started, which froze the copy it serves.", metadata.pid),
2465 "Run `mj daemon restart` so new sessions use the rebuilt worker. Sessions already running keep their worker until they are quiet enough to be upgraded.",
2466 )),
2467 Ok(false) => checks.push(DoctorCheck::ready(id, title, detail)),
2468 Err(error) => checks.push(DoctorCheck::warning(
2469 id,
2470 title,
2471 format!("{detail} Could not compare it with the daemon's start time: {error:#}"),
2472 "Run `mj daemon restart` if rebuilt worker code is not taking effect.",
2473 )),
2474 }
2475 }
2476 checks
2477}
2478
2479fn container_worker_architectures(config: ConfigStatus<'_>) -> Vec<String> {
2481 let Ok(config) = config else {
2482 return Vec::new();
2483 };
2484 let mut architectures = Vec::new();
2485 for target in config.targets.values() {
2486 let container = match target {
2487 TargetTemplate::LocalPodman { container }
2488 | TargetTemplate::LocalDocker { container }
2489 | TargetTemplate::AppleContainer { container }
2490 | TargetTemplate::SshPodman { container, .. }
2491 | TargetTemplate::SshDocker { container, .. } => container,
2492 _ => continue,
2493 };
2494 let arch = container
2495 .platform
2496 .as_deref()
2497 .and_then(|platform| platform.rsplit('/').next())
2498 .map_or_else(
2499 || std::env::consts::ARCH.to_owned(),
2500 normalized_worker_architecture,
2501 );
2502 if !architectures.contains(&arch) {
2503 architectures.push(arch);
2504 }
2505 }
2506 architectures
2507}
2508
2509fn normalized_worker_architecture(platform_arch: &str) -> String {
2512 crate::targets::normalize_architecture(platform_arch)
2513 .unwrap_or(platform_arch)
2514 .to_owned()
2515}
2516
2517fn pinned_worker_digests() -> Vec<String> {
2522 let root = mj_core::config::data_dir().join("workers").join("pinned");
2523 let Ok(entries) = std::fs::read_dir(root) else {
2524 return Vec::new();
2525 };
2526 let mut digests: Vec<String> = entries
2527 .flatten()
2528 .filter(|entry| entry.path().is_dir())
2529 .filter_map(|entry| entry.file_name().into_string().ok())
2530 .collect();
2531 digests.sort();
2532 digests
2533}
2534
2535fn worker_changed_since_daemon_start(path: &Path, started_at: &str) -> Result<bool> {
2540 let started: SystemTime = chrono::DateTime::parse_from_rfc3339(started_at)
2541 .map_err(|error| anyhow::anyhow!("parse daemon start time {started_at:?}: {error}"))?
2542 .into();
2543 let modified = std::fs::metadata(path)?.modified()?;
2544 Ok(modified > started)
2545}
2546
2547fn offered_targets<'a>(
2555 config: &Config,
2556 engine_checks: impl IntoIterator<Item = &'a DoctorCheck>,
2557) -> Config {
2558 let ready = engine_checks
2559 .into_iter()
2560 .filter(|check| check.status == CheckStatus::Ready)
2561 .map(|check| check.id.as_str())
2562 .collect::<std::collections::BTreeSet<_>>();
2563 let engine_ready = |target_id: &str| match target_id {
2564 "podman" => ready.contains("runtime.podman"),
2565 "docker" => ready.contains("runtime.docker"),
2566 "apple-container" => ready.contains("runtime.apple-container"),
2567 _ => true,
2568 };
2569 let mut offered = config.clone().with_local_targets();
2570 offered
2571 .targets
2572 .retain(|id, _| config.configures_target(id) || engine_ready(id));
2573 offered
2574}
2575
2576fn worker_binary_checks(config: ConfigStatus<'_>) -> Vec<DoctorCheck> {
2577 let config = match config {
2578 Ok(config) => config,
2579 Err(ConfigGap::NewerVersion(version)) => {
2580 return vec![newer_config_skip(
2581 "worker.containers",
2582 "Container worker binary",
2583 version,
2584 )];
2585 }
2586 Err(gap @ (ConfigGap::Missing | ConfigGap::Unreadable)) => {
2587 return vec![DoctorCheck::fixable(
2588 "worker.containers",
2589 "Container worker binary",
2590 format!(
2591 "Worker availability cannot be checked until {}.",
2592 gap.awaited()
2593 ),
2594 gap.remediation(),
2595 )];
2596 }
2597 };
2598 let containers = config
2599 .targets
2600 .iter()
2601 .filter_map(|(id, target)| match target {
2602 TargetTemplate::LocalPodman { container }
2603 | TargetTemplate::LocalDocker { container }
2604 | TargetTemplate::AppleContainer { container } => Some((id, container, None)),
2605 TargetTemplate::SshPodman { container, .. } => {
2606 Some((id, container, Some("ssh-podman")))
2607 }
2608 TargetTemplate::SshDocker { container, .. } => {
2609 Some((id, container, Some("ssh-docker")))
2610 }
2611 _ => None,
2612 })
2613 .collect::<Vec<_>>();
2614 if containers.is_empty() {
2615 return vec![DoctorCheck::unsupported(
2616 "worker.containers",
2617 "Container worker binary",
2618 "No container target is configured.",
2619 )];
2620 }
2621 containers
2622 .into_iter()
2623 .map(|(id, container, remote_kind)| {
2624 if let Some(remote_kind) = remote_kind
2625 && container.platform.is_none()
2626 {
2627 return DoctorCheck::unsupported(
2630 format!("worker.{id}"),
2631 format!("Container worker binary for target {id}"),
2632 format!(
2633 "Set `platform` on this {remote_kind} target to check its worker binary; the remote architecture is unknown until provisioning."
2634 ),
2635 );
2636 }
2637 worker_binary_check(id, container)
2638 })
2639 .collect()
2640}
2641
2642fn ssh_bare_worker_checks(
2646 config: ConfigStatus<'_>,
2647 executor: &impl CommandExecutor,
2648) -> Vec<DoctorCheck> {
2649 let Ok(config) = config else {
2650 return Vec::new();
2651 };
2652 config
2653 .targets
2654 .iter()
2655 .filter(|(_, target)| matches!(target, TargetTemplate::SshBare { .. }))
2656 .map(|(id, target)| {
2657 let check_id = format!("worker.{id}");
2658 let title = format!("Worker binary for SSH target {id}");
2659 match ssh_worker_binary_prerequisite(target, executor) {
2660 None => DoctorCheck::unsupported(
2661 check_id,
2662 title,
2663 format!(
2664 "The platform of this host could not be read over SSH, so its worker binary is not checked; see `runtime.ssh-bare.{id}`."
2665 ),
2666 ),
2667 Some((triple, result)) => worker_source_check(check_id, title, &triple, result),
2668 }
2669 })
2670 .collect()
2671}
2672
2673fn worker_binary_check(id: &str, container: &ContainerTemplate) -> DoctorCheck {
2674 let title = format!("Container worker binary for target {id}");
2675 let arch = match container_architecture(container.platform.as_deref()) {
2676 Ok(arch) => arch,
2677 Err(reason) => {
2678 return DoctorCheck::unsupported(format!("worker.{id}"), title, reason);
2679 }
2680 };
2681 let triple = format!("{arch}-unknown-linux-musl");
2682 worker_source_check(
2683 format!("worker.{id}"),
2684 title,
2685 &triple,
2686 worker_binary_prerequisite_for_arch(arch),
2687 )
2688}
2689
2690fn worker_source_check(
2691 check_id: String,
2692 title: String,
2693 triple: &str,
2694 source: anyhow::Result<WorkerBinaryAvailability>,
2695) -> DoctorCheck {
2696 match source {
2697 Ok(WorkerBinaryAvailability::Local { path, source }) => DoctorCheck::ready(
2698 check_id,
2699 title,
2700 format!(
2701 "{triple} worker is available from {source}: {}",
2702 path.display()
2703 ),
2704 ),
2705 Ok(WorkerBinaryAvailability::Remote { url, .. }) => DoctorCheck::ready(
2706 check_id,
2707 title,
2708 format!("{triple} worker will be verified and downloaded from {url} when needed."),
2709 ),
2710 Err(error) => DoctorCheck::fixable(
2711 check_id,
2712 title,
2713 format!("No usable {triple} worker source: {error:#}"),
2714 format!(
2715 "Build it with `cargo build --release --target {triple} -p brokk-mj-worker --bin mj-worker`, install `mj-worker-{triple}` beside `mj`, or set MJ_WORKER_BINARY, MJ_WORKER_DIR, or MJ_WORKER_URL with MJ_WORKER_SHA256."
2716 ),
2717 ),
2718 }
2719}
2720
2721fn container_architecture(platform: Option<&str>) -> std::result::Result<&'static str, String> {
2722 let candidate = platform.unwrap_or(std::env::consts::ARCH);
2723 let candidate = candidate
2724 .split('/')
2725 .rev()
2726 .find(|part| matches!(*part, "x86_64" | "amd64" | "aarch64" | "arm64"))
2727 .unwrap_or(candidate);
2728 crate::targets::normalize_architecture(candidate).map_err(|_| format!(
2729 "Container architecture {candidate:?} is unsupported; Mjolnir supports x86_64 and aarch64 Linux workers."
2730 ))
2731}
2732
2733fn apple_container_image(config: ConfigStatus<'_>) -> String {
2734 config
2735 .ok()
2736 .and_then(|config| {
2737 config.targets.values().find_map(|target| match target {
2738 TargetTemplate::AppleContainer { container } => Some(container.image.clone()),
2739 _ => None,
2740 })
2741 })
2742 .unwrap_or_else(|| DEFAULT_CONTAINER_IMAGE.into())
2743}
2744
2745pub fn apple_container_runtime_check(
2747 platform: &ApplePlatform,
2748 executor: &impl CommandExecutor,
2749) -> DoctorCheck {
2750 match platform {
2751 ApplePlatform::Linux => {
2752 return DoctorCheck::unsupported(
2753 "runtime.apple-container",
2754 "Apple container runtime",
2755 "macOS only",
2756 );
2757 }
2758 ApplePlatform::Other(current) => {
2759 return DoctorCheck::unsupported(
2760 "runtime.apple-container",
2761 "Apple container runtime",
2762 format!("macOS only (current platform: {current})"),
2763 );
2764 }
2765 ApplePlatform::Macos {
2766 architecture,
2767 major_version,
2768 } if architecture != "aarch64" && architecture != "arm64" => {
2769 return DoctorCheck::unsupported(
2770 "runtime.apple-container",
2771 "Apple container runtime",
2772 "Apple container requires Apple silicon; Intel Macs are unsupported.",
2773 );
2774 }
2775 ApplePlatform::Macos { major_version, .. } if *major_version < 26 => {
2776 return DoctorCheck::unsupported(
2777 "runtime.apple-container",
2778 "Apple container runtime",
2779 format!("Apple container requires macOS 26 or newer (found {major_version})."),
2780 );
2781 }
2782 ApplePlatform::Macos { .. } => {}
2783 }
2784
2785 apple_container_daemon_check(executor)
2786}
2787
2788pub fn apple_container_check(
2789 platform: &ApplePlatform,
2790 executor: &impl CommandExecutor,
2791 smoke: bool,
2792 image: String,
2793) -> DoctorCheck {
2794 let daemon = apple_container_runtime_check(platform, executor);
2795 if daemon.status != CheckStatus::Ready {
2796 return daemon;
2797 }
2798
2799 if !smoke {
2800 return DoctorCheck::fixable(
2801 "runtime.apple-container",
2802 "Apple container runtime",
2803 "The daemon is running, but the required disposable smoke test was not requested.",
2804 "Run `mj doctor --json --smoke`.",
2805 );
2806 }
2807
2808 let target = RuntimeTargetTemplate::AppleContainer(RuntimeContainerTemplate {
2809 build_cache: None,
2810 image,
2811 pull_policy: Default::default(),
2812 extra_run_args: vec![],
2813 workspace_storage: Default::default(),
2814 });
2815 match run_setup_smoke_test(&target, &doctor_smoke_id(), executor) {
2816 Ok(()) => DoctorCheck::ready(
2817 "runtime.apple-container",
2818 "Apple container runtime",
2819 "Installed, daemon running, and disposable run/exec/remove smoke test passed.",
2820 ),
2821 Err(error) => DoctorCheck::fixable(
2822 "runtime.apple-container",
2823 "Apple container runtime",
2824 format!("Disposable run/exec/remove smoke test failed: {error:#}"),
2825 "Fix the configured image or container runtime, then run `mj doctor --json --smoke` again.",
2826 ),
2827 }
2828}
2829
2830fn apple_container_daemon_check(executor: &impl CommandExecutor) -> DoctorCheck {
2835 let installed =
2836 CommandSpec::new("container", ["--version"]).purpose("check Apple container installation");
2837 match executor.execute(&installed) {
2838 Err(error) => {
2839 return DoctorCheck::fixable(
2840 "runtime.apple-container",
2841 "Apple container runtime",
2842 format!(
2843 "The `container` command is not available: {}",
2844 error.root_cause()
2845 ),
2846 format!("Install the official signed package: {APPLE_CONTAINER_INSTALL_URL}"),
2847 );
2848 }
2849 Ok(output) if output.status != 0 => {
2850 return DoctorCheck::fixable(
2851 "runtime.apple-container",
2852 "Apple container runtime",
2853 format!(
2854 "The installed `container --version` command failed: {}",
2855 String::from_utf8_lossy(&output.stderr).trim()
2856 ),
2857 format!("Reinstall the official signed package: {APPLE_CONTAINER_INSTALL_URL}"),
2858 );
2859 }
2860 Ok(_) => {}
2861 }
2862
2863 let status =
2864 CommandSpec::new("container", ["system", "status"]).purpose("check Apple container daemon");
2865 match executor.execute(&status) {
2866 Ok(output) if output.status == 0 => DoctorCheck::ready(
2867 "runtime.apple-container",
2868 "Apple container runtime",
2869 "Installed, and the Apple container daemon is running.",
2870 ),
2871 Ok(output) => DoctorCheck::fixable(
2872 "runtime.apple-container",
2873 "Apple container runtime",
2874 format!(
2875 "The Apple container daemon is stopped: {}",
2876 String::from_utf8_lossy(&output.stderr).trim()
2877 ),
2878 "Run `container system start`.",
2879 ),
2880 Err(error) => DoctorCheck::fixable(
2881 "runtime.apple-container",
2882 "Apple container runtime",
2883 format!("Could not query the Apple container daemon: {error}"),
2884 "Run `container system start`.",
2885 ),
2886 }
2887}
2888
2889pub fn current_apple_platform(executor: &impl CommandExecutor) -> ApplePlatform {
2890 if cfg!(target_os = "linux") {
2891 return ApplePlatform::Linux;
2892 }
2893 if !cfg!(target_os = "macos") {
2894 return ApplePlatform::Other(std::env::consts::OS.into());
2895 }
2896 let major_version = executor
2897 .execute(&CommandSpec::new("sw_vers", ["-productVersion"]).purpose("detect macOS version"))
2898 .ok()
2899 .filter(|output| output.status == 0)
2900 .and_then(|output| {
2901 String::from_utf8(output.stdout)
2902 .ok()
2903 .and_then(|value| value.trim().split('.').next()?.parse().ok())
2904 })
2905 .unwrap_or(0);
2906 ApplePlatform::Macos {
2907 architecture: std::env::consts::ARCH.into(),
2908 major_version,
2909 }
2910}
2911
2912#[cfg(test)]
2913mod tests;
2914
2915#[derive(Debug, Default, PartialEq, Eq)]
2917pub(crate) struct ReviewResidue {
2918 pub refs: Vec<String>,
2920 pub scratch_indexes: Vec<PathBuf>,
2922}
2923
2924impl ReviewResidue {
2925 fn is_empty(&self) -> bool {
2926 self.refs.is_empty() && self.scratch_indexes.is_empty()
2927 }
2928}
2929
2930pub(crate) fn review_residue(repository: &Path) -> ReviewResidue {
2937 let mut residue = ReviewResidue::default();
2938 let git_dir = repository.join(".git");
2939 if !git_dir.exists() {
2940 return residue;
2941 }
2942 for reference in ["review-baseline", "review-capture"] {
2943 if git_dir.join("refs/hel").join(reference).is_file() {
2944 residue.refs.push(format!("refs/hel/{reference}"));
2945 }
2946 }
2947 if let Ok(packed) = std::fs::read_to_string(git_dir.join("packed-refs")) {
2949 for line in packed.lines() {
2950 if let Some((_, reference)) = line.split_once(' ')
2951 && reference.starts_with("refs/hel/")
2952 && !residue.refs.iter().any(|known| known == reference)
2953 {
2954 residue.refs.push(reference.to_owned());
2955 }
2956 }
2957 }
2958 if let Ok(entries) = std::fs::read_dir(&git_dir) {
2959 for entry in entries.filter_map(Result::ok) {
2960 if entry
2961 .file_name()
2962 .to_str()
2963 .is_some_and(|name| name.starts_with("hel-review-index-"))
2964 {
2965 residue.scratch_indexes.push(entry.path());
2966 }
2967 }
2968 }
2969 residue.refs.sort();
2970 residue.scratch_indexes.sort();
2971 residue
2972}
2973
2974pub(crate) fn review_residue_repositories(
2985 configured: impl IntoIterator<Item = PathBuf>,
2986 sessions: &[&mj_core::state::SessionRecord],
2987) -> Vec<PathBuf> {
2988 use mj_core::state::ManagedCheckoutKind;
2989
2990 let managed_roots = sessions
2991 .iter()
2992 .filter_map(|session| session.managed_worktree.as_ref())
2993 .map(|worktree| worktree.worktree_root.as_path())
2994 .collect::<Vec<_>>();
2995 let mut in_use = Vec::new();
2996 for session in sessions.iter().filter(|session| session.state.is_active()) {
2997 if let Some(directory) = &session.project_directory {
2998 in_use.push(directory.as_path());
2999 }
3000 if let Some(worktree) = &session.managed_worktree
3001 && worktree.kind == ManagedCheckoutKind::Worktree
3002 {
3003 in_use.push(worktree.source_repository.as_path());
3004 }
3005 }
3006 let mut repositories = configured
3007 .into_iter()
3008 .chain(
3009 sessions
3010 .iter()
3011 .filter_map(|session| session.project_directory.clone()),
3012 )
3013 .filter(|repository| {
3014 !is_inside_managed_checkout(repository)
3015 && !managed_roots
3016 .iter()
3017 .any(|root| repository.starts_with(root))
3018 && !in_use.iter().any(|directory| repository == directory)
3019 })
3020 .collect::<Vec<_>>();
3021 repositories.sort();
3022 repositories.dedup();
3023 repositories
3024}
3025
3026fn is_inside_managed_checkout(path: &Path) -> bool {
3029 let components = path
3030 .components()
3031 .map(|component| component.as_os_str())
3032 .collect::<Vec<_>>();
3033 components
3034 .windows(3)
3035 .any(|window| window[0] == ".mj" && (window[1] == "clones" || window[1] == "worktrees"))
3036}
3037
3038fn review_residue_checks(config: ConfigStatus<'_>) -> Vec<DoctorCheck> {
3044 let Ok(config) = config else {
3045 return Vec::new();
3046 };
3047 let configured = config
3048 .bundles
3049 .values()
3050 .flat_map(|bundle| bundle.repositories.iter())
3051 .filter_map(|repository| repository.local.clone());
3052 let state = crate::database::load_state().ok();
3055 let sessions = state
3056 .as_ref()
3057 .map(|state| state.sessions.values().collect::<Vec<_>>())
3058 .unwrap_or_default();
3059 let repositories = review_residue_repositories(configured, &sessions);
3060 if repositories.is_empty() {
3061 return Vec::new();
3062 }
3063 let found = repositories
3064 .into_iter()
3065 .map(|repository| {
3066 let residue = review_residue(&repository);
3067 (repository, residue)
3068 })
3069 .filter(|(_, residue)| !residue.is_empty())
3070 .collect::<Vec<_>>();
3071 if found.is_empty() {
3072 return vec![DoctorCheck::ready(
3073 "review.residue",
3074 "Review leftovers in your repositories",
3075 "No Mjolnir refs or scratch index files were found in the configured repositories.",
3076 )];
3077 }
3078 let detail = found
3079 .iter()
3080 .map(|(repository, residue)| {
3081 let mut parts = Vec::new();
3082 if !residue.refs.is_empty() {
3083 parts.push(residue.refs.join(", "));
3084 }
3085 if !residue.scratch_indexes.is_empty() {
3086 parts.push(mj_core::text::counted(
3087 residue.scratch_indexes.len(),
3088 "leftover scratch index file",
3089 "leftover scratch index files",
3090 ));
3091 }
3092 format!("{}: {}", repository.display(), parts.join("; "))
3093 })
3094 .collect::<Vec<_>>()
3095 .join(". ");
3096 let commands = found
3097 .iter()
3098 .flat_map(|(repository, residue)| {
3099 let repository = repository.display().to_string();
3100 let mut commands = residue
3101 .refs
3102 .iter()
3103 .map(|reference| format!("git -C {repository} update-ref -d {reference}"))
3104 .collect::<Vec<_>>();
3105 commands.extend(
3106 residue
3107 .scratch_indexes
3108 .iter()
3109 .map(|index| format!("rm -f {}", index.display())),
3110 );
3111 commands.push(format!("git -C {repository} gc --prune=now"));
3112 commands
3113 })
3114 .collect::<Vec<_>>()
3115 .join("\n");
3116 vec![DoctorCheck::fixable(
3117 "review.residue",
3118 "Review leftovers in your repositories",
3119 format!("Mjolnir left these in repositories it does not own: {detail}."),
3120 format!("Remove them yourself when you are ready:\n{commands}"),
3121 )]
3122}