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