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::{WorkerBinaryAvailability, worker_binary_prerequisite_for_arch};
11use crate::setup::{
12 DiscoveredHome, discover_harness_homes_with_executor, harness_is_authenticated_with_executor,
13};
14use crate::targets::{
15 BoundedProcessExecutor, CommandExecutor, CommandSpec, CommandTimedOut,
16 ContainerTemplate as RuntimeContainerTemplate, PodmanProbe, ProcessExecutor,
17 SshTarget as RuntimeSshTarget, TargetTemplate as RuntimeTargetTemplate, failed_podman_probe,
18 run_setup_smoke_test, ssh_command, ssh_connectivity_probe, ssh_validation_command,
19 verify_local_docker, verify_local_podman, verify_ssh_docker, verify_ssh_podman,
20};
21use mj_core::config::{
22 Config, ContainerTemplate, HarnessHost, HarnessKind, HarnessProfile, TargetTemplate,
23 config_path,
24};
25use mj_core::credentials::login_command;
26
27const DEFAULT_CONTAINER_IMAGE: &str = "ubuntu:24.04";
33const APPLE_CONTAINER_INSTALL_URL: &str = "https://github.com/apple/container#initial-install";
34
35pub const PROBE_TIMEOUT: Duration = Duration::from_secs(15);
42
43pub const fn probe_executor() -> BoundedProcessExecutor {
46 BoundedProcessExecutor::new(PROBE_TIMEOUT)
47}
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
50#[serde(rename_all = "lowercase")]
51pub enum CheckStatus {
52 Ready,
53 Warning,
54 Fixable,
55 Unsupported,
56}
57
58impl CheckStatus {
59 pub const fn label(self) -> &'static str {
60 match self {
61 Self::Ready => "ready",
62 Self::Warning => "warning",
63 Self::Fixable => "fixable",
64 Self::Unsupported => "unsupported",
65 }
66 }
67}
68
69#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
70pub struct DoctorCheck {
71 pub id: String,
72 pub title: String,
73 pub status: CheckStatus,
74 pub detail: String,
75 pub remediation: Option<String>,
76}
77
78impl DoctorCheck {
79 fn ready(id: impl Into<String>, title: impl Into<String>, detail: impl Into<String>) -> Self {
80 Self {
81 id: id.into(),
82 title: title.into(),
83 status: CheckStatus::Ready,
84 detail: detail.into(),
85 remediation: None,
86 }
87 }
88
89 fn warning(
90 id: impl Into<String>,
91 title: impl Into<String>,
92 detail: impl Into<String>,
93 remediation: impl Into<String>,
94 ) -> Self {
95 Self {
96 id: id.into(),
97 title: title.into(),
98 status: CheckStatus::Warning,
99 detail: detail.into(),
100 remediation: Some(remediation.into()),
101 }
102 }
103
104 pub(crate) fn fixable(
105 id: impl Into<String>,
106 title: impl Into<String>,
107 detail: impl Into<String>,
108 remediation: impl Into<String>,
109 ) -> Self {
110 Self {
111 id: id.into(),
112 title: title.into(),
113 status: CheckStatus::Fixable,
114 detail: detail.into(),
115 remediation: Some(remediation.into()),
116 }
117 }
118
119 fn unsupported(
120 id: impl Into<String>,
121 title: impl Into<String>,
122 detail: impl Into<String>,
123 ) -> Self {
124 Self {
125 id: id.into(),
126 title: title.into(),
127 status: CheckStatus::Unsupported,
128 detail: detail.into(),
129 remediation: None,
130 }
131 }
132}
133
134#[derive(Debug, Clone, Copy, PartialEq, Eq)]
135pub struct DoctorOptions {
136 pub smoke: bool,
137}
138
139#[derive(Debug, Clone, PartialEq, Eq)]
140pub enum ApplePlatform {
141 Linux,
142 Macos {
143 architecture: String,
144 major_version: u32,
145 },
146 Other(String),
147}
148
149#[derive(Debug, Clone, Copy, PartialEq, Eq)]
150pub enum InstructionsPlatform {
151 Linux,
152 Macos,
153}
154
155pub fn run_current(options: DoctorOptions) -> Vec<DoctorCheck> {
156 if options.smoke {
157 return run_with(
161 &ProcessExecutor,
162 current_apple_platform(&ProcessExecutor),
163 options,
164 );
165 }
166 let executor = probe_executor();
167 run_with(&executor, current_apple_platform(&executor), options)
168}
169
170pub fn run_with(
171 executor: &impl CommandExecutor,
172 apple_platform: ApplePlatform,
173 options: DoctorOptions,
174) -> Vec<DoctorCheck> {
175 run_with_config_path(&config_path(), executor, apple_platform, options)
176}
177
178pub fn run_with_config_path(
184 config_path: &Path,
185 executor: &impl CommandExecutor,
186 apple_platform: ApplePlatform,
187 options: DoctorOptions,
188) -> Vec<DoctorCheck> {
189 let (config, mut checks) = configuration_checks(config_path);
190 checks.push(harness_discovery_check(config.as_ref(), executor));
191 checks.extend(harness_checks(config.as_ref(), executor));
192 checks.extend(subagent_eligibility_checks(config.as_ref()));
193 checks.extend(podman_checks(config.as_ref(), executor, options.smoke));
194 checks.extend(docker_checks(config.as_ref(), executor, options.smoke));
195 checks.extend(ssh_bare_checks(config.as_ref(), executor));
196 checks.extend(ssh_podman_checks(config.as_ref(), executor, options.smoke));
197 checks.extend(ssh_docker_checks(config.as_ref(), executor, options.smoke));
198 checks.extend(aws_checks(config.as_ref(), executor));
199 checks.extend(worker_binary_checks(config.as_ref()));
200 checks.push(daemon_build_check());
201 checks.extend(worker_freshness_checks(config.as_ref()));
202 checks.extend(review_residue_checks(config.as_ref()));
203 checks.push(apple_container_check(
204 &apple_platform,
205 executor,
206 options.smoke,
207 apple_container_image(config.as_ref()),
208 ));
209 checks
210}
211
212fn harness_discovery_check(
213 config: Option<&Config>,
214 executor: &impl CommandExecutor,
215) -> DoctorCheck {
216 let home = dirs::home_dir();
217 let overrides = HarnessKind::ALL.into_iter().filter_map(|kind| {
218 std::env::var_os(kind.home_env()).map(|path| (kind, kind.home_from_environment(path)))
219 });
220 let discovered = discover_harness_homes_with_executor(home.as_deref(), overrides, executor);
221 harness_discovery_check_from(
222 &discovered,
223 config.is_some_and(|config| !config.profiles.is_empty()),
224 )
225}
226
227fn harness_discovery_check_from(
228 discovered: &[DiscoveredHome],
229 has_configured_profiles: bool,
230) -> DoctorCheck {
231 if discovered.is_empty() {
232 return if has_configured_profiles {
233 DoctorCheck::ready(
234 "harness.discovery",
235 "Harness home discovery",
236 "No default or environment-overridden harness homes were found; configured profile homes are checked below.",
237 )
238 } else {
239 DoctorCheck::fixable(
240 "harness.discovery",
241 "Harness home discovery",
242 "No Codex, Claude Code, Kimi Code, or Grok Build home was found in the default or environment-overridden locations.",
243 "Install and sign in to a supported harness, then open F7 Settings → Agent Profiles.",
244 )
245 };
246 }
247
248 let homes = discovered
249 .iter()
250 .map(|home| {
251 let authentication = if home.authenticated {
252 "authenticated"
253 } else {
254 "not authenticated"
255 };
256 format!(
257 "{} at {} ({authentication})",
258 home.kind.display_name(),
259 home.path.display()
260 )
261 })
262 .collect::<Vec<_>>()
263 .join("; ");
264 DoctorCheck::ready(
265 "harness.discovery",
266 "Harness home discovery",
267 format!("Discovered {homes}. Configured profile authentication is checked below."),
268 )
269}
270
271pub fn all_ready(checks: &[DoctorCheck]) -> bool {
272 checks
273 .iter()
274 .all(|check| check.status != CheckStatus::Fixable)
275}
276
277pub fn render_human(checks: &[DoctorCheck], output: &mut impl Write) -> Result<()> {
278 for check in checks {
279 writeln!(
280 output,
281 "{} {}: {}",
282 check.status.label(),
283 check.title,
284 check.detail
285 )?;
286 if let Some(remediation) = &check.remediation {
287 writeln!(output, " remediation: {remediation}")?;
288 }
289 }
290 Ok(())
291}
292
293pub fn setup_instructions(platform: InstructionsPlatform) -> String {
294 match platform {
295 InstructionsPlatform::Linux => format!(
296 "# Hel setup instructions for Linux\n\n\
297This page is self-contained. Follow this exact loop as the user who will run Hel:\n\n\
2981. Run `mj doctor --json`.\n\
2992. Follow every `fixable` remediation from its JSON output.\n\
3003. Run `mj doctor --json` again. Repeat until no check is `fixable`.\n\
3014. Finish with `mj doctor --json --smoke` to verify every configured container\n\
302 image end to end, and resolve anything it reports as `fixable`.\n\n\
303For a coding-agent handoff, provide this entire instructions page together with\n\
304the latest `mj doctor --json` output.\n\n\
305## Linux container-runtime postconditions\n\n{}\n\n{}",
306 crate::targets::PODMAN_DOCUMENTATION,
307 crate::targets::DOCKER_DOCUMENTATION
308 ),
309 InstructionsPlatform::Macos => format!(
310 "# Hel setup instructions for macOS\n\n\
311This page is self-contained. Follow this exact loop as the user who will run Hel:\n\n\
3121. Run `mj doctor --json`.\n\
3132. Follow every `fixable` remediation from its JSON output.\n\
3143. Run `mj doctor --json` again. Repeat until no check is `fixable`.\n\n\
315For a coding-agent handoff, provide this entire instructions page together with\n\
316the latest `mj doctor --json` output.\n\n\
317## Apple container runtime\n\n\
318Hel's Apple container target requires Apple silicon and macOS 26 or newer.\n\
319On an Intel Mac or an older macOS release, the target is unsupported; use a\n\
320local Podman, SSH, or AWS target instead.\n\n\
321If the `container` command is absent, install only the official signed package:\n\n\
322<https://github.com/apple/container#initial-install>\n\n\
323Hel never downloads or installs that package. If doctor reports a stopped\n\
324daemon, run exactly:\n\n```console\ncontainer system start\n```\n\n\
325Finish with the opt-in disposable runtime test in JSON mode:\n\n```console\nmj doctor --json --smoke\n```\n\n\
326Apple container is ready only when that smoke test creates a disposable\n\
327container, executes `true` in it, and removes it successfully. Use the image\n\
328configured by an `apple-container` target; without one, doctor uses\n\
329`{DEFAULT_CONTAINER_IMAGE}` for the smoke test.\n\n\
330## Shared Hel prerequisites\n\n\
331`mj doctor --json` also checks the configuration, each configured harness home\n\
332and authentication marker, selected container worker binaries, and any relevant\n\
333Podman prerequisites. Resolve every `fixable` status before starting a session."
334 ),
335 }
336}
337
338fn configuration_checks(path: &Path) -> (Option<Config>, Vec<DoctorCheck>) {
339 if !path.exists() {
340 return (
341 None,
342 vec![DoctorCheck::fixable(
343 "config",
344 "Mjolnir configuration",
345 format!("{} does not exist", path.display()),
346 "Open Mjolnir and press F7 for Settings to add an agent profile.",
347 )],
348 );
349 }
350 if let Some(found) = mj_core::config::newer_version_on_disk(path) {
354 return (
355 None,
356 vec![DoctorCheck::fixable(
357 "config",
358 "Mjolnir configuration",
359 format!(
360 "{} was written by a newer Mjolnir (config version {found}; this build supports {})",
361 path.display(),
362 mj_core::config::CONFIG_VERSION
363 ),
364 "Update Mjolnir to that build or newer. Do not lower the version value by hand or replace the file.",
365 )],
366 );
367 }
368 match Config::load_from(path) {
369 Ok(config) => {
370 let mut checks = vec![DoctorCheck::ready(
371 "config",
372 "Mjolnir configuration",
373 format!("{} is valid", path.display()),
374 )];
375 if config.enabled_profiles().next().is_none() || config.bundles.is_empty() {
376 checks.push(DoctorCheck::fixable(
377 "config.session-prerequisites",
378 "Session configuration",
379 "An enabled profile and project bundle are required for configured bundle sessions. Local targets are supplied automatically.",
380 "Open F7 Settings to add or enable agent profiles and projects.",
381 ));
382 } else {
383 checks.push(DoctorCheck::ready(
384 "config.session-prerequisites",
385 "Session configuration",
386 "At least one profile, bundle, and target are configured.",
387 ));
388 }
389 (Some(config), checks)
390 }
391 Err(error) => (
392 None,
393 vec![DoctorCheck::fixable(
394 "config",
395 "Mjolnir configuration",
396 format!("{} is invalid: {error:#}", path.display()),
397 "Fix the reported TOML error in config.toml, or run `mj setup` to replace it.",
398 )],
399 ),
400 }
401}
402
403fn harness_checks(config: Option<&Config>, executor: &impl CommandExecutor) -> Vec<DoctorCheck> {
404 let Some(config) = config else {
405 return vec![DoctorCheck::fixable(
406 "harness.profiles",
407 "Harness profiles",
408 "Harness homes cannot be checked until config.toml is valid.",
409 "Fix config.toml, then rerun `mj doctor --json`.",
410 )];
411 };
412 if config.profiles.is_empty() {
413 return vec![DoctorCheck::fixable(
414 "harness.profiles",
415 "Harness profiles",
416 "No harness profiles are configured.",
417 "Open F7 Settings → Agent Profiles to detect accounts or add a profile.",
418 )];
419 }
420 config
421 .profiles
422 .iter()
423 .map(|(id, profile)| {
424 let title = format!("Harness profile {id}");
425 if !profile.enabled {
426 return DoctorCheck::ready(
427 format!("harness.{id}"),
428 title,
429 "Profile is disabled; home and authentication checks were skipped.",
430 );
431 }
432 if let Some(default_home) = unscopable_home_is_ignored(config, profile) {
433 return DoctorCheck::fixable(
434 format!("harness.{id}"),
435 title,
436 format!(
437 "{} is ignored by a session on this machine: {} on macOS reads {} \
438 whatever {} says",
439 profile.home.display(),
440 profile.kind.display_name(),
441 default_home.display(),
442 profile.kind.home_env(),
443 ),
444 format!(
445 "Set this profile's home to {}, or use it only on container and SSH \
446 targets, where the home is still scoped.",
447 default_home.display()
448 ),
449 );
450 }
451 if !profile.home.is_dir() {
452 return DoctorCheck::fixable(
453 format!("harness.{id}"),
454 title,
455 format!("{} does not exist", profile.home.display()),
456 format!(
457 "{} If this profile should use an existing installation, select its home in Setup.",
458 harness_login_remediation(id, profile)
459 ),
460 );
461 }
462 if !harness_is_authenticated_with_executor(profile, executor) {
463 return DoctorCheck::fixable(
464 format!("harness.{id}"),
465 title,
466 format!(
467 "No usable authentication was detected for {}",
468 profile.home.display()
469 ),
470 harness_login_remediation(id, profile),
471 );
472 }
473 DoctorCheck::ready(
474 format!("harness.{id}"),
475 title,
476 format!(
477 "{} is present and authentication is available",
478 profile.home.display()
479 ),
480 )
481 })
482 .collect()
483}
484
485fn unscopable_home_is_ignored(config: &Config, profile: &HarnessProfile) -> Option<PathBuf> {
493 if profile
494 .kind
495 .scopes_home_with_environment(HarnessHost::current())
496 {
497 return None;
498 }
499 if !config
503 .targets
504 .values()
505 .any(|target| matches!(target, TargetTemplate::LocalBare))
506 {
507 return None;
508 }
509 let default_home = dirs::home_dir()?.join(profile.kind.default_home_leaf());
510 (profile.home != default_home).then_some(default_home)
511}
512
513fn subagent_eligibility_checks(config: Option<&Config>) -> Vec<DoctorCheck> {
521 let Some(config) = config else {
522 return Vec::new();
523 };
524 config
525 .subagents
526 .eligible_profiles
527 .iter()
528 .filter(|(_, eligible)| **eligible)
529 .filter_map(|(id, _)| {
530 let profile = config.profiles.get(id)?;
531 (!profile.enabled).then(|| {
532 DoctorCheck::warning(
533 format!("subagents.{id}"),
534 format!("Sub-agent profile {id}"),
535 format!(
536 "Profile {id:?} is listed in [subagents.eligible_profiles] but is disabled, so it is not offered for sub-agent use."
537 ),
538 format!(
539 "Re-enable profile {id:?}, or remove it from [subagents.eligible_profiles]."
540 ),
541 )
542 })
543 })
544 .collect()
545}
546
547fn harness_login_remediation(id: &str, profile: &HarnessProfile) -> String {
554 let (program, arguments) = match login_command(profile) {
555 Ok(command) => command,
556 Err(error) => return format!("{error} Check {}.", profile.home.display()),
560 };
561 format!(
562 "Run `mj login --profile {id}`; it runs `{program} {}` against {}.",
563 arguments.join(" "),
564 profile.home.display()
565 )
566}
567
568fn podman_checks(
573 config: Option<&Config>,
574 executor: &impl CommandExecutor,
575 smoke: bool,
576) -> Vec<DoctorCheck> {
577 let preflight = podman_check(config, executor);
578 let preflight_passed = preflight.status == CheckStatus::Ready;
579 let mut checks = vec![preflight];
580 if preflight_passed {
581 checks.extend(podman_image_checks(config, executor, smoke));
582 }
583 checks
584}
585
586fn podman_check(config: Option<&Config>, executor: &impl CommandExecutor) -> DoctorCheck {
587 let Some(config) = config else {
588 return DoctorCheck::unsupported(
589 "runtime.podman",
590 "Rootless Podman",
591 "Podman prerequisites cannot be evaluated until config.toml is valid.",
592 );
593 };
594 if local_podman_targets(config).is_empty() {
595 return DoctorCheck::unsupported(
596 "runtime.podman",
597 "Rootless Podman",
598 "No local-podman target is configured.",
599 );
600 }
601 local_podman_runtime_check(executor)
602}
603
604pub fn local_podman_runtime_check(executor: &impl CommandExecutor) -> DoctorCheck {
611 match verify_local_podman(executor) {
612 Ok(preflight) => DoctorCheck::ready(
613 "runtime.podman",
614 "Rootless Podman",
615 format!("Podman {} has a valid rootless UID map.", preflight.version),
616 ),
617 Err(error) => {
618 let detail = format!("{error:#}");
619 DoctorCheck::fixable(
620 "runtime.podman",
621 "Rootless Podman",
622 detail,
623 podman_remediation(&error),
624 )
625 }
626 }
627}
628
629fn local_podman_targets(config: &Config) -> Vec<(&String, &ContainerTemplate)> {
630 config
631 .targets
632 .iter()
633 .filter_map(|(id, target)| match target {
634 TargetTemplate::LocalPodman { container } => Some((id, container)),
635 _ => None,
636 })
637 .collect()
638}
639
640fn podman_image_checks(
641 config: Option<&Config>,
642 executor: &impl CommandExecutor,
643 smoke: bool,
644) -> Vec<DoctorCheck> {
645 let Some(config) = config else {
646 return Vec::new();
647 };
648 local_podman_targets(config)
649 .into_iter()
650 .map(|(id, container)| podman_image_check(id, &container.image, executor, smoke))
651 .collect()
652}
653
654fn podman_image_check(
655 id: &str,
656 image: &str,
657 executor: &impl CommandExecutor,
658 smoke: bool,
659) -> DoctorCheck {
660 let check_id = format!("runtime.podman.image.{id}");
661 let title = format!("Podman image for target {id}");
662 if smoke {
663 let target = RuntimeTargetTemplate::LocalPodman(RuntimeContainerTemplate {
664 build_cache: None,
665 image: image.to_owned(),
666 pull_policy: Default::default(),
667 extra_run_args: vec![],
668 workspace_storage: Default::default(),
669 });
670 return match run_setup_smoke_test(&target, &doctor_smoke_id(), executor) {
671 Ok(()) => DoctorCheck::ready(
672 check_id,
673 title,
674 format!("Disposable run/exec/remove smoke test passed for image {image}."),
675 ),
676 Err(error) => DoctorCheck::fixable(
677 check_id,
678 title,
679 format!(
680 "Disposable run/exec/remove smoke test failed for image {image}: {error:#}"
681 ),
682 "Fix the configured image or Podman runtime, then run `mj doctor --json --smoke` again.",
683 ),
684 };
685 }
686
687 let command = CommandSpec::new("podman", ["image", "exists", image])
688 .purpose("check Podman image presence");
689 match executor.execute(&command) {
690 Ok(output) if output.status == 0 => DoctorCheck::ready(
691 check_id,
692 title,
693 format!("Image {image} is present in local Podman storage."),
694 ),
695 Ok(_) => DoctorCheck::fixable(
696 check_id,
697 title,
698 format!("Image {image} is not present in local Podman storage."),
699 missing_image_remediation(image),
700 ),
701 Err(error) => DoctorCheck::fixable(
702 check_id,
703 title,
704 format!(
705 "Could not check whether image {image} is present in local Podman storage: {error}"
706 ),
707 missing_image_remediation(image),
708 ),
709 }
710}
711
712fn missing_image_remediation(image: &str) -> String {
713 format!(
714 "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."
715 )
716}
717
718fn docker_checks(
720 config: Option<&Config>,
721 executor: &impl CommandExecutor,
722 smoke: bool,
723) -> Vec<DoctorCheck> {
724 let Some(config) = config else {
725 return vec![DoctorCheck::unsupported(
726 "runtime.docker",
727 "Docker",
728 "Docker prerequisites cannot be evaluated until config.toml is valid.",
729 )];
730 };
731 let targets = local_docker_targets(config);
732 if targets.is_empty() {
733 return vec![DoctorCheck::unsupported(
734 "runtime.docker",
735 "Docker",
736 "No local-docker target is configured.",
737 )];
738 }
739 let preflight = local_docker_runtime_check(executor);
740 if preflight.status != CheckStatus::Ready {
741 return vec![preflight];
742 }
743 let mut checks = vec![preflight];
744 checks.extend(
745 targets
746 .into_iter()
747 .map(|(id, container)| docker_image_check(id, &container.image, executor, smoke)),
748 );
749 checks
750}
751
752pub fn local_docker_runtime_check(executor: &impl CommandExecutor) -> DoctorCheck {
753 match verify_local_docker(executor) {
754 Ok(preflight) => DoctorCheck::ready(
755 "runtime.docker",
756 "Docker",
757 format!(
758 "Docker {} is connected to a Linux daemon.",
759 preflight.version
760 ),
761 ),
762 Err(error) => DoctorCheck::fixable(
763 "runtime.docker",
764 "Docker",
765 format!("{error:#}"),
766 "Install and start Docker, then make sure `docker info` succeeds as the user running mj.",
767 ),
768 }
769}
770
771fn local_docker_targets(config: &Config) -> Vec<(&String, &ContainerTemplate)> {
772 config
773 .targets
774 .iter()
775 .filter_map(|(id, target)| match target {
776 TargetTemplate::LocalDocker { container } => Some((id, container)),
777 _ => None,
778 })
779 .collect()
780}
781
782fn docker_image_check(
783 id: &str,
784 image: &str,
785 executor: &impl CommandExecutor,
786 smoke: bool,
787) -> DoctorCheck {
788 let check_id = format!("runtime.docker.image.{id}");
789 let title = format!("Docker image for target {id}");
790 if smoke {
791 let target = RuntimeTargetTemplate::LocalDocker(RuntimeContainerTemplate {
792 build_cache: None,
793 image: image.to_owned(),
794 pull_policy: Default::default(),
795 extra_run_args: vec![],
796 workspace_storage: Default::default(),
797 });
798 return match run_setup_smoke_test(&target, &doctor_smoke_id(), executor) {
799 Ok(()) => DoctorCheck::ready(
800 check_id,
801 title,
802 format!(
803 "Disposable run/exec/remove and OverlayFS attachment smoke test passed for image {image}."
804 ),
805 ),
806 Err(error) => DoctorCheck::fixable(
807 check_id,
808 title,
809 format!(
810 "Disposable run/exec/remove smoke test failed for image {image}: {error:#}"
811 ),
812 "Fix the configured image or Docker runtime, then run `mj doctor --json --smoke` again.",
813 ),
814 };
815 }
816 let command = CommandSpec::new("docker", ["image", "inspect", image])
817 .purpose("check Docker image presence");
818 match executor.execute(&command) {
819 Ok(output) if output.status == 0 => DoctorCheck::ready(
820 check_id,
821 title,
822 format!("Image {image} is present in Docker storage."),
823 ),
824 Ok(_) => DoctorCheck::fixable(
825 check_id,
826 title,
827 format!("Image {image} is not present in Docker storage."),
828 format!("Pull it with `docker pull {image}`, or run `mj doctor --json --smoke`."),
829 ),
830 Err(error) => DoctorCheck::fixable(
831 check_id,
832 title,
833 format!("Could not inspect Docker image {image}: {error}"),
834 format!("Make sure `docker info` succeeds, then run `docker pull {image}`."),
835 ),
836 }
837}
838
839enum SshConnectivity {
844 Reachable,
845 Failed { detail: String, remediation: String },
846}
847
848fn ssh_connectivity(ssh: &RuntimeSshTarget, executor: &impl CommandExecutor) -> SshConnectivity {
853 let destination = &ssh.destination;
854 let command = ssh_connectivity_probe(ssh);
855 match executor.execute(&command) {
856 Err(error) => SshConnectivity::Failed {
857 detail: format!("Could not run `ssh {destination} true`: {error:#}"),
858 remediation: ssh_launch_failure_remediation(&error, ssh),
859 },
860 Ok(output) if output.status != 0 => {
861 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned();
862 SshConnectivity::Failed {
863 detail: format!("`ssh {destination} true` failed: {stderr}"),
864 remediation: ssh_failure_remediation(&stderr, ssh),
865 }
866 }
867 Ok(_) => SshConnectivity::Reachable,
868 }
869}
870
871const 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).";
872
873#[derive(Debug, Clone, Copy, PartialEq, Eq)]
879enum SshFailure {
880 UntrustedHostKey,
881 Unauthenticated,
882 ClientMissing,
883 Unreachable,
885 Unrecognized,
886}
887
888fn classify_ssh_stderr(stderr: &str) -> SshFailure {
889 const UNTRUSTED_HOST_KEY: [&str; 3] = [
890 "Host key verification failed",
891 "No ECDSA host key is known",
892 "REMOTE HOST IDENTIFICATION HAS CHANGED",
893 ];
894 const UNAUTHENTICATED: [&str; 4] = [
895 "Permission denied",
896 "Too many authentication failures",
897 "no matching host key",
898 "Authentication failed",
899 ];
900 const CLIENT_MISSING: [&str; 2] = ["ssh: command not found", "No such file or directory"];
901 const UNREACHABLE: [&str; 3] = [
902 "Connection timed out",
903 "No route to host",
904 "Network is unreachable",
905 ];
906
907 let reported = |signatures: &[&str]| signatures.iter().any(|text| stderr.contains(text));
908 if reported(&UNTRUSTED_HOST_KEY) {
909 SshFailure::UntrustedHostKey
910 } else if reported(&UNAUTHENTICATED) {
911 SshFailure::Unauthenticated
912 } else if reported(&CLIENT_MISSING) {
913 SshFailure::ClientMissing
914 } else if reported(&UNREACHABLE) {
915 SshFailure::Unreachable
916 } else {
917 SshFailure::Unrecognized
918 }
919}
920
921fn ssh_launch_failure_remediation(error: &anyhow::Error, ssh: &RuntimeSshTarget) -> String {
924 if error.downcast_ref::<CommandTimedOut>().is_some() {
925 return ssh_unreachable_remediation(ssh);
926 }
927 let missing_binary = error.chain().any(|cause| {
928 cause
929 .downcast_ref::<std::io::Error>()
930 .is_some_and(|io| io.kind() == std::io::ErrorKind::NotFound)
931 });
932 if missing_binary {
933 return SSH_MISSING_REMEDIATION.to_owned();
934 }
935 format!(
936 "Run `ssh {} true` by hand and resolve the error it reports: {error:#}",
937 ssh.destination
938 )
939}
940
941fn ssh_unreachable_remediation(ssh: &RuntimeSshTarget) -> String {
944 let host = ssh_host_only(&ssh.destination);
945 format!(
946 "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.",
947 ssh.destination
948 )
949}
950
951fn ssh_failure_remediation(stderr: &str, ssh: &RuntimeSshTarget) -> String {
953 let destination = &ssh.destination;
954 match classify_ssh_stderr(stderr) {
955 SshFailure::UntrustedHostKey => {
956 let host = ssh_host_only(destination);
957 format!(
958 "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."
959 )
960 }
961 SshFailure::Unauthenticated => match ssh_identity_file(ssh) {
962 Some(identity) => format!(
963 "Install your public key on the host with `ssh-copy-id -i {identity}.pub {destination}`."
964 ),
965 None => {
966 format!("Install your public key on the host with `ssh-copy-id {destination}`.")
967 }
968 },
969 SshFailure::ClientMissing => SSH_MISSING_REMEDIATION.to_owned(),
970 SshFailure::Unreachable => ssh_unreachable_remediation(ssh),
971 SshFailure::Unrecognized => {
972 format!(
973 "Run `ssh {destination} true` by hand and resolve the error it reports: {stderr}"
974 )
975 }
976 }
977}
978
979fn ssh_host_only(destination: &str) -> &str {
981 destination
982 .rsplit_once('@')
983 .map_or(destination, |(_, host)| host)
984}
985
986fn ssh_identity_file(ssh: &RuntimeSshTarget) -> Option<&str> {
988 let position = ssh.ssh_args.iter().position(|arg| arg == "-i")?;
989 ssh.ssh_args.get(position + 1).map(String::as_str)
990}
991
992fn ssh_bare_checks(config: Option<&Config>, executor: &impl CommandExecutor) -> Vec<DoctorCheck> {
994 let Some(config) = config else {
995 return Vec::new();
996 };
997 config
998 .targets
999 .iter()
1000 .filter_map(|(id, target)| match target {
1001 TargetTemplate::SshBare { ssh, .. } => {
1002 Some(ssh_bare_check(id, &RuntimeSshTarget::from(ssh), executor))
1003 }
1004 _ => None,
1005 })
1006 .collect()
1007}
1008
1009fn ssh_bare_check(
1010 id: &str,
1011 ssh: &RuntimeSshTarget,
1012 executor: &impl CommandExecutor,
1013) -> DoctorCheck {
1014 let check_id = format!("runtime.ssh-bare.{id}");
1015 let title = format!("SSH access for target {id}");
1016 match ssh_connectivity(ssh, executor) {
1017 SshConnectivity::Reachable => DoctorCheck::ready(
1018 check_id,
1019 title,
1020 format!(
1021 "`ssh {} true` succeeds noninteractively from this host.",
1022 ssh.destination
1023 ),
1024 ),
1025 SshConnectivity::Failed {
1026 detail,
1027 remediation,
1028 } => DoctorCheck::fixable(check_id, title, detail, remediation),
1029 }
1030}
1031
1032fn ssh_podman_checks(
1035 config: Option<&Config>,
1036 executor: &impl CommandExecutor,
1037 smoke: bool,
1038) -> Vec<DoctorCheck> {
1039 let Some(config) = config else {
1040 return Vec::new();
1041 };
1042 config
1043 .targets
1044 .iter()
1045 .flat_map(|(id, target)| match target {
1046 TargetTemplate::SshPodman { ssh, container, .. } => {
1047 let ssh = RuntimeSshTarget::from(ssh);
1048 let (check, reachable) =
1049 ssh_podman_check(id, &ssh, &container.image, executor, smoke);
1050 let mut checks = vec![check];
1051 if reachable {
1053 checks.push(ssh_podman_limits_check(id, &ssh, executor));
1054 }
1055 checks
1056 }
1057 _ => Vec::new(),
1058 })
1059 .collect()
1060}
1061
1062fn ssh_podman_check(
1065 id: &str,
1066 ssh: &RuntimeSshTarget,
1067 image: &str,
1068 executor: &impl CommandExecutor,
1069 smoke: bool,
1070) -> (DoctorCheck, bool) {
1071 let check_id = format!("runtime.ssh-podman.{id}");
1072 let title = format!("Remote Podman for target {id}");
1073 if let SshConnectivity::Failed {
1076 detail,
1077 remediation,
1078 } = ssh_connectivity(ssh, executor)
1079 {
1080 return (
1081 DoctorCheck::fixable(check_id, title, detail, remediation),
1082 false,
1083 );
1084 }
1085 (
1086 ssh_podman_runtime_check(check_id, title, ssh, image, executor, smoke),
1087 true,
1088 )
1089}
1090
1091fn ssh_podman_runtime_check(
1093 check_id: String,
1094 title: String,
1095 ssh: &RuntimeSshTarget,
1096 image: &str,
1097 executor: &impl CommandExecutor,
1098 smoke: bool,
1099) -> DoctorCheck {
1100 let destination = &ssh.destination;
1101 let preflight = match verify_ssh_podman(ssh, executor) {
1102 Ok(preflight) => preflight,
1103 Err(error) => {
1104 let detail = format!("{error:#}");
1105 let remediation = match podman_remediation_match(&error) {
1106 Some(remediation) => format!("On {destination}: {remediation}"),
1107 None => format!(
1108 "Verify `ssh {destination}` succeeds noninteractively from this host, then install rootless Podman 4 or newer there (see docs/PODMAN.md)."
1109 ),
1110 };
1111 return DoctorCheck::fixable(check_id, title, detail, remediation);
1112 }
1113 };
1114 let linger_warning = preflight.warnings.first();
1115 if !smoke && let Some(warning) = linger_warning {
1116 return DoctorCheck::warning(
1117 check_id,
1118 title,
1119 format!(
1120 "Remote rootless Podman {} is available via {destination}, but {}",
1121 preflight.version, warning.detail
1122 ),
1123 &warning.remediation,
1124 );
1125 }
1126 if !smoke {
1127 return DoctorCheck::ready(
1128 check_id,
1129 title,
1130 format!(
1131 "Remote rootless Podman {} is available via {destination}. Run `mj doctor --json --smoke` to verify the image end to end.",
1132 preflight.version
1133 ),
1134 );
1135 }
1136
1137 let target = RuntimeTargetTemplate::SshPodman {
1138 ssh: ssh.clone(),
1139 container: RuntimeContainerTemplate {
1140 build_cache: None,
1141 image: image.to_owned(),
1142 pull_policy: Default::default(),
1143 extra_run_args: vec![],
1144 workspace_storage: Default::default(),
1145 },
1146 };
1147 match run_setup_smoke_test(&target, &doctor_smoke_id(), executor) {
1148 Ok(()) => match linger_warning {
1149 Some(warning) => DoctorCheck::warning(
1150 check_id,
1151 title,
1152 format!(
1153 "Disposable run/exec/remove smoke test passed for image {image} on {destination}, but {}",
1154 warning.detail
1155 ),
1156 &warning.remediation,
1157 ),
1158 None => DoctorCheck::ready(
1159 check_id,
1160 title,
1161 format!(
1162 "Disposable run/exec/remove smoke test passed for image {image} on {destination}."
1163 ),
1164 ),
1165 },
1166 Err(error) => DoctorCheck::fixable(
1167 check_id,
1168 title,
1169 format!(
1170 "Disposable run/exec/remove smoke test failed for image {image} on {destination}: {error:#}"
1171 ),
1172 format!(
1173 "Fix the configured image or Podman runtime on {destination}, then run `mj doctor --json --smoke` again."
1174 ),
1175 ),
1176 }
1177}
1178
1179const SSH_PODMAN_HOST_LIMITS_SCRIPT: &str = r#"
1188if [ -r /proc/sys/kernel/keys/maxkeys ]; then
1189 printf 'keys.max=%s\n' "$(cat /proc/sys/kernel/keys/maxkeys)"
1190fi
1191if [ -r /proc/key-users ]; then
1192 awk -v uid="$(id -u)" '
1193 { user = $1; sub(/:$/, "", user) }
1194 user == uid {
1195 split($4, quota, "/")
1196 printf "keys.used=%s\nkeys.quota=%s\n", quota[1], quota[2]
1197 }
1198 ' /proc/key-users
1199fi
1200unreadable=0
1201maxstartups=
1202# A drop-in directory that cannot be listed hides any override it holds.
1203if [ -d /etc/ssh/sshd_config.d ] && ! [ -r /etc/ssh/sshd_config.d ]; then
1204 unreadable=1
1205fi
1206for file in /etc/ssh/sshd_config /etc/ssh/sshd_config.d/*.conf; do
1207 [ -e "$file" ] || continue
1208 if [ -r "$file" ]; then
1209 match=$(grep -i '^[[:space:]]*maxstartups[[:space:]]' "$file" 2>/dev/null | tail -n 1)
1210 [ -n "$match" ] && maxstartups=$(printf '%s\n' "$match" | awk '{ print $2 }')
1211 else
1212 unreadable=1
1213 fi
1214done
1215[ -n "$maxstartups" ] && printf 'maxstartups=%s\n' "$maxstartups"
1216[ "$unreadable" = 1 ] && printf 'maxstartups.unreadable=1\n'
1217exit 0
1218"#;
1219
1220const KEYRING_PRESSURE_PERCENT: u64 = 80;
1224
1225#[derive(Debug, Default, PartialEq, Eq)]
1228struct HostLimits {
1229 keys_used: Option<u64>,
1230 keys_quota: Option<u64>,
1231 keys_max: Option<u64>,
1232 max_startups: Option<String>,
1233 max_startups_unreadable: bool,
1234}
1235
1236fn parse_host_limits(stdout: &[u8]) -> HostLimits {
1237 let text = String::from_utf8_lossy(stdout);
1238 let mut limits = HostLimits::default();
1239 for line in text.lines() {
1240 let Some((name, value)) = line.split_once('=') else {
1241 continue;
1242 };
1243 let value = value.trim();
1244 match name.trim() {
1245 "keys.used" => limits.keys_used = value.parse().ok(),
1246 "keys.quota" => limits.keys_quota = value.parse().ok(),
1247 "keys.max" => limits.keys_max = value.parse().ok(),
1248 "maxstartups" if !value.is_empty() => limits.max_startups = Some(value.to_owned()),
1249 "maxstartups.unreadable" => limits.max_startups_unreadable = value == "1",
1250 _ => {}
1251 }
1252 }
1253 limits
1254}
1255
1256impl HostLimits {
1257 fn is_empty(&self) -> bool {
1259 self.keys_used.is_none()
1260 && self.keys_quota.is_none()
1261 && self.keys_max.is_none()
1262 && self.max_startups.is_none()
1263 && !self.max_startups_unreadable
1264 }
1265
1266 fn keyring_is_under_pressure(&self) -> bool {
1267 match (self.keys_used, self.keys_quota) {
1268 (Some(used), Some(quota)) if quota > 0 => {
1269 used.saturating_mul(100) >= quota.saturating_mul(KEYRING_PRESSURE_PERCENT)
1270 }
1271 _ => false,
1272 }
1273 }
1274
1275 fn keyring_sentence(&self, destination: &str) -> String {
1276 match (self.keys_used, self.keys_quota) {
1277 (Some(used), Some(quota)) => {
1278 let system = match self.keys_max {
1279 Some(max) => format!(", and `kernel.keys.maxkeys` is {max}"),
1280 None => String::new(),
1281 };
1282 format!(
1283 "The login user on {destination} holds {used} of its {quota} kernel keyring quota{system}."
1284 )
1285 }
1286 _ => format!(
1287 "The kernel keyring quota for the login user on {destination} could not be read."
1288 ),
1289 }
1290 }
1291
1292 fn max_startups_sentence(&self) -> String {
1293 match (&self.max_startups, self.max_startups_unreadable) {
1294 (Some(value), _) => format!("sshd MaxStartups is {value}."),
1295 (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(),
1296 (None, false) => {
1297 "sshd MaxStartups is not set in sshd_config, so sshd's default applies.".to_owned()
1298 }
1299 }
1300 }
1301}
1302
1303fn ssh_podman_limits_check(
1307 id: &str,
1308 ssh: &RuntimeSshTarget,
1309 executor: &impl CommandExecutor,
1310) -> DoctorCheck {
1311 let check_id = format!("runtime.ssh-podman.{id}.limits");
1312 let title = format!("Host limits for target {id}");
1313 let destination = &ssh.destination;
1314 let manual = || {
1315 format!(
1316 "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`."
1317 )
1318 };
1319 let command = ssh_validation_command(
1320 ssh,
1321 vec![
1322 "sh".to_owned(),
1323 "-c".to_owned(),
1324 SSH_PODMAN_HOST_LIMITS_SCRIPT.to_owned(),
1325 ],
1326 "read ssh-podman host limits",
1327 );
1328 let limits = match executor.execute(&command) {
1329 Ok(output) if output.status == 0 => parse_host_limits(&output.stdout),
1330 Ok(output) => {
1331 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned();
1332 return DoctorCheck::warning(
1333 check_id,
1334 title,
1335 format!(
1336 "Could not read the kernel keyring quota or sshd MaxStartups from {destination}: {stderr}"
1337 ),
1338 manual(),
1339 );
1340 }
1341 Err(error) => {
1342 return DoctorCheck::warning(
1343 check_id,
1344 title,
1345 format!(
1346 "Could not read the kernel keyring quota or sshd MaxStartups from {destination}: {error}"
1347 ),
1348 manual(),
1349 );
1350 }
1351 };
1352 if limits.is_empty() {
1353 return DoctorCheck::warning(
1354 check_id,
1355 title,
1356 format!("{destination} reported no readable kernel keyring or sshd limits."),
1357 manual(),
1358 );
1359 }
1360 let detail = format!(
1361 "{} {}",
1362 limits.keyring_sentence(destination),
1363 limits.max_startups_sentence()
1364 );
1365 if limits.keyring_is_under_pressure() {
1366 return DoctorCheck::warning(
1367 check_id,
1368 title,
1369 format!(
1370 "{detail} Every container takes a session keyring, so `podman run` fails with `crun: create keyring` once the quota is gone."
1371 ),
1372 format!(
1373 "Raise `kernel.keys.maxkeys` and `kernel.keys.maxbytes` with sysctl on {destination}, and close finished sessions promptly."
1374 ),
1375 );
1376 }
1377 DoctorCheck::ready(check_id, title, detail)
1378}
1379
1380fn ssh_docker_checks(
1383 config: Option<&Config>,
1384 executor: &impl CommandExecutor,
1385 smoke: bool,
1386) -> Vec<DoctorCheck> {
1387 let Some(config) = config else {
1388 return Vec::new();
1389 };
1390 config
1391 .targets
1392 .iter()
1393 .filter_map(|(id, target)| match target {
1394 TargetTemplate::SshDocker { ssh, container } => Some(ssh_docker_check(
1395 id,
1396 &RuntimeSshTarget::from(ssh),
1397 &container.image,
1398 executor,
1399 smoke,
1400 )),
1401 _ => None,
1402 })
1403 .collect()
1404}
1405
1406fn ssh_docker_check(
1407 id: &str,
1408 ssh: &RuntimeSshTarget,
1409 image: &str,
1410 executor: &impl CommandExecutor,
1411 smoke: bool,
1412) -> DoctorCheck {
1413 let check_id = format!("runtime.ssh-docker.{id}");
1414 let title = format!("Remote Docker for target {id}");
1415 let destination = &ssh.destination;
1416 if let SshConnectivity::Failed {
1417 detail,
1418 remediation,
1419 } = ssh_connectivity(ssh, executor)
1420 {
1421 return DoctorCheck::fixable(check_id, title, detail, remediation);
1422 }
1423
1424 let preflight = match verify_ssh_docker(ssh, executor) {
1425 Ok(preflight) => preflight,
1426 Err(error) => {
1427 let detail = format!("{error:#}");
1428 return DoctorCheck::fixable(
1429 check_id,
1430 title,
1431 detail,
1432 format!(
1433 "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."
1434 ),
1435 );
1436 }
1437 };
1438
1439 if smoke {
1440 let target = RuntimeTargetTemplate::SshDocker {
1441 ssh: ssh.clone(),
1442 container: RuntimeContainerTemplate {
1443 build_cache: None,
1444 image: image.to_owned(),
1445 pull_policy: Default::default(),
1446 extra_run_args: vec![],
1447 workspace_storage: Default::default(),
1448 },
1449 };
1450 return match run_setup_smoke_test(&target, &doctor_smoke_id(), executor) {
1451 Ok(()) => DoctorCheck::ready(
1452 check_id,
1453 title,
1454 format!(
1455 "Remote Docker {} is available via {destination}; disposable run/exec/remove and remote OverlayFS attachment smoke test passed for image {image}.",
1456 preflight.version
1457 ),
1458 ),
1459 Err(error) => DoctorCheck::fixable(
1460 check_id,
1461 title,
1462 format!(
1463 "Disposable run/exec/remove smoke test failed for image {image} on {destination}: {error:#}"
1464 ),
1465 format!(
1466 "Fix the configured image or Docker runtime on {destination}, then run `mj doctor --json --smoke` again."
1467 ),
1468 ),
1469 };
1470 }
1471
1472 let image_command = ssh_command(
1473 ssh,
1474 [
1475 "docker".to_owned(),
1476 "image".to_owned(),
1477 "inspect".to_owned(),
1478 image.to_owned(),
1479 ]
1480 .to_vec(),
1481 )
1482 .purpose("check remote Docker image presence");
1483 match executor.execute(&image_command) {
1484 Ok(output) if output.status == 0 => DoctorCheck::ready(
1485 check_id,
1486 title,
1487 format!(
1488 "Remote Docker {} is available via {destination}; image {image} is present. Run `mj doctor --json --smoke` to verify remote OverlayFS attachments.",
1489 preflight.version
1490 ),
1491 ),
1492 Ok(output) => DoctorCheck::fixable(
1493 check_id,
1494 title,
1495 format!(
1496 "Image {image} is not present in remote Docker storage on {destination}: {}",
1497 String::from_utf8_lossy(&output.stderr).trim()
1498 ),
1499 format!(
1500 "Pull it on {destination} with `ssh {destination} docker pull {image}`, or run `mj doctor --json --smoke`."
1501 ),
1502 ),
1503 Err(error) => DoctorCheck::fixable(
1504 check_id,
1505 title,
1506 format!("Could not inspect remote Docker image {image} on {destination}: {error}"),
1507 format!(
1508 "Verify `ssh {destination} docker info` succeeds, then pull {image} on that host."
1509 ),
1510 ),
1511 }
1512}
1513
1514fn doctor_smoke_id() -> String {
1516 format!(
1517 "doctor-{}-{:x}",
1518 std::process::id(),
1519 SystemTime::now()
1520 .duration_since(UNIX_EPOCH)
1521 .unwrap_or_default()
1522 .as_nanos()
1523 )
1524}
1525
1526fn podman_remediation(error: &anyhow::Error) -> &'static str {
1527 podman_remediation_match(error).unwrap_or(
1528 "Install Podman with `sudo apt update && sudo apt install -y podman uidmap` (Debian/Ubuntu) or `sudo dnf install -y podman shadow-utils` (Fedora).",
1529 )
1530}
1531
1532fn podman_remediation_match(error: &anyhow::Error) -> Option<&'static str> {
1539 failed_podman_probe(error).map(PodmanProbe::remediation)
1540}
1541
1542const AWS_CLI_INSTALL_URL: &str =
1543 "https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html";
1544
1545fn aws_checks(config: Option<&Config>, executor: &impl CommandExecutor) -> Vec<DoctorCheck> {
1548 let Some(config) = config else {
1549 return Vec::new();
1550 };
1551 config
1552 .targets
1553 .iter()
1554 .filter_map(|(id, target)| match target {
1555 TargetTemplate::AwsEc2 {
1556 aws_profile,
1557 region,
1558 launch_template,
1559 ..
1560 } => Some(aws_target_check(
1561 id,
1562 aws_profile.as_deref(),
1563 region,
1564 launch_template,
1565 executor,
1566 )),
1567 _ => None,
1568 })
1569 .collect()
1570}
1571
1572fn aws_global_args<'a>(profile: Option<&'a str>, region: &'a str) -> Vec<String> {
1575 vec![
1576 "--profile".to_owned(),
1577 profile.unwrap_or("default").to_owned(),
1578 "--region".to_owned(),
1579 region.to_owned(),
1580 ]
1581}
1582
1583fn aws_target_check(
1584 id: &str,
1585 profile: Option<&str>,
1586 region: &str,
1587 launch_template: &str,
1588 executor: &impl CommandExecutor,
1589) -> DoctorCheck {
1590 let check_id = format!("runtime.aws-ec2.{id}");
1591 let title = format!("AWS EC2 target {id}");
1592 let profile_label = profile.unwrap_or("default");
1593
1594 let version = CommandSpec::new("aws", ["--version"]).purpose("check AWS CLI installation");
1595 match executor.execute(&version) {
1596 Err(error) => {
1597 return DoctorCheck::fixable(
1598 check_id,
1599 title,
1600 format!("The `aws` command is not available: {error}"),
1601 format!("Install the AWS CLI and put `aws` on PATH: {AWS_CLI_INSTALL_URL}"),
1602 );
1603 }
1604 Ok(output) if output.status != 0 => {
1605 return DoctorCheck::fixable(
1606 check_id,
1607 title,
1608 format!(
1609 "`aws --version` failed: {}",
1610 String::from_utf8_lossy(&output.stderr).trim()
1611 ),
1612 format!("Reinstall the AWS CLI: {AWS_CLI_INSTALL_URL}"),
1613 );
1614 }
1615 Ok(_) => {}
1616 }
1617
1618 let mut identity_args = aws_global_args(profile, region);
1619 identity_args.extend(["sts".to_owned(), "get-caller-identity".to_owned()]);
1620 identity_args.extend(["--output".to_owned(), "json".to_owned()]);
1621 let identity =
1622 CommandSpec::new("aws", identity_args).purpose("check AWS credentials for a doctor target");
1623 match executor.execute(&identity) {
1624 Err(error) => {
1625 return DoctorCheck::fixable(
1626 check_id,
1627 title,
1628 format!("Could not run `aws sts get-caller-identity`: {error}"),
1629 format!(
1630 "Configure credentials with `aws configure --profile {profile_label}`, or sign in with `aws sso login --profile {profile_label}`."
1631 ),
1632 );
1633 }
1634 Ok(output) if output.status != 0 => {
1635 return DoctorCheck::fixable(
1636 check_id,
1637 title,
1638 format!(
1639 "AWS credentials for profile {profile_label} are not usable: {}",
1640 String::from_utf8_lossy(&output.stderr).trim()
1641 ),
1642 format!(
1643 "Configure credentials with `aws configure --profile {profile_label}`, or sign in with `aws sso login --profile {profile_label}`."
1644 ),
1645 );
1646 }
1647 Ok(_) => {}
1648 }
1649
1650 let by_id = launch_template.starts_with("lt-");
1653 let mut template_args = aws_global_args(profile, region);
1654 template_args.extend(["ec2".to_owned(), "describe-launch-templates".to_owned()]);
1655 template_args.extend([
1656 if by_id {
1657 "--launch-template-ids".to_owned()
1658 } else {
1659 "--launch-template-names".to_owned()
1660 },
1661 launch_template.to_owned(),
1662 ]);
1663 template_args.extend(["--output".to_owned(), "json".to_owned()]);
1664 let template =
1665 CommandSpec::new("aws", template_args).purpose("check the configured AWS launch template");
1666 let template_remediation = format!(
1667 "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."
1668 );
1669 match executor.execute(&template) {
1670 Err(error) => DoctorCheck::fixable(
1671 check_id,
1672 title,
1673 format!("Could not query launch template {launch_template}: {error}"),
1674 template_remediation,
1675 ),
1676 Ok(output) if output.status != 0 => DoctorCheck::fixable(
1677 check_id,
1678 title,
1679 format!(
1680 "Launch template {launch_template} was not found in {region}: {}",
1681 String::from_utf8_lossy(&output.stderr).trim()
1682 ),
1683 template_remediation,
1684 ),
1685 Ok(_) => DoctorCheck::ready(
1686 check_id,
1687 title,
1688 format!(
1689 "The AWS CLI is installed, profile {profile_label} has valid credentials, and launch template {launch_template} exists in {region}."
1690 ),
1691 ),
1692 }
1693}
1694
1695fn daemon_build_check() -> DoctorCheck {
1703 const ID: &str = "daemon.build";
1704 const TITLE: &str = "Daemon build";
1705 let Ok(metadata) = mj_client::daemon::read_metadata_any() else {
1706 return DoctorCheck::ready(
1707 ID,
1708 TITLE,
1709 "No Mjolnir daemon is running; the next command starts one from this build.",
1710 );
1711 };
1712 let pid = metadata.pid;
1713 match mj_client::executable::process_runs_this_executable(pid) {
1714 Ok(Some(true)) => DoctorCheck::ready(
1715 ID,
1716 TITLE,
1717 format!(
1718 "Daemon {pid} runs this build (version {}).",
1719 metadata.build_version
1720 ),
1721 ),
1722 Ok(Some(false)) => DoctorCheck::warning(
1723 ID,
1724 TITLE,
1725 format!(
1726 "Daemon {pid} runs {}, while this client runs {}. Both report version {}, so the version alone cannot tell them apart. Code rebuilt since that daemon started is not running.",
1727 describe_executable(mj_client::executable::process_executable_path(pid)),
1728 describe_executable(mj_client::executable::running_executable_path()),
1729 metadata.build_version,
1730 ),
1731 "Run `mj daemon restart` from this build. It now fails rather than reporting success if another client's build wins.",
1732 ),
1733 Ok(None) => DoctorCheck::ready(
1734 ID,
1735 TITLE,
1736 format!(
1737 "Daemon {pid} is recorded but not running; the next command starts one from this build."
1738 ),
1739 ),
1740 Err(error) => DoctorCheck::warning(
1741 ID,
1742 TITLE,
1743 format!("Could not tell which build daemon {pid} runs: {error:#}"),
1744 "Run `mj daemon restart` from this build if rebuilt code is not taking effect.",
1745 ),
1746 }
1747}
1748
1749fn describe_executable(path: Option<std::path::PathBuf>) -> String {
1750 path.map_or_else(
1751 || "an unknown file".to_owned(),
1752 |path| path.display().to_string(),
1753 )
1754}
1755
1756fn worker_freshness_checks(config: Option<&Config>) -> Vec<DoctorCheck> {
1764 let mut checks = Vec::new();
1765 let daemon = mj_client::daemon::read_metadata_any()
1766 .ok()
1767 .filter(|metadata| mj_client::daemon::process_is_alive(metadata.pid));
1768 let pinned = pinned_worker_digests();
1769 let mut sources: Vec<(String, Result<WorkerBinaryAvailability>)> = vec![(
1770 "this host".to_owned(),
1771 crate::controller::native_worker_binary_prerequisite(),
1772 )];
1773 for arch in container_worker_architectures(config) {
1774 sources.push((
1775 format!("{arch} Linux targets"),
1776 worker_binary_prerequisite_for_arch(&arch),
1777 ));
1778 }
1779 for (label, availability) in sources {
1780 let id = format!("worker.freshness.{}", label.replace(' ', "-"));
1781 let title = format!("Worker binary for {label}");
1782 let path = match availability {
1783 Ok(WorkerBinaryAvailability::Local { path, .. }) => path,
1784 Ok(WorkerBinaryAvailability::Remote { .. }) => continue,
1787 Err(error) => {
1788 checks.push(DoctorCheck::unsupported(
1789 id,
1790 title,
1791 format!("No worker binary resolves for {label}: {error:#}"),
1792 ));
1793 continue;
1794 }
1795 };
1796 let digest = mj_core::worker_launch::worker_executable_digest(&path)
1797 .unwrap_or_else(|error| format!("unreadable ({error:#})"));
1798 let pinned_note = if pinned.is_empty() {
1799 "the daemon has pinned no worker".to_owned()
1800 } else if pinned.contains(&digest) {
1801 "this content is in the daemon's pinned worker cache".to_owned()
1802 } else {
1803 format!(
1804 "the pinned worker cache holds {} instead",
1805 pinned.join(", ")
1806 )
1807 };
1808 let detail = format!("{} has digest {digest}; {pinned_note}.", path.display());
1809 let Some(metadata) = daemon.as_ref() else {
1810 checks.push(DoctorCheck::ready(
1811 id,
1812 title,
1813 format!("{detail} No daemon is running, so the next session uses this file."),
1814 ));
1815 continue;
1816 };
1817 match worker_changed_since_daemon_start(&path, &metadata.started_at) {
1818 Ok(true) => checks.push(DoctorCheck::warning(
1819 id,
1820 title,
1821 format!("{detail} It was rebuilt after daemon {} started, which froze the copy it serves.", metadata.pid),
1822 "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.",
1823 )),
1824 Ok(false) => checks.push(DoctorCheck::ready(id, title, detail)),
1825 Err(error) => checks.push(DoctorCheck::warning(
1826 id,
1827 title,
1828 format!("{detail} Could not compare it with the daemon's start time: {error:#}"),
1829 "Run `mj daemon restart` if rebuilt worker code is not taking effect.",
1830 )),
1831 }
1832 }
1833 checks
1834}
1835
1836fn container_worker_architectures(config: Option<&Config>) -> Vec<String> {
1838 let Some(config) = config else {
1839 return Vec::new();
1840 };
1841 let mut architectures = Vec::new();
1842 for target in config.targets.values() {
1843 let container = match target {
1844 TargetTemplate::LocalPodman { container }
1845 | TargetTemplate::LocalDocker { container }
1846 | TargetTemplate::AppleContainer { container }
1847 | TargetTemplate::SshPodman { container, .. }
1848 | TargetTemplate::SshDocker { container, .. } => container,
1849 _ => continue,
1850 };
1851 let arch = container
1852 .platform
1853 .as_deref()
1854 .and_then(|platform| platform.rsplit('/').next())
1855 .map_or_else(
1856 || std::env::consts::ARCH.to_owned(),
1857 normalized_worker_architecture,
1858 );
1859 if !architectures.contains(&arch) {
1860 architectures.push(arch);
1861 }
1862 }
1863 architectures
1864}
1865
1866fn normalized_worker_architecture(platform_arch: &str) -> String {
1869 match platform_arch {
1870 "amd64" => "x86_64".to_owned(),
1871 "arm64" => "aarch64".to_owned(),
1872 other => other.to_owned(),
1873 }
1874}
1875
1876fn pinned_worker_digests() -> Vec<String> {
1881 let root = mj_core::config::data_dir().join("workers").join("pinned");
1882 let Ok(entries) = std::fs::read_dir(root) else {
1883 return Vec::new();
1884 };
1885 let mut digests: Vec<String> = entries
1886 .flatten()
1887 .filter(|entry| entry.path().is_dir())
1888 .filter_map(|entry| entry.file_name().into_string().ok())
1889 .collect();
1890 digests.sort();
1891 digests
1892}
1893
1894fn worker_changed_since_daemon_start(path: &Path, started_at: &str) -> Result<bool> {
1899 let started: SystemTime = chrono::DateTime::parse_from_rfc3339(started_at)
1900 .map_err(|error| anyhow::anyhow!("parse daemon start time {started_at:?}: {error}"))?
1901 .into();
1902 let modified = std::fs::metadata(path)?.modified()?;
1903 Ok(modified > started)
1904}
1905
1906fn worker_binary_checks(config: Option<&Config>) -> Vec<DoctorCheck> {
1907 let Some(config) = config else {
1908 return vec![DoctorCheck::fixable(
1909 "worker.containers",
1910 "Container worker binary",
1911 "Worker availability cannot be checked until config.toml is valid.",
1912 "Fix config.toml, then rerun `mj doctor --json`.",
1913 )];
1914 };
1915 let containers = config
1916 .targets
1917 .iter()
1918 .filter_map(|(id, target)| match target {
1919 TargetTemplate::LocalPodman { container }
1920 | TargetTemplate::LocalDocker { container }
1921 | TargetTemplate::AppleContainer { container } => Some((id, container, None)),
1922 TargetTemplate::SshPodman { container, .. } => {
1923 Some((id, container, Some("ssh-podman")))
1924 }
1925 TargetTemplate::SshDocker { container, .. } => {
1926 Some((id, container, Some("ssh-docker")))
1927 }
1928 _ => None,
1929 })
1930 .collect::<Vec<_>>();
1931 if containers.is_empty() {
1932 return vec![DoctorCheck::unsupported(
1933 "worker.containers",
1934 "Container worker binary",
1935 "No container target is configured.",
1936 )];
1937 }
1938 containers
1939 .into_iter()
1940 .map(|(id, container, remote_kind)| {
1941 if let Some(remote_kind) = remote_kind
1942 && container.platform.is_none()
1943 {
1944 return DoctorCheck::unsupported(
1947 format!("worker.{id}"),
1948 format!("Container worker binary for target {id}"),
1949 format!(
1950 "Set `platform` on this {remote_kind} target to check its worker binary; the remote architecture is unknown until provisioning."
1951 ),
1952 );
1953 }
1954 worker_binary_check(id, container)
1955 })
1956 .collect()
1957}
1958
1959fn worker_binary_check(id: &str, container: &ContainerTemplate) -> DoctorCheck {
1960 let title = format!("Container worker binary for target {id}");
1961 let arch = match container_architecture(container.platform.as_deref()) {
1962 Ok(arch) => arch,
1963 Err(reason) => {
1964 return DoctorCheck::unsupported(format!("worker.{id}"), title, reason);
1965 }
1966 };
1967 let triple = format!("{arch}-unknown-linux-musl");
1968 match worker_binary_prerequisite_for_arch(arch) {
1969 Ok(WorkerBinaryAvailability::Local { path, source }) => DoctorCheck::ready(
1970 format!("worker.{id}"),
1971 title,
1972 format!(
1973 "{triple} worker is available from {source}: {}",
1974 path.display()
1975 ),
1976 ),
1977 Ok(WorkerBinaryAvailability::Remote { url, .. }) => DoctorCheck::ready(
1978 format!("worker.{id}"),
1979 title,
1980 format!("{triple} worker will be verified and downloaded from {url} when needed."),
1981 ),
1982 Err(error) => DoctorCheck::fixable(
1983 format!("worker.{id}"),
1984 title,
1985 format!("No usable {triple} worker source: {error:#}"),
1986 format!(
1987 "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."
1988 ),
1989 ),
1990 }
1991}
1992
1993fn container_architecture(platform: Option<&str>) -> std::result::Result<&'static str, String> {
1994 let candidate = platform.unwrap_or(std::env::consts::ARCH);
1995 let candidate = candidate
1996 .split('/')
1997 .rev()
1998 .find(|part| matches!(*part, "x86_64" | "amd64" | "aarch64" | "arm64"))
1999 .unwrap_or(candidate);
2000 match candidate {
2001 "x86_64" | "amd64" => Ok("x86_64"),
2002 "aarch64" | "arm64" => Ok("aarch64"),
2003 other => Err(format!(
2004 "Container architecture {other:?} is unsupported; Mjolnir supports x86_64 and aarch64 Linux workers."
2005 )),
2006 }
2007}
2008
2009fn apple_container_image(config: Option<&Config>) -> String {
2010 config
2011 .and_then(|config| {
2012 config.targets.values().find_map(|target| match target {
2013 TargetTemplate::AppleContainer { container } => Some(container.image.clone()),
2014 _ => None,
2015 })
2016 })
2017 .unwrap_or_else(|| DEFAULT_CONTAINER_IMAGE.into())
2018}
2019
2020pub fn apple_container_check(
2021 platform: &ApplePlatform,
2022 executor: &impl CommandExecutor,
2023 smoke: bool,
2024 image: String,
2025) -> DoctorCheck {
2026 match platform {
2027 ApplePlatform::Linux => {
2028 return DoctorCheck::unsupported(
2029 "runtime.apple-container",
2030 "Apple container runtime",
2031 "macOS only",
2032 );
2033 }
2034 ApplePlatform::Other(current) => {
2035 return DoctorCheck::unsupported(
2036 "runtime.apple-container",
2037 "Apple container runtime",
2038 format!("macOS only (current platform: {current})"),
2039 );
2040 }
2041 ApplePlatform::Macos {
2042 architecture,
2043 major_version,
2044 } if architecture != "aarch64" && architecture != "arm64" => {
2045 return DoctorCheck::unsupported(
2046 "runtime.apple-container",
2047 "Apple container runtime",
2048 "Apple container requires Apple silicon; Intel Macs are unsupported.",
2049 );
2050 }
2051 ApplePlatform::Macos { major_version, .. } if *major_version < 26 => {
2052 return DoctorCheck::unsupported(
2053 "runtime.apple-container",
2054 "Apple container runtime",
2055 format!("Apple container requires macOS 26 or newer (found {major_version})."),
2056 );
2057 }
2058 ApplePlatform::Macos { .. } => {}
2059 }
2060
2061 let daemon = apple_container_daemon_check(executor);
2062 if daemon.status != CheckStatus::Ready {
2063 return daemon;
2064 }
2065
2066 if !smoke {
2067 return DoctorCheck::fixable(
2068 "runtime.apple-container",
2069 "Apple container runtime",
2070 "The daemon is running, but the required disposable smoke test was not requested.",
2071 "Run `mj doctor --json --smoke`.",
2072 );
2073 }
2074
2075 let target = RuntimeTargetTemplate::AppleContainer(RuntimeContainerTemplate {
2076 build_cache: None,
2077 image,
2078 pull_policy: Default::default(),
2079 extra_run_args: vec![],
2080 workspace_storage: Default::default(),
2081 });
2082 match run_setup_smoke_test(&target, &doctor_smoke_id(), executor) {
2083 Ok(()) => DoctorCheck::ready(
2084 "runtime.apple-container",
2085 "Apple container runtime",
2086 "Installed, daemon running, and disposable run/exec/remove smoke test passed.",
2087 ),
2088 Err(error) => DoctorCheck::fixable(
2089 "runtime.apple-container",
2090 "Apple container runtime",
2091 format!("Disposable run/exec/remove smoke test failed: {error:#}"),
2092 "Fix the configured image or container runtime, then run `mj doctor --json --smoke` again.",
2093 ),
2094 }
2095}
2096
2097pub fn apple_container_daemon_check(executor: &impl CommandExecutor) -> DoctorCheck {
2104 let installed =
2105 CommandSpec::new("container", ["--version"]).purpose("check Apple container installation");
2106 match executor.execute(&installed) {
2107 Err(error) => {
2108 return DoctorCheck::fixable(
2109 "runtime.apple-container",
2110 "Apple container runtime",
2111 format!("The `container` command is not available: {error}"),
2112 format!("Install the official signed package: {APPLE_CONTAINER_INSTALL_URL}"),
2113 );
2114 }
2115 Ok(output) if output.status != 0 => {
2116 return DoctorCheck::fixable(
2117 "runtime.apple-container",
2118 "Apple container runtime",
2119 format!(
2120 "The installed `container --version` command failed: {}",
2121 String::from_utf8_lossy(&output.stderr).trim()
2122 ),
2123 format!("Reinstall the official signed package: {APPLE_CONTAINER_INSTALL_URL}"),
2124 );
2125 }
2126 Ok(_) => {}
2127 }
2128
2129 let status =
2130 CommandSpec::new("container", ["system", "status"]).purpose("check Apple container daemon");
2131 match executor.execute(&status) {
2132 Ok(output) if output.status == 0 => DoctorCheck::ready(
2133 "runtime.apple-container",
2134 "Apple container runtime",
2135 "Installed, and the Apple container daemon is running.",
2136 ),
2137 Ok(output) => DoctorCheck::fixable(
2138 "runtime.apple-container",
2139 "Apple container runtime",
2140 format!(
2141 "The Apple container daemon is stopped: {}",
2142 String::from_utf8_lossy(&output.stderr).trim()
2143 ),
2144 "Run `container system start`.",
2145 ),
2146 Err(error) => DoctorCheck::fixable(
2147 "runtime.apple-container",
2148 "Apple container runtime",
2149 format!("Could not query the Apple container daemon: {error}"),
2150 "Run `container system start`.",
2151 ),
2152 }
2153}
2154
2155pub fn current_apple_platform(executor: &impl CommandExecutor) -> ApplePlatform {
2156 if cfg!(target_os = "linux") {
2157 return ApplePlatform::Linux;
2158 }
2159 if !cfg!(target_os = "macos") {
2160 return ApplePlatform::Other(std::env::consts::OS.into());
2161 }
2162 let major_version = executor
2163 .execute(&CommandSpec::new("sw_vers", ["-productVersion"]).purpose("detect macOS version"))
2164 .ok()
2165 .filter(|output| output.status == 0)
2166 .and_then(|output| {
2167 String::from_utf8(output.stdout)
2168 .ok()
2169 .and_then(|value| value.trim().split('.').next()?.parse().ok())
2170 })
2171 .unwrap_or(0);
2172 ApplePlatform::Macos {
2173 architecture: std::env::consts::ARCH.into(),
2174 major_version,
2175 }
2176}
2177
2178#[cfg(test)]
2179mod tests;
2180
2181#[derive(Debug, Default, PartialEq, Eq)]
2183pub(crate) struct ReviewResidue {
2184 pub refs: Vec<String>,
2186 pub scratch_indexes: Vec<PathBuf>,
2188}
2189
2190impl ReviewResidue {
2191 fn is_empty(&self) -> bool {
2192 self.refs.is_empty() && self.scratch_indexes.is_empty()
2193 }
2194}
2195
2196pub(crate) fn review_residue(repository: &Path) -> ReviewResidue {
2203 let mut residue = ReviewResidue::default();
2204 let git_dir = repository.join(".git");
2205 if !git_dir.exists() {
2206 return residue;
2207 }
2208 for reference in ["review-baseline", "review-capture"] {
2209 if git_dir.join("refs/hel").join(reference).is_file() {
2210 residue.refs.push(format!("refs/hel/{reference}"));
2211 }
2212 }
2213 if let Ok(packed) = std::fs::read_to_string(git_dir.join("packed-refs")) {
2215 for line in packed.lines() {
2216 if let Some((_, reference)) = line.split_once(' ')
2217 && reference.starts_with("refs/hel/")
2218 && !residue.refs.iter().any(|known| known == reference)
2219 {
2220 residue.refs.push(reference.to_owned());
2221 }
2222 }
2223 }
2224 if let Ok(entries) = std::fs::read_dir(&git_dir) {
2225 for entry in entries.filter_map(Result::ok) {
2226 if entry
2227 .file_name()
2228 .to_str()
2229 .is_some_and(|name| name.starts_with("hel-review-index-"))
2230 {
2231 residue.scratch_indexes.push(entry.path());
2232 }
2233 }
2234 }
2235 residue.refs.sort();
2236 residue.scratch_indexes.sort();
2237 residue
2238}
2239
2240fn review_residue_checks(config: Option<&Config>) -> Vec<DoctorCheck> {
2246 let Some(config) = config else {
2247 return Vec::new();
2248 };
2249 let mut repositories: Vec<PathBuf> = config
2250 .bundles
2251 .values()
2252 .flat_map(|bundle| bundle.repositories.iter())
2253 .filter_map(|repository| repository.local.clone())
2254 .collect();
2255 if let Ok(state) = crate::database::load_state() {
2260 repositories.extend(
2261 state
2262 .sessions
2263 .values()
2264 .filter_map(|session| session.project_directory.clone()),
2265 );
2266 }
2267 repositories.sort();
2268 repositories.dedup();
2269 if repositories.is_empty() {
2270 return Vec::new();
2271 }
2272 let found = repositories
2273 .into_iter()
2274 .map(|repository| {
2275 let residue = review_residue(&repository);
2276 (repository, residue)
2277 })
2278 .filter(|(_, residue)| !residue.is_empty())
2279 .collect::<Vec<_>>();
2280 if found.is_empty() {
2281 return vec![DoctorCheck::ready(
2282 "review.residue",
2283 "Review leftovers in your repositories",
2284 "No Mjolnir refs or scratch index files were found in the configured repositories.",
2285 )];
2286 }
2287 let detail = found
2288 .iter()
2289 .map(|(repository, residue)| {
2290 let mut parts = Vec::new();
2291 if !residue.refs.is_empty() {
2292 parts.push(residue.refs.join(", "));
2293 }
2294 if !residue.scratch_indexes.is_empty() {
2295 parts.push(format!(
2296 "{} leftover scratch index file(s)",
2297 residue.scratch_indexes.len()
2298 ));
2299 }
2300 format!("{}: {}", repository.display(), parts.join("; "))
2301 })
2302 .collect::<Vec<_>>()
2303 .join(". ");
2304 let commands = found
2305 .iter()
2306 .flat_map(|(repository, residue)| {
2307 let repository = repository.display().to_string();
2308 let mut commands = residue
2309 .refs
2310 .iter()
2311 .map(|reference| format!("git -C {repository} update-ref -d {reference}"))
2312 .collect::<Vec<_>>();
2313 commands.extend(
2314 residue
2315 .scratch_indexes
2316 .iter()
2317 .map(|index| format!("rm -f {}", index.display())),
2318 );
2319 commands.push(format!("git -C {repository} gc --prune=now"));
2320 commands
2321 })
2322 .collect::<Vec<_>>()
2323 .join("\n");
2324 vec![DoctorCheck::fixable(
2325 "review.residue",
2326 "Review leftovers in your repositories",
2327 format!(
2328 "Mjolnir left these in repositories it does not own: {detail}. \
2329 A running session's own `refs/hel/review-baseline` is in use; \
2330 remove that one only when no session is working in that repository."
2331 ),
2332 format!("Remove them yourself when you are ready:\n{commands}"),
2333 )]
2334}