use std::io::Write;
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use anyhow::Result;
use serde::Serialize;
use crate::controller::{WorkerBinaryAvailability, worker_binary_prerequisite_for_arch};
use crate::setup::{
DiscoveredHome, discover_harness_homes_with_executor, harness_is_authenticated_with_executor,
};
use crate::targets::{
BoundedProcessExecutor, CommandExecutor, CommandSpec, CommandTimedOut,
ContainerTemplate as RuntimeContainerTemplate, DockerUnavailable, PODMAN_DOCUMENTATION_URL,
PodmanPostcondition, ProcessExecutor, SshTarget as RuntimeSshTarget,
TargetTemplate as RuntimeTargetTemplate, failed_podman_postcondition, podman_probe_observation,
run_setup_smoke_test, ssh_command, ssh_connectivity_probe, ssh_validation_command,
verify_local_docker, verify_local_podman, verify_ssh_docker, verify_ssh_podman,
};
use mj_core::config::{
Config, ContainerTemplate, HarnessKind, HarnessProfile, TargetTemplate, config_path,
};
use mj_core::credentials::login_command;
const DEFAULT_CONTAINER_IMAGE: &str = "ubuntu:24.04";
const APPLE_CONTAINER_INSTALL_URL: &str = "https://github.com/apple/container#initial-install";
pub const PROBE_TIMEOUT: Duration = Duration::from_secs(15);
pub const fn probe_executor() -> BoundedProcessExecutor {
BoundedProcessExecutor::new(PROBE_TIMEOUT)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum CheckStatus {
Ready,
Warning,
Fixable,
Unsupported,
}
impl CheckStatus {
pub const fn label(self) -> &'static str {
match self {
Self::Ready => "ready",
Self::Warning => "warning",
Self::Fixable => "fixable",
Self::Unsupported => "unsupported",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct DoctorCheck {
pub id: String,
pub title: String,
pub status: CheckStatus,
pub detail: String,
pub remediation: Option<String>,
}
impl DoctorCheck {
fn ready(id: impl Into<String>, title: impl Into<String>, detail: impl Into<String>) -> Self {
Self {
id: id.into(),
title: title.into(),
status: CheckStatus::Ready,
detail: detail.into(),
remediation: None,
}
}
fn warning(
id: impl Into<String>,
title: impl Into<String>,
detail: impl Into<String>,
remediation: impl Into<String>,
) -> Self {
Self {
id: id.into(),
title: title.into(),
status: CheckStatus::Warning,
detail: detail.into(),
remediation: Some(remediation.into()),
}
}
pub(crate) fn fixable(
id: impl Into<String>,
title: impl Into<String>,
detail: impl Into<String>,
remediation: impl Into<String>,
) -> Self {
Self {
id: id.into(),
title: title.into(),
status: CheckStatus::Fixable,
detail: detail.into(),
remediation: Some(remediation.into()),
}
}
fn unsupported(
id: impl Into<String>,
title: impl Into<String>,
detail: impl Into<String>,
) -> Self {
Self {
id: id.into(),
title: title.into(),
status: CheckStatus::Unsupported,
detail: detail.into(),
remediation: None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct DoctorOptions {
pub smoke: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ApplePlatform {
Linux,
Macos {
architecture: String,
major_version: u32,
},
Other(String),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InstructionsPlatform {
Linux,
Macos,
}
pub fn run_current(options: DoctorOptions) -> Vec<DoctorCheck> {
if options.smoke {
return run_with(
&ProcessExecutor,
current_apple_platform(&ProcessExecutor),
options,
);
}
let executor = probe_executor();
run_with(&executor, current_apple_platform(&executor), options)
}
pub fn run_with(
executor: &impl CommandExecutor,
apple_platform: ApplePlatform,
options: DoctorOptions,
) -> Vec<DoctorCheck> {
run_with_config_path(&config_path(), executor, apple_platform, options)
}
pub fn run_with_config_path(
config_path: &Path,
executor: &impl CommandExecutor,
apple_platform: ApplePlatform,
options: DoctorOptions,
) -> Vec<DoctorCheck> {
let (loaded, mut checks) = configuration_checks(config_path);
let config: ConfigStatus<'_> = loaded.as_ref().map_err(|gap| *gap);
checks.push(harness_discovery_check(config, executor));
checks.extend(harness_checks(config, executor));
checks.extend(subagent_eligibility_checks(config));
let podman = podman_checks(config, executor, options.smoke, &apple_platform);
let docker = docker_checks(config, executor, options.smoke);
let apple_container = apple_container_check(
&apple_platform,
executor,
options.smoke,
apple_container_image(config),
);
let offered = config.map(|config| {
offered_targets(
config,
podman.iter().chain(&docker).chain([&apple_container]),
)
});
let offered = offered.as_ref().map_err(|gap| *gap);
checks.extend(podman);
checks.extend(docker);
checks.extend(ssh_bare_checks(config, executor));
checks.extend(ssh_podman_checks(config, executor, options.smoke));
checks.extend(ssh_docker_checks(config, executor, options.smoke));
checks.extend(build_cache_checks(offered, executor));
checks.extend(aws_checks(config, executor));
checks.extend(worker_binary_checks(offered));
checks.push(daemon_build_check());
checks.extend(worker_freshness_checks(offered));
checks.extend(review_residue_checks(config));
checks.push(apple_container);
checks
}
fn build_cache_checks(
config: ConfigStatus<'_>,
executor: &impl CommandExecutor,
) -> Vec<DoctorCheck> {
let Ok(config) = config else {
return Vec::new();
};
crate::controller::doctor_host_mbx(config, executor)
.into_iter()
.map(|host| {
let id = format!("build-cache.{}", host.host);
let title = format!("Build cache on {}", host.host);
let targets = host.targets.join(", ");
match host.status {
crate::controller::DoctorHostMbxStatus::Absent => DoctorCheck::ready(
id,
title,
format!(
"No native mbx is installed; targets {targets} can use Mjolnir's mbx {}.",
crate::controller::MBX_VERSION
),
),
crate::controller::DoctorHostMbxStatus::Compatible(version) => DoctorCheck::ready(
id,
title,
format!(
"Host mbx {version} is compatible with Mjolnir's mbx {} for targets {targets}.",
crate::controller::MBX_VERSION
),
),
crate::controller::DoctorHostMbxStatus::TooOld(version) => DoctorCheck::warning(
id,
title,
format!(
"Host mbx {version} is older than Mjolnir's mbx {}; sessions on targets {targets} run without the shared build cache.",
crate::controller::MBX_VERSION
),
format!(
"Upgrade mbx on {} to {} or newer, then rerun `mj doctor`.",
host.host,
crate::controller::MBX_VERSION
),
),
crate::controller::DoctorHostMbxStatus::Unknown(error) => DoctorCheck::warning(
id,
title,
format!("Could not check host mbx for targets {targets}: {error}"),
format!(
"Check access to {} and run `mbx --version` there, then rerun `mj doctor`.",
host.host
),
),
}
})
.collect()
}
fn harness_discovery_check(
config: ConfigStatus<'_>,
executor: &impl CommandExecutor,
) -> DoctorCheck {
let home = dirs::home_dir();
let overrides = HarnessKind::ALL.into_iter().filter_map(|kind| {
std::env::var_os(kind.home_env()).map(|path| (kind, kind.home_from_environment(path)))
});
let discovered = discover_harness_homes_with_executor(home.as_deref(), overrides, executor);
harness_discovery_check_from(
&discovered,
config.is_ok_and(|config| !config.profiles.is_empty()),
&settings_key(config.ok()),
)
}
fn harness_discovery_check_from(
discovered: &[DiscoveredHome],
has_configured_profiles: bool,
settings_key: &str,
) -> DoctorCheck {
if discovered.is_empty() {
return if has_configured_profiles {
DoctorCheck::ready(
"harness.discovery",
"Harness home discovery",
"No default or environment-overridden harness homes were found; configured profile homes are checked below.",
)
} else {
DoctorCheck::fixable(
"harness.discovery",
"Harness home discovery",
format!(
"No {} home was found in the default or environment-overridden locations.",
HarnessKind::every_display_name_or()
),
format!(
"Install and sign in to a supported harness, then open Mjolnir, press {settings_key} for Settings, and choose Agent Profiles."
),
)
};
}
let homes = discovered
.iter()
.map(|home| {
let authentication = if home.authenticated {
"authenticated"
} else {
"not authenticated"
};
format!(
"{} at {} ({authentication})",
home.kind.display_name(),
home.path.display()
)
})
.collect::<Vec<_>>()
.join("; ");
DoctorCheck::ready(
"harness.discovery",
"Harness home discovery",
format!("Discovered {homes}. Configured profile authentication is checked below."),
)
}
fn settings_key(config: Option<&Config>) -> String {
let keybinds = config.map_or_else(mj_core::config::Keybinds::default, Config::keybinds);
keybinds
.labels(mj_core::config::KeyAction::OpenSettings)
.into_iter()
.next()
.unwrap_or_else(|| "the Settings command in the command palette".to_owned())
}
pub fn all_ready(checks: &[DoctorCheck]) -> bool {
checks
.iter()
.all(|check| check.status != CheckStatus::Fixable)
}
pub fn render_human(checks: &[DoctorCheck], output: &mut impl Write) -> Result<()> {
for check in checks {
writeln!(
output,
"{} {}: {}",
check.status.label(),
check.title,
check.detail
)?;
if let Some(remediation) = &check.remediation {
writeln!(output, " remediation: {remediation}")?;
}
}
Ok(())
}
pub fn setup_instructions(platform: InstructionsPlatform) -> String {
match platform {
InstructionsPlatform::Linux => format!(
"# Mjolnir setup instructions for Linux\n\n\
This page is self-contained. Follow this exact loop as the user who will run `mj`:\n\n\
1. Run `mj doctor --json`.\n\
2. Follow every `fixable` remediation from its JSON output.\n\
3. Run `mj doctor --json` again. Repeat until no check is `fixable`.\n\
4. Finish with `mj doctor --json --smoke` to verify every configured container\n\
image end to end, and resolve anything it reports as `fixable`.\n\n\
For a coding-agent handoff, provide this entire instructions page together with\n\
the latest `mj doctor --json` output.\n\n\
## Local bare runtime\n\n\
A local bare runtime runs the agent directly on this machine. It needs the\n\
native `mj-worker` installed beside `mj`; the release installer and the npm\n\
package include it. The worker installs the pinned harness version itself.\n\
Codex and Claude need Node.js 22 or newer and npm on `PATH`. Kimi and Grok\n\
need curl and Bash. Muse needs curl and tar. Mjolnir does not install these\n\
prerequisites.\n\n\
## Linux container-runtime postconditions\n\n{}\n\n{}",
crate::targets::PODMAN_DOCUMENTATION,
crate::targets::DOCKER_DOCUMENTATION
),
InstructionsPlatform::Macos => format!(
"# Mjolnir setup instructions for macOS\n\n\
Run these commands as the user who will run `mj`:\n\n\
1. Install and sign in to a supported coding agent, then run `mj setup`.\n\
Setup discovers agent homes and local runtimes, offers AWS when the AWS CLI\n\
has credentials, and offers SSH hosts from `~/.ssh/config`. Review its\n\
proposed configuration and confirm writing `config.toml`. It runs smoke\n\
tests for newly added container targets and ends with a doctor report.\n\
2. Run `mj doctor --json` and follow every `fixable` remediation. Repeat until\n\
no check is `fixable`. Review warnings for any target you intend to use;\n\
`unsupported` runtimes are unavailable, not ready.\n\
3. If you intend to use containers, start the runtime and run\n\
`mj doctor --json --smoke`. Do this for Docker Desktop as well as Apple\n\
container. Resolve failures for the target you intend to use.\n\
4. Run `mj`, then press n in the Sessions pane to start your first session.\n\
If no profile was discovered, open Settings (Ctrl-B then s by default)\n\
to add one first.\n\n\
You can also configure profiles and targets in Settings instead of `mj setup`.\n\
For a named instance, use the same `--instance <name>` on every command above.\n\n\
For a coding-agent handoff, provide this entire instructions page together with\n\
the latest `mj doctor --json` output.\n\n\
## Local bare runtime\n\n\
A local bare runtime runs the agent directly on this machine. It needs the\n\
native `mj-worker` installed beside `mj`; the release installer, npm package,\n\
and Homebrew formula include it (Homebrew keeps both binaries in libexec).\n\
The worker installs the pinned harness version itself. Codex and Claude need\n\
Node.js 22 or newer and npm on `PATH`. Kimi and Grok need curl and Bash.\n\
Muse needs curl and tar. Mjolnir does not install these prerequisites.\n\n\
## Docker Desktop\n\n\
Start Docker Desktop and wait for its Linux daemon to be ready before setup\n\
or the smoke test. `mj doctor --json --smoke` checks the container image and\n\
the OverlayFS copy-on-write attachment, including disposable container cleanup.\n\
A connected daemon alone does not establish that these operations work.\n\n\
## Apple container runtime\n\n\
Mjolnir's Apple container target requires Apple silicon and macOS 26 or newer.\n\
On an Intel Mac or an older macOS release, the target is unsupported; use the\n\
local bare runtime, an SSH target, or an AWS target instead.\n\n\
On a supported Mac, if `container` is absent, install only the official signed package:\n\n\
<https://github.com/apple/container#initial-install>\n\n\
Mjolnir never downloads or installs that package. If doctor reports a stopped\n\
daemon, run exactly:\n\n```console\ncontainer system start\n```\n\n\
Finish with the opt-in disposable runtime test in JSON mode:\n\n```console\nmj doctor --json --smoke\n```\n\n\
Apple container is ready only when that smoke test creates a disposable\n\
container, executes `true` in it, and removes it successfully. Use the image\n\
configured by an `apple-container` target; without one, doctor uses\n\
`{DEFAULT_CONTAINER_IMAGE}` for the smoke test.\n\n\
## Shared Mjolnir prerequisites\n\n\
`mj doctor --json` also checks the configuration, each configured harness home\n\
and authentication marker, selected container worker binaries, and any relevant\n\
Podman prerequisites. Resolve every `fixable` status before starting a session.\n"
),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ConfigGap {
NewerVersion(u32),
Missing,
Unreadable,
}
impl ConfigGap {
fn awaited(self) -> &'static str {
match self {
ConfigGap::NewerVersion(_) => "this Mjolnir can read config.toml",
ConfigGap::Missing => "config.toml exists",
ConfigGap::Unreadable => "config.toml is valid",
}
}
fn remediation(self) -> &'static str {
match self {
ConfigGap::NewerVersion(_) => {
"Update Mjolnir to the build that wrote config.toml or newer, then rerun `mj doctor`."
}
ConfigGap::Missing => {
"Run `mj` and add an agent profile, or run `mj setup`; either one writes config.toml. Then rerun `mj doctor`."
}
ConfigGap::Unreadable => "Fix config.toml, then rerun `mj doctor`.",
}
}
}
type ConfigStatus<'a> = std::result::Result<&'a Config, ConfigGap>;
fn newer_config_skip(id: &str, title: &str, version: u32) -> DoctorCheck {
DoctorCheck::unsupported(
id,
title,
format!(
"Skipped: config.toml was written by a newer Mjolnir (config version {version}; this build supports {}). Update Mjolnir to that build or newer.",
mj_core::config::CONFIG_VERSION
),
)
}
fn configuration_checks(path: &Path) -> (std::result::Result<Config, ConfigGap>, Vec<DoctorCheck>) {
if !path.exists() {
return (
Err(ConfigGap::Missing),
vec![DoctorCheck::fixable(
"config",
"Mjolnir configuration",
format!("{} does not exist", path.display()),
format!(
"Open Mjolnir and press {} for Settings to add an agent profile.",
settings_key(None)
),
)],
);
}
if let Some(found) = mj_core::config::newer_version_on_disk(path) {
return (
Err(ConfigGap::NewerVersion(found)),
vec![DoctorCheck::fixable(
"config",
"Mjolnir configuration",
format!(
"{} was written by a newer Mjolnir (config version {found}; this build supports {})",
path.display(),
mj_core::config::CONFIG_VERSION
),
"Update Mjolnir to that build or newer. Do not lower the version value by hand or replace the file.",
)],
);
}
match Config::load_from(path) {
Ok(config) => {
let mut checks = vec![DoctorCheck::ready(
"config",
"Mjolnir configuration",
format!("{} is valid", path.display()),
)];
if config.enabled_profiles().next().is_none() {
checks.push(DoctorCheck::fixable(
"config.session-prerequisites",
"Session configuration",
"No agent profile is enabled, so no session can start. Local targets are supplied automatically.",
format!(
"Open Mjolnir and press {} for Settings to add or enable an agent profile.",
settings_key(Some(&config))
),
));
} else if config.bundles.is_empty() {
checks.push(DoctorCheck::ready(
"config.session-prerequisites",
"Session configuration",
"An agent profile is enabled. No project bundle is configured; sessions start from a project directory, and a bundle is only needed to start from a saved set of repositories.",
));
} else {
checks.push(DoctorCheck::ready(
"config.session-prerequisites",
"Session configuration",
"At least one profile, bundle, and target are configured.",
));
}
(Ok(config), checks)
}
Err(error) => (
Err(ConfigGap::Unreadable),
vec![DoctorCheck::fixable(
"config",
"Mjolnir configuration",
format!("{} is invalid: {error:#}", path.display()),
"Fix the reported TOML error in config.toml, or run `mj setup` to replace it.",
)],
),
}
}
fn harness_checks(config: ConfigStatus<'_>, executor: &impl CommandExecutor) -> Vec<DoctorCheck> {
let config = match config {
Ok(config) => config,
Err(ConfigGap::NewerVersion(version)) => {
return vec![newer_config_skip(
"harness.profiles",
"Harness profiles",
version,
)];
}
Err(gap @ (ConfigGap::Missing | ConfigGap::Unreadable)) => {
return vec![DoctorCheck::fixable(
"harness.profiles",
"Harness profiles",
format!("Harness homes cannot be checked until {}.", gap.awaited()),
gap.remediation(),
)];
}
};
if config.profiles.is_empty() {
return vec![DoctorCheck::fixable(
"harness.profiles",
"Harness profiles",
"No harness profiles are configured.",
format!(
"Open Mjolnir, press {} for Settings, and choose Agent Profiles to detect accounts or add a profile.",
settings_key(Some(config))
),
)];
}
config
.profiles
.iter()
.map(|(id, profile)| {
let mut check = harness_profile_check(id, profile, executor);
check.detail = format!("{} {}", profile_summary(config, id, profile), check.detail);
check
})
.collect()
}
fn harness_profile_check(
id: &str,
profile: &HarnessProfile,
executor: &impl CommandExecutor,
) -> DoctorCheck {
let title = format!("Harness profile {id}");
if !profile.enabled {
return DoctorCheck::ready(
format!("harness.{id}"),
title,
"Profile is disabled; home and authentication checks were skipped.",
);
}
if !profile.home.is_dir() {
return DoctorCheck::fixable(
format!("harness.{id}"),
title,
format!("{} does not exist", profile.home.display()),
format!(
"{} If this profile should use an existing installation, select its home in Setup.",
harness_login_remediation(id, profile)
),
);
}
if !harness_is_authenticated_with_executor(profile, executor) {
return DoctorCheck::fixable(
format!("harness.{id}"),
title,
format!(
"No usable authentication was detected for {}",
profile.home.display()
),
harness_login_remediation(id, profile),
);
}
DoctorCheck::ready(
format!("harness.{id}"),
title,
format!(
"{} is present and authentication is available",
profile.home.display()
),
)
}
fn profile_summary(config: &Config, id: &str, profile: &HarnessProfile) -> String {
let delegation = if config
.subagents
.eligible_profiles
.get(id)
.copied()
.unwrap_or(false)
{
"any session's sub-agents may use it"
} else {
"only its own sessions' sub-agents may use it"
};
format!(
"{}; {}; {delegation}.",
profile.kind.display_name(),
profile_quota_source(profile)
)
}
fn profile_quota_source(profile: &HarnessProfile) -> String {
match profile.kind {
HarnessKind::Claude => "Claude subscription quota".to_owned(),
HarnessKind::Codex => match crate::quota::provider_credential(profile) {
Some(provider) if crate::zai_usage::serves_quota(&provider.host) => {
format!("quota from {}", provider.host)
}
Some(provider) => format!(
"pay-per-use through {}, counted as 100% left when choosing a sub-agent's profile",
provider.host
),
None => match profile.codex_provider() {
Ok(None) => "ChatGPT subscription quota".to_owned(),
Ok(Some(provider)) => format!(
"no quota report, because custom provider {:?} has no API key in this profile's environment",
provider.id
),
Err(error) => format!("its Codex config.toml could not be read ({error:#})"),
},
},
kind => format!("quota as {} reports it", kind.display_name()),
}
}
fn subagent_eligibility_checks(config: ConfigStatus<'_>) -> Vec<DoctorCheck> {
let Ok(config) = config else {
return Vec::new();
};
let mut checks = vec![subagent_policy_check(config)];
checks.extend(
config
.subagents
.eligible_profiles
.iter()
.filter(|(_, eligible)| **eligible)
.filter_map(|(id, _)| match config.profiles.get(id) {
Some(profile) if !profile.enabled => Some(DoctorCheck::warning(
format!("subagents.{id}"),
format!("Sub-agent profile {id}"),
format!(
"Profile {id:?} is listed in [subagents.eligible_profiles] but is disabled, so it is not offered for sub-agent use."
),
format!(
"Re-enable profile {id:?}, or remove it from [subagents.eligible_profiles]."
),
)),
_ => None,
}),
);
checks
}
fn subagent_policy_check(config: &Config) -> DoctorCheck {
let subagents = &config.subagents;
let eligible = subagents
.eligible_profiles
.iter()
.filter(|(_, eligible)| **eligible)
.map(|(id, _)| id.as_str())
.collect::<Vec<_>>();
let others = if eligible.is_empty() {
"no other profile".to_owned()
} else {
eligible.join(", ")
};
let detail = format!(
"Claude and Codex sessions may opt in, up to {} sub-agents at once per session. A session's sub-agents may use its own profile and: {others}.",
subagents.max_concurrent
);
DoctorCheck::ready("subagents.policy", "Sub-agent policy", detail)
}
fn harness_login_remediation(id: &str, profile: &HarnessProfile) -> String {
let (program, arguments) = match login_command(profile) {
Ok(command) => command,
Err(error) => return format!("{error} Check {}.", profile.home.display()),
};
format!(
"Run `mj login --profile {id}`; it runs `{program} {}` against {}.",
arguments.join(" "),
profile.home.display()
)
}
fn podman_checks(
config: ConfigStatus<'_>,
executor: &impl CommandExecutor,
smoke: bool,
platform: &ApplePlatform,
) -> Vec<DoctorCheck> {
let effective = config.map(|config| config.clone().with_local_targets());
let effective = effective.as_ref().map_err(|gap| *gap);
let explicit = config.is_ok_and(|config| {
local_podman_targets(config)
.iter()
.any(|(id, _)| config.configures_target(id))
});
let preflight = builtin_target_availability(
podman_check(effective, executor, platform),
explicit,
"Podman",
"podman",
);
let preflight_passed = preflight.status == CheckStatus::Ready;
let mut checks = vec![preflight];
if preflight_passed {
checks.extend(
podman_image_checks(effective, executor, smoke)
.into_iter()
.map(|(id, check)| builtin_image_check(config, &id, check)),
);
}
checks
}
fn builtin_image_check(
config: ConfigStatus<'_>,
target_id: &str,
check: DoctorCheck,
) -> DoctorCheck {
let explicit = config.is_ok_and(|config| config.configures_target(target_id));
if explicit || check.status != CheckStatus::Fixable {
return check;
}
DoctorCheck::warning(
check.id,
check.title,
format!(
"{} (built-in `{target_id}` target; the dashboard downloads its image when it starts)",
check.detail
),
check.remediation.unwrap_or_default(),
)
}
fn builtin_target_availability(
check: DoctorCheck,
explicit: bool,
engine: &str,
target_id: &str,
) -> DoctorCheck {
if explicit || check.status != CheckStatus::Fixable {
return check;
}
DoctorCheck::unsupported(
check.id,
check.title,
format!(
"{engine} is not available, so the built-in `{target_id}` target is marked unavailable: {}",
check.detail
),
)
}
fn podman_check(
config: ConfigStatus<'_>,
executor: &impl CommandExecutor,
platform: &ApplePlatform,
) -> DoctorCheck {
let config = match config {
Ok(config) => config,
Err(ConfigGap::NewerVersion(version)) => {
return newer_config_skip("runtime.podman", "Rootless Podman", version);
}
Err(gap @ (ConfigGap::Missing | ConfigGap::Unreadable)) => {
return DoctorCheck::unsupported(
"runtime.podman",
"Rootless Podman",
format!(
"Podman prerequisites cannot be evaluated until {}.",
gap.awaited()
),
);
}
};
if local_podman_targets(config).is_empty() {
return DoctorCheck::unsupported(
"runtime.podman",
"Rootless Podman",
"No local-podman target is configured.",
);
}
local_podman_runtime_check(executor, platform)
}
pub fn local_podman_runtime_check(
executor: &impl CommandExecutor,
platform: &ApplePlatform,
) -> DoctorCheck {
if !matches!(platform, ApplePlatform::Linux) {
return DoctorCheck::unsupported(
"runtime.podman",
"Rootless Podman",
"Mjolnir's local Podman target requires a Linux host; Podman machine is not supported. Use localhost or an SSH target on a Linux host.",
);
}
match verify_local_podman(executor) {
Ok(preflight) => DoctorCheck::ready(
"runtime.podman",
"Rootless Podman",
format!("Podman {} has a valid rootless UID map.", preflight.version),
),
Err(error) => DoctorCheck::fixable(
"runtime.podman",
"Rootless Podman",
podman_failure_detail(&error),
podman_remediation(&error),
),
}
}
fn local_podman_targets(config: &Config) -> Vec<(&String, &ContainerTemplate)> {
config
.targets
.iter()
.filter_map(|(id, target)| match target {
TargetTemplate::LocalPodman { container } => Some((id, container)),
_ => None,
})
.collect()
}
fn podman_image_checks(
config: ConfigStatus<'_>,
executor: &impl CommandExecutor,
smoke: bool,
) -> Vec<(String, DoctorCheck)> {
let Ok(config) = config else {
return Vec::new();
};
local_podman_targets(config)
.into_iter()
.map(|(id, container)| {
(
id.clone(),
podman_image_check(id, &container.image, executor, smoke),
)
})
.collect()
}
fn podman_image_check(
id: &str,
image: &str,
executor: &impl CommandExecutor,
smoke: bool,
) -> DoctorCheck {
let check_id = format!("runtime.podman.image.{id}");
let title = format!("Podman image for target {id}");
if smoke {
let target = RuntimeTargetTemplate::LocalPodman(RuntimeContainerTemplate {
build_cache: None,
image: image.to_owned(),
pull_policy: Default::default(),
extra_run_args: vec![],
workspace_storage: Default::default(),
});
return match run_setup_smoke_test(&target, &doctor_smoke_id(), executor) {
Ok(()) => DoctorCheck::ready(
check_id,
title,
format!("Disposable run/exec/remove smoke test passed for image {image}."),
),
Err(error) => DoctorCheck::fixable(
check_id,
title,
format!(
"Disposable run/exec/remove smoke test failed for image {image}: {error:#}"
),
"Fix the configured image or Podman runtime, then run `mj doctor --json --smoke` again.",
),
};
}
let command = CommandSpec::new("podman", ["image", "exists", image])
.purpose("check Podman image presence");
match executor.execute(&command) {
Ok(output) if output.status == 0 => DoctorCheck::ready(
check_id,
title,
format!("Image {image} is present in local Podman storage."),
),
Ok(_) => DoctorCheck::fixable(
check_id,
title,
format!("Image {image} is not present in local Podman storage."),
missing_image_remediation(image),
),
Err(error) => DoctorCheck::fixable(
check_id,
title,
format!(
"Could not check whether image {image} is present in local Podman storage: {error}"
),
missing_image_remediation(image),
),
}
}
fn missing_image_remediation(image: &str) -> String {
format!(
"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."
)
}
fn docker_checks(
config: ConfigStatus<'_>,
executor: &impl CommandExecutor,
smoke: bool,
) -> Vec<DoctorCheck> {
let config = match config {
Ok(config) => config,
Err(ConfigGap::NewerVersion(version)) => {
return vec![newer_config_skip("runtime.docker", "Docker", version)];
}
Err(gap @ (ConfigGap::Missing | ConfigGap::Unreadable)) => {
return vec![DoctorCheck::unsupported(
"runtime.docker",
"Docker",
format!(
"Docker prerequisites cannot be evaluated until {}.",
gap.awaited()
),
)];
}
};
let explicit = local_docker_targets(config)
.iter()
.any(|(id, _)| config.configures_target(id));
let effective = config.clone().with_local_targets();
let targets = local_docker_targets(&effective);
if targets.is_empty() {
return vec![DoctorCheck::unsupported(
"runtime.docker",
"Docker",
"No local-docker target is configured.",
)];
}
let preflight = builtin_target_availability(
local_docker_runtime_check(executor),
explicit,
"Docker",
"docker",
);
if preflight.status != CheckStatus::Ready {
return vec![preflight];
}
let mut checks = vec![preflight];
checks.extend(targets.into_iter().map(|(id, container)| {
builtin_image_check(
Ok(config),
id,
docker_image_check(id, &container.image, executor, smoke),
)
}));
checks
}
pub fn local_docker_runtime_check(executor: &impl CommandExecutor) -> DoctorCheck {
match verify_local_docker(executor) {
Ok(preflight) => DoctorCheck::ready(
"runtime.docker",
"Docker",
format!(
"Docker {} is connected to a Linux daemon.",
preflight.version
),
),
Err(error) => match error.downcast_ref::<DockerUnavailable>() {
Some(problem) => DoctorCheck::fixable(
"runtime.docker",
"Docker",
problem.to_string(),
problem.remediation(),
),
None => DoctorCheck::fixable(
"runtime.docker",
"Docker",
format!("{error:#}"),
"Install and start Docker, then make sure `docker info` succeeds as the user running mj.",
),
},
}
}
fn local_docker_targets(config: &Config) -> Vec<(&String, &ContainerTemplate)> {
config
.targets
.iter()
.filter_map(|(id, target)| match target {
TargetTemplate::LocalDocker { container } => Some((id, container)),
_ => None,
})
.collect()
}
fn docker_image_check(
id: &str,
image: &str,
executor: &impl CommandExecutor,
smoke: bool,
) -> DoctorCheck {
let check_id = format!("runtime.docker.image.{id}");
let title = format!("Docker image for target {id}");
if smoke {
let target = RuntimeTargetTemplate::LocalDocker(RuntimeContainerTemplate {
build_cache: None,
image: image.to_owned(),
pull_policy: Default::default(),
extra_run_args: vec![],
workspace_storage: Default::default(),
});
return match run_setup_smoke_test(&target, &doctor_smoke_id(), executor) {
Ok(()) => DoctorCheck::ready(
check_id,
title,
format!(
"Disposable run/exec/remove and OverlayFS attachment smoke test passed for image {image}."
),
),
Err(error) => DoctorCheck::fixable(
check_id,
title,
format!(
"Disposable run/exec/remove smoke test failed for image {image}: {error:#}"
),
"Fix the configured image or Docker runtime, then run `mj doctor --json --smoke` again.",
),
};
}
let command = CommandSpec::new("docker", ["image", "inspect", image])
.purpose("check Docker image presence");
match executor.execute(&command) {
Ok(output) if output.status == 0 => DoctorCheck::ready(
check_id,
title,
format!("Image {image} is present in Docker storage."),
),
Ok(_) => DoctorCheck::fixable(
check_id,
title,
format!("Image {image} is not present in Docker storage."),
format!("Pull it with `docker pull {image}`, or run `mj doctor --json --smoke`."),
),
Err(error) => DoctorCheck::fixable(
check_id,
title,
format!("Could not inspect Docker image {image}: {error}"),
format!("Make sure `docker info` succeeds, then run `docker pull {image}`."),
),
}
}
enum SshConnectivity {
Reachable,
Failed { detail: String, remediation: String },
}
fn ssh_connectivity(ssh: &RuntimeSshTarget, executor: &impl CommandExecutor) -> SshConnectivity {
let destination = &ssh.destination;
let command = ssh_connectivity_probe(ssh);
match executor.execute(&command) {
Err(error) => SshConnectivity::Failed {
detail: format!("Could not run `ssh {destination} true`: {error:#}"),
remediation: ssh_launch_failure_remediation(&error, ssh),
},
Ok(output) if output.status != 0 => {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned();
SshConnectivity::Failed {
detail: format!("`ssh {destination} true` failed: {stderr}"),
remediation: ssh_failure_remediation(&stderr, ssh),
}
}
Ok(_) => SshConnectivity::Reachable,
}
}
const 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).";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SshFailure {
UntrustedHostKey,
Unauthenticated,
ClientMissing,
Unreachable,
Unrecognized,
}
fn classify_ssh_stderr(stderr: &str) -> SshFailure {
const UNTRUSTED_HOST_KEY: [&str; 3] = [
"Host key verification failed",
"No ECDSA host key is known",
"REMOTE HOST IDENTIFICATION HAS CHANGED",
];
const UNAUTHENTICATED: [&str; 4] = [
"Permission denied",
"Too many authentication failures",
"no matching host key",
"Authentication failed",
];
const CLIENT_MISSING: [&str; 2] = ["ssh: command not found", "No such file or directory"];
const UNREACHABLE: [&str; 3] = [
"Connection timed out",
"No route to host",
"Network is unreachable",
];
let reported = |signatures: &[&str]| signatures.iter().any(|text| stderr.contains(text));
if reported(&UNTRUSTED_HOST_KEY) {
SshFailure::UntrustedHostKey
} else if reported(&UNAUTHENTICATED) {
SshFailure::Unauthenticated
} else if reported(&CLIENT_MISSING) {
SshFailure::ClientMissing
} else if reported(&UNREACHABLE) {
SshFailure::Unreachable
} else {
SshFailure::Unrecognized
}
}
fn ssh_launch_failure_remediation(error: &anyhow::Error, ssh: &RuntimeSshTarget) -> String {
if error.downcast_ref::<CommandTimedOut>().is_some() {
return ssh_unreachable_remediation(ssh);
}
let missing_binary = error.chain().any(|cause| {
cause
.downcast_ref::<std::io::Error>()
.is_some_and(|io| io.kind() == std::io::ErrorKind::NotFound)
});
if missing_binary {
return SSH_MISSING_REMEDIATION.to_owned();
}
format!(
"Run `ssh {} true` by hand and resolve the error it reports: {error:#}",
ssh.destination
)
}
fn ssh_unreachable_remediation(ssh: &RuntimeSshTarget) -> String {
let host = ssh_host_only(&ssh.destination);
format!(
"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.",
ssh.destination
)
}
fn ssh_failure_remediation(stderr: &str, ssh: &RuntimeSshTarget) -> String {
let destination = &ssh.destination;
match classify_ssh_stderr(stderr) {
SshFailure::UntrustedHostKey => {
let host = ssh_host_only(destination);
format!(
"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."
)
}
SshFailure::Unauthenticated => match ssh_identity_file(ssh) {
Some(identity) => format!(
"Install your public key on the host with `ssh-copy-id -i {identity}.pub {destination}`."
),
None => {
format!("Install your public key on the host with `ssh-copy-id {destination}`.")
}
},
SshFailure::ClientMissing => SSH_MISSING_REMEDIATION.to_owned(),
SshFailure::Unreachable => ssh_unreachable_remediation(ssh),
SshFailure::Unrecognized => {
format!(
"Run `ssh {destination} true` by hand and resolve the error it reports: {stderr}"
)
}
}
}
fn ssh_host_only(destination: &str) -> &str {
destination
.rsplit_once('@')
.map_or(destination, |(_, host)| host)
}
fn ssh_identity_file(ssh: &RuntimeSshTarget) -> Option<&str> {
let position = ssh.ssh_args.iter().position(|arg| arg == "-i")?;
ssh.ssh_args.get(position + 1).map(String::as_str)
}
fn ssh_bare_checks(config: ConfigStatus<'_>, executor: &impl CommandExecutor) -> Vec<DoctorCheck> {
let Ok(config) = config else {
return Vec::new();
};
config
.targets
.iter()
.filter_map(|(id, target)| match target {
TargetTemplate::SshBare { ssh, .. } => {
Some(ssh_bare_check(id, &RuntimeSshTarget::from(ssh), executor))
}
_ => None,
})
.collect()
}
fn ssh_bare_check(
id: &str,
ssh: &RuntimeSshTarget,
executor: &impl CommandExecutor,
) -> DoctorCheck {
let check_id = format!("runtime.ssh-bare.{id}");
let title = format!("SSH access for target {id}");
match ssh_connectivity(ssh, executor) {
SshConnectivity::Reachable => DoctorCheck::ready(
check_id,
title,
format!(
"`ssh {} true` succeeds noninteractively from this host.",
ssh.destination
),
),
SshConnectivity::Failed {
detail,
remediation,
} => DoctorCheck::fixable(check_id, title, detail, remediation),
}
}
fn ssh_podman_checks(
config: ConfigStatus<'_>,
executor: &impl CommandExecutor,
smoke: bool,
) -> Vec<DoctorCheck> {
let Ok(config) = config else {
return Vec::new();
};
config
.targets
.iter()
.flat_map(|(id, target)| match target {
TargetTemplate::SshPodman { ssh, container, .. } => {
let ssh = RuntimeSshTarget::from(ssh);
let (check, reachable) =
ssh_podman_check(id, &ssh, &container.image, executor, smoke);
let mut checks = vec![check];
if reachable {
checks.push(ssh_podman_limits_check(id, &ssh, executor));
}
checks
}
_ => Vec::new(),
})
.collect()
}
fn ssh_podman_check(
id: &str,
ssh: &RuntimeSshTarget,
image: &str,
executor: &impl CommandExecutor,
smoke: bool,
) -> (DoctorCheck, bool) {
let check_id = format!("runtime.ssh-podman.{id}");
let title = format!("Remote Podman for target {id}");
if let SshConnectivity::Failed {
detail,
remediation,
} = ssh_connectivity(ssh, executor)
{
return (
DoctorCheck::fixable(check_id, title, detail, remediation),
false,
);
}
(
ssh_podman_runtime_check(check_id, title, ssh, image, executor, smoke),
true,
)
}
fn ssh_podman_runtime_check(
check_id: String,
title: String,
ssh: &RuntimeSshTarget,
image: &str,
executor: &impl CommandExecutor,
smoke: bool,
) -> DoctorCheck {
let destination = &ssh.destination;
let preflight = match verify_ssh_podman(ssh, executor) {
Ok(preflight) => preflight,
Err(error) => {
let detail = podman_failure_detail(&error);
let remediation = match podman_remediation_match(&error) {
Some(remediation) => {
format!("On {destination}: {remediation} See {PODMAN_DOCUMENTATION_URL}.")
}
None => format!(
"Verify `ssh {destination}` succeeds noninteractively from this host, then install rootless Podman 4.3 or newer there. See {PODMAN_DOCUMENTATION_URL}."
),
};
return DoctorCheck::fixable(check_id, title, detail, remediation);
}
};
let linger_warning = preflight.warnings.first();
if !smoke && let Some(warning) = linger_warning {
return DoctorCheck::warning(
check_id,
title,
format!(
"Remote rootless Podman {} is available via {destination}, but {}",
preflight.version, warning.detail
),
&warning.remediation,
);
}
if !smoke {
return DoctorCheck::ready(
check_id,
title,
format!(
"Remote rootless Podman {} is available via {destination}. Run `mj doctor --json --smoke` to verify the image end to end.",
preflight.version
),
);
}
let target = RuntimeTargetTemplate::SshPodman {
ssh: ssh.clone(),
container: RuntimeContainerTemplate {
build_cache: None,
image: image.to_owned(),
pull_policy: Default::default(),
extra_run_args: vec![],
workspace_storage: Default::default(),
},
};
match run_setup_smoke_test(&target, &doctor_smoke_id(), executor) {
Ok(()) => match linger_warning {
Some(warning) => DoctorCheck::warning(
check_id,
title,
format!(
"Disposable run/exec/remove smoke test passed for image {image} on {destination}, but {}",
warning.detail
),
&warning.remediation,
),
None => DoctorCheck::ready(
check_id,
title,
format!(
"Disposable run/exec/remove smoke test passed for image {image} on {destination}."
),
),
},
Err(error) => DoctorCheck::fixable(
check_id,
title,
format!(
"Disposable run/exec/remove smoke test failed for image {image} on {destination}: {error:#}"
),
format!(
"Fix the configured image or Podman runtime on {destination}, then run `mj doctor --json --smoke` again."
),
),
}
}
const SSH_PODMAN_HOST_LIMITS_SCRIPT: &str = r#"
if [ -r /proc/sys/kernel/keys/maxkeys ]; then
printf 'keys.max=%s\n' "$(cat /proc/sys/kernel/keys/maxkeys)"
fi
if [ -r /proc/key-users ]; then
awk -v uid="$(id -u)" '
{ user = $1; sub(/:$/, "", user) }
user == uid {
split($4, quota, "/")
printf "keys.used=%s\nkeys.quota=%s\n", quota[1], quota[2]
}
' /proc/key-users
fi
unreadable=0
maxstartups=
# A drop-in directory that cannot be listed hides any override it holds.
if [ -d /etc/ssh/sshd_config.d ] && ! [ -r /etc/ssh/sshd_config.d ]; then
unreadable=1
fi
for file in /etc/ssh/sshd_config /etc/ssh/sshd_config.d/*.conf; do
[ -e "$file" ] || continue
if [ -r "$file" ]; then
match=$(grep -i '^[[:space:]]*maxstartups[[:space:]]' "$file" 2>/dev/null | tail -n 1)
[ -n "$match" ] && maxstartups=$(printf '%s\n' "$match" | awk '{ print $2 }')
else
unreadable=1
fi
done
[ -n "$maxstartups" ] && printf 'maxstartups=%s\n' "$maxstartups"
[ "$unreadable" = 1 ] && printf 'maxstartups.unreadable=1\n'
exit 0
"#;
const KEYRING_PRESSURE_PERCENT: u64 = 80;
#[derive(Debug, Default, PartialEq, Eq)]
struct HostLimits {
keys_used: Option<u64>,
keys_quota: Option<u64>,
keys_max: Option<u64>,
max_startups: Option<String>,
max_startups_unreadable: bool,
}
fn parse_host_limits(stdout: &[u8]) -> HostLimits {
let text = String::from_utf8_lossy(stdout);
let mut limits = HostLimits::default();
for line in text.lines() {
let Some((name, value)) = line.split_once('=') else {
continue;
};
let value = value.trim();
match name.trim() {
"keys.used" => limits.keys_used = value.parse().ok(),
"keys.quota" => limits.keys_quota = value.parse().ok(),
"keys.max" => limits.keys_max = value.parse().ok(),
"maxstartups" if !value.is_empty() => limits.max_startups = Some(value.to_owned()),
"maxstartups.unreadable" => limits.max_startups_unreadable = value == "1",
_ => {}
}
}
limits
}
impl HostLimits {
fn is_empty(&self) -> bool {
self.keys_used.is_none()
&& self.keys_quota.is_none()
&& self.keys_max.is_none()
&& self.max_startups.is_none()
&& !self.max_startups_unreadable
}
fn keyring_is_under_pressure(&self) -> bool {
match (self.keys_used, self.keys_quota) {
(Some(used), Some(quota)) if quota > 0 => {
used.saturating_mul(100) >= quota.saturating_mul(KEYRING_PRESSURE_PERCENT)
}
_ => false,
}
}
fn keyring_sentence(&self, destination: &str) -> String {
match (self.keys_used, self.keys_quota) {
(Some(used), Some(quota)) => {
let system = match self.keys_max {
Some(max) => format!(", and `kernel.keys.maxkeys` is {max}"),
None => String::new(),
};
format!(
"The login user on {destination} holds {used} of its {quota} kernel keyring quota{system}."
)
}
_ => format!(
"The kernel keyring quota for the login user on {destination} could not be read."
),
}
}
fn max_startups_sentence(&self) -> String {
match (&self.max_startups, self.max_startups_unreadable) {
(Some(value), _) => format!("sshd MaxStartups is {value}."),
(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(),
(None, false) => {
"sshd MaxStartups is not set in sshd_config, so sshd's default applies.".to_owned()
}
}
}
}
fn ssh_podman_limits_check(
id: &str,
ssh: &RuntimeSshTarget,
executor: &impl CommandExecutor,
) -> DoctorCheck {
let check_id = format!("runtime.ssh-podman.{id}.limits");
let title = format!("Host limits for target {id}");
let destination = &ssh.destination;
let manual = || {
format!(
"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`."
)
};
let command = ssh_validation_command(
ssh,
vec![
"sh".to_owned(),
"-c".to_owned(),
SSH_PODMAN_HOST_LIMITS_SCRIPT.to_owned(),
],
"read ssh-podman host limits",
);
let limits = match executor.execute(&command) {
Ok(output) if output.status == 0 => parse_host_limits(&output.stdout),
Ok(output) => {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned();
return DoctorCheck::warning(
check_id,
title,
format!(
"Could not read the kernel keyring quota or sshd MaxStartups from {destination}: {stderr}"
),
manual(),
);
}
Err(error) => {
return DoctorCheck::warning(
check_id,
title,
format!(
"Could not read the kernel keyring quota or sshd MaxStartups from {destination}: {error}"
),
manual(),
);
}
};
if limits.is_empty() {
return DoctorCheck::warning(
check_id,
title,
format!("{destination} reported no readable kernel keyring or sshd limits."),
manual(),
);
}
let detail = format!(
"{} {}",
limits.keyring_sentence(destination),
limits.max_startups_sentence()
);
if limits.keyring_is_under_pressure() {
return DoctorCheck::warning(
check_id,
title,
format!(
"{detail} Every container takes a session keyring, so `podman run` fails with `crun: create keyring` once the quota is gone."
),
format!(
"Raise `kernel.keys.maxkeys` and `kernel.keys.maxbytes` with sysctl on {destination}, and close finished sessions promptly."
),
);
}
DoctorCheck::ready(check_id, title, detail)
}
fn ssh_docker_checks(
config: ConfigStatus<'_>,
executor: &impl CommandExecutor,
smoke: bool,
) -> Vec<DoctorCheck> {
let Ok(config) = config else {
return Vec::new();
};
config
.targets
.iter()
.filter_map(|(id, target)| match target {
TargetTemplate::SshDocker { ssh, container } => Some(ssh_docker_check(
id,
&RuntimeSshTarget::from(ssh),
&container.image,
executor,
smoke,
)),
_ => None,
})
.collect()
}
fn ssh_docker_check(
id: &str,
ssh: &RuntimeSshTarget,
image: &str,
executor: &impl CommandExecutor,
smoke: bool,
) -> DoctorCheck {
let check_id = format!("runtime.ssh-docker.{id}");
let title = format!("Remote Docker for target {id}");
let destination = &ssh.destination;
if let SshConnectivity::Failed {
detail,
remediation,
} = ssh_connectivity(ssh, executor)
{
return DoctorCheck::fixable(check_id, title, detail, remediation);
}
let preflight = match verify_ssh_docker(ssh, executor) {
Ok(preflight) => preflight,
Err(error) => {
let detail = format!("{error:#}");
return DoctorCheck::fixable(
check_id,
title,
detail,
format!(
"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."
),
);
}
};
if smoke {
let target = RuntimeTargetTemplate::SshDocker {
ssh: ssh.clone(),
container: RuntimeContainerTemplate {
build_cache: None,
image: image.to_owned(),
pull_policy: Default::default(),
extra_run_args: vec![],
workspace_storage: Default::default(),
},
};
return match run_setup_smoke_test(&target, &doctor_smoke_id(), executor) {
Ok(()) => DoctorCheck::ready(
check_id,
title,
format!(
"Remote Docker {} is available via {destination}; disposable run/exec/remove and remote OverlayFS attachment smoke test passed for image {image}.",
preflight.version
),
),
Err(error) => DoctorCheck::fixable(
check_id,
title,
format!(
"Disposable run/exec/remove smoke test failed for image {image} on {destination}: {error:#}"
),
format!(
"Fix the configured image or Docker runtime on {destination}, then run `mj doctor --json --smoke` again."
),
),
};
}
let image_command = ssh_command(
ssh,
[
"docker".to_owned(),
"image".to_owned(),
"inspect".to_owned(),
image.to_owned(),
]
.to_vec(),
)
.purpose("check remote Docker image presence");
match executor.execute(&image_command) {
Ok(output) if output.status == 0 => DoctorCheck::ready(
check_id,
title,
format!(
"Remote Docker {} is available via {destination}; image {image} is present. Run `mj doctor --json --smoke` to verify remote OverlayFS attachments.",
preflight.version
),
),
Ok(output) => DoctorCheck::fixable(
check_id,
title,
format!(
"Image {image} is not present in remote Docker storage on {destination}: {}",
String::from_utf8_lossy(&output.stderr).trim()
),
format!(
"Pull it on {destination} with `ssh {destination} docker pull {image}`, or run `mj doctor --json --smoke`."
),
),
Err(error) => DoctorCheck::fixable(
check_id,
title,
format!("Could not inspect remote Docker image {image} on {destination}: {error}"),
format!(
"Verify `ssh {destination} docker info` succeeds, then pull {image} on that host."
),
),
}
}
fn doctor_smoke_id() -> String {
format!(
"doctor-{}-{:x}",
std::process::id(),
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
)
}
fn podman_remediation(error: &anyhow::Error) -> String {
let fix = podman_remediation_match(error).unwrap_or(
"Install Podman with `sudo apt update && sudo apt install -y podman uidmap` (Debian/Ubuntu) or `sudo dnf install -y podman shadow-utils` (Fedora).",
);
format!("{fix} See {PODMAN_DOCUMENTATION_URL}.")
}
fn podman_failure_detail(error: &anyhow::Error) -> String {
podman_probe_observation(error).map_or_else(|| format!("{error:#}"), str::to_owned)
}
fn podman_remediation_match(error: &anyhow::Error) -> Option<&'static str> {
failed_podman_postcondition(error).map(PodmanPostcondition::remediation)
}
const AWS_CLI_INSTALL_URL: &str =
"https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html";
fn aws_checks(config: ConfigStatus<'_>, executor: &impl CommandExecutor) -> Vec<DoctorCheck> {
let Ok(config) = config else {
return Vec::new();
};
config
.targets
.iter()
.filter_map(|(id, target)| match target {
TargetTemplate::AwsEc2 {
aws_profile,
region,
launch_template,
..
} => Some(aws_target_check(
id,
aws_profile.as_deref(),
region,
launch_template,
executor,
)),
_ => None,
})
.collect()
}
fn aws_global_args<'a>(profile: Option<&'a str>, region: &'a str) -> Vec<String> {
vec![
"--profile".to_owned(),
profile.unwrap_or("default").to_owned(),
"--region".to_owned(),
region.to_owned(),
]
}
fn aws_target_check(
id: &str,
profile: Option<&str>,
region: &str,
launch_template: &str,
executor: &impl CommandExecutor,
) -> DoctorCheck {
let check_id = format!("runtime.aws-ec2.{id}");
let title = format!("AWS EC2 target {id}");
let profile_label = profile.unwrap_or("default");
let version = CommandSpec::new("aws", ["--version"]).purpose("check AWS CLI installation");
match executor.execute(&version) {
Err(error) => {
return DoctorCheck::fixable(
check_id,
title,
format!("The `aws` command is not available: {error}"),
format!("Install the AWS CLI and put `aws` on PATH: {AWS_CLI_INSTALL_URL}"),
);
}
Ok(output) if output.status != 0 => {
return DoctorCheck::fixable(
check_id,
title,
format!(
"`aws --version` failed: {}",
String::from_utf8_lossy(&output.stderr).trim()
),
format!("Reinstall the AWS CLI: {AWS_CLI_INSTALL_URL}"),
);
}
Ok(_) => {}
}
let mut identity_args = aws_global_args(profile, region);
identity_args.extend(["sts".to_owned(), "get-caller-identity".to_owned()]);
identity_args.extend(["--output".to_owned(), "json".to_owned()]);
let identity =
CommandSpec::new("aws", identity_args).purpose("check AWS credentials for a doctor target");
match executor.execute(&identity) {
Err(error) => {
return DoctorCheck::fixable(
check_id,
title,
format!("Could not run `aws sts get-caller-identity`: {error}"),
format!(
"Configure credentials with `aws configure --profile {profile_label}`, or sign in with `aws sso login --profile {profile_label}`."
),
);
}
Ok(output) if output.status != 0 => {
return DoctorCheck::fixable(
check_id,
title,
format!(
"AWS credentials for profile {profile_label} are not usable: {}",
String::from_utf8_lossy(&output.stderr).trim()
),
format!(
"Configure credentials with `aws configure --profile {profile_label}`, or sign in with `aws sso login --profile {profile_label}`."
),
);
}
Ok(_) => {}
}
let by_id = launch_template.starts_with("lt-");
let mut template_args = aws_global_args(profile, region);
template_args.extend(["ec2".to_owned(), "describe-launch-templates".to_owned()]);
template_args.extend([
if by_id {
"--launch-template-ids".to_owned()
} else {
"--launch-template-names".to_owned()
},
launch_template.to_owned(),
]);
template_args.extend(["--output".to_owned(), "json".to_owned()]);
let template =
CommandSpec::new("aws", template_args).purpose("check the configured AWS launch template");
let template_remediation = format!(
"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."
);
match executor.execute(&template) {
Err(error) => DoctorCheck::fixable(
check_id,
title,
format!("Could not query launch template {launch_template}: {error}"),
template_remediation,
),
Ok(output) if output.status != 0 => DoctorCheck::fixable(
check_id,
title,
format!(
"Launch template {launch_template} was not found in {region}: {}",
String::from_utf8_lossy(&output.stderr).trim()
),
template_remediation,
),
Ok(_) => DoctorCheck::ready(
check_id,
title,
format!(
"The AWS CLI is installed, profile {profile_label} has valid credentials, and launch template {launch_template} exists in {region}."
),
),
}
}
fn daemon_build_check() -> DoctorCheck {
const ID: &str = "daemon.build";
const TITLE: &str = "Daemon build";
let Ok(metadata) = mj_client::daemon::read_metadata_any() else {
return DoctorCheck::ready(
ID,
TITLE,
"No Mjolnir daemon is running; the next command starts one from this build.",
);
};
let pid = metadata.pid;
match mj_client::executable::process_runs_this_executable(pid) {
Ok(Some(true)) => DoctorCheck::ready(
ID,
TITLE,
format!(
"Daemon {pid} runs this build (version {}).",
metadata.build_version
),
),
Ok(Some(false)) => DoctorCheck::warning(
ID,
TITLE,
format!(
"{}. Two builds can report the same version, so the files are what tell them apart. Code rebuilt since that daemon started is not running.",
mj_client::executable::describe_running_daemon_and_client_builds(
pid,
&metadata.build_version,
),
),
"Run `mj daemon restart` from this build. It now fails rather than reporting success if another client's build wins.",
),
Ok(None) => DoctorCheck::ready(
ID,
TITLE,
format!(
"Daemon {pid} is recorded but not running; the next command starts one from this build."
),
),
Err(error) => DoctorCheck::warning(
ID,
TITLE,
format!("Could not tell which build daemon {pid} runs: {error:#}"),
"Run `mj daemon restart` from this build if rebuilt code is not taking effect.",
),
}
}
fn worker_freshness_checks(config: ConfigStatus<'_>) -> Vec<DoctorCheck> {
let mut checks = Vec::new();
let daemon = mj_client::daemon::read_metadata_any()
.ok()
.filter(|metadata| mj_client::daemon::process_is_alive(metadata.pid));
let pinned = pinned_worker_digests();
let mut sources: Vec<(String, Result<WorkerBinaryAvailability>)> = vec![(
"this host".to_owned(),
crate::controller::native_worker_binary_prerequisite(),
)];
for arch in container_worker_architectures(config) {
sources.push((
format!("{arch} Linux targets"),
worker_binary_prerequisite_for_arch(&arch),
));
}
for (label, availability) in sources {
let id = format!("worker.freshness.{}", label.replace(' ', "-"));
let title = format!("Worker binary for {label}");
let path = match availability {
Ok(WorkerBinaryAvailability::Local { path, .. }) => path,
Ok(WorkerBinaryAvailability::Remote { .. }) => continue,
Err(error) => {
checks.push(DoctorCheck::unsupported(
id,
title,
format!("No worker binary resolves for {label}: {error:#}"),
));
continue;
}
};
let digest = mj_core::worker_launch::worker_executable_digest(&path)
.unwrap_or_else(|error| format!("unreadable ({error:#})"));
let pinned_note = if pinned.is_empty() {
"the daemon has pinned no worker".to_owned()
} else if pinned.contains(&digest) {
"this content is in the daemon's pinned worker cache".to_owned()
} else {
format!(
"the pinned worker cache holds {} instead",
pinned.join(", ")
)
};
let detail = format!("{} has digest {digest}; {pinned_note}.", path.display());
let Some(metadata) = daemon.as_ref() else {
checks.push(DoctorCheck::ready(
id,
title,
format!("{detail} No daemon is running, so the next session uses this file."),
));
continue;
};
match worker_changed_since_daemon_start(&path, &metadata.started_at) {
Ok(true) => checks.push(DoctorCheck::warning(
id,
title,
format!("{detail} It was rebuilt after daemon {} started, which froze the copy it serves.", metadata.pid),
"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.",
)),
Ok(false) => checks.push(DoctorCheck::ready(id, title, detail)),
Err(error) => checks.push(DoctorCheck::warning(
id,
title,
format!("{detail} Could not compare it with the daemon's start time: {error:#}"),
"Run `mj daemon restart` if rebuilt worker code is not taking effect.",
)),
}
}
checks
}
fn container_worker_architectures(config: ConfigStatus<'_>) -> Vec<String> {
let Ok(config) = config else {
return Vec::new();
};
let mut architectures = Vec::new();
for target in config.targets.values() {
let container = match target {
TargetTemplate::LocalPodman { container }
| TargetTemplate::LocalDocker { container }
| TargetTemplate::AppleContainer { container }
| TargetTemplate::SshPodman { container, .. }
| TargetTemplate::SshDocker { container, .. } => container,
_ => continue,
};
let arch = container
.platform
.as_deref()
.and_then(|platform| platform.rsplit('/').next())
.map_or_else(
|| std::env::consts::ARCH.to_owned(),
normalized_worker_architecture,
);
if !architectures.contains(&arch) {
architectures.push(arch);
}
}
architectures
}
fn normalized_worker_architecture(platform_arch: &str) -> String {
crate::targets::normalize_architecture(platform_arch)
.unwrap_or(platform_arch)
.to_owned()
}
fn pinned_worker_digests() -> Vec<String> {
let root = mj_core::config::data_dir().join("workers").join("pinned");
let Ok(entries) = std::fs::read_dir(root) else {
return Vec::new();
};
let mut digests: Vec<String> = entries
.flatten()
.filter(|entry| entry.path().is_dir())
.filter_map(|entry| entry.file_name().into_string().ok())
.collect();
digests.sort();
digests
}
fn worker_changed_since_daemon_start(path: &Path, started_at: &str) -> Result<bool> {
let started: SystemTime = chrono::DateTime::parse_from_rfc3339(started_at)
.map_err(|error| anyhow::anyhow!("parse daemon start time {started_at:?}: {error}"))?
.into();
let modified = std::fs::metadata(path)?.modified()?;
Ok(modified > started)
}
fn offered_targets<'a>(
config: &Config,
engine_checks: impl IntoIterator<Item = &'a DoctorCheck>,
) -> Config {
let ready = engine_checks
.into_iter()
.filter(|check| check.status == CheckStatus::Ready)
.map(|check| check.id.as_str())
.collect::<std::collections::BTreeSet<_>>();
let engine_ready = |target_id: &str| match target_id {
"podman" => ready.contains("runtime.podman"),
"docker" => ready.contains("runtime.docker"),
"apple-container" => ready.contains("runtime.apple-container"),
_ => true,
};
let mut offered = config.clone().with_local_targets();
offered
.targets
.retain(|id, _| config.configures_target(id) || engine_ready(id));
offered
}
fn worker_binary_checks(config: ConfigStatus<'_>) -> Vec<DoctorCheck> {
let config = match config {
Ok(config) => config,
Err(ConfigGap::NewerVersion(version)) => {
return vec![newer_config_skip(
"worker.containers",
"Container worker binary",
version,
)];
}
Err(gap @ (ConfigGap::Missing | ConfigGap::Unreadable)) => {
return vec![DoctorCheck::fixable(
"worker.containers",
"Container worker binary",
format!(
"Worker availability cannot be checked until {}.",
gap.awaited()
),
gap.remediation(),
)];
}
};
let containers = config
.targets
.iter()
.filter_map(|(id, target)| match target {
TargetTemplate::LocalPodman { container }
| TargetTemplate::LocalDocker { container }
| TargetTemplate::AppleContainer { container } => Some((id, container, None)),
TargetTemplate::SshPodman { container, .. } => {
Some((id, container, Some("ssh-podman")))
}
TargetTemplate::SshDocker { container, .. } => {
Some((id, container, Some("ssh-docker")))
}
_ => None,
})
.collect::<Vec<_>>();
if containers.is_empty() {
return vec![DoctorCheck::unsupported(
"worker.containers",
"Container worker binary",
"No container target is configured.",
)];
}
containers
.into_iter()
.map(|(id, container, remote_kind)| {
if let Some(remote_kind) = remote_kind
&& container.platform.is_none()
{
return DoctorCheck::unsupported(
format!("worker.{id}"),
format!("Container worker binary for target {id}"),
format!(
"Set `platform` on this {remote_kind} target to check its worker binary; the remote architecture is unknown until provisioning."
),
);
}
worker_binary_check(id, container)
})
.collect()
}
fn worker_binary_check(id: &str, container: &ContainerTemplate) -> DoctorCheck {
let title = format!("Container worker binary for target {id}");
let arch = match container_architecture(container.platform.as_deref()) {
Ok(arch) => arch,
Err(reason) => {
return DoctorCheck::unsupported(format!("worker.{id}"), title, reason);
}
};
let triple = format!("{arch}-unknown-linux-musl");
match worker_binary_prerequisite_for_arch(arch) {
Ok(WorkerBinaryAvailability::Local { path, source }) => DoctorCheck::ready(
format!("worker.{id}"),
title,
format!(
"{triple} worker is available from {source}: {}",
path.display()
),
),
Ok(WorkerBinaryAvailability::Remote { url, .. }) => DoctorCheck::ready(
format!("worker.{id}"),
title,
format!("{triple} worker will be verified and downloaded from {url} when needed."),
),
Err(error) => DoctorCheck::fixable(
format!("worker.{id}"),
title,
format!("No usable {triple} worker source: {error:#}"),
format!(
"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."
),
),
}
}
fn container_architecture(platform: Option<&str>) -> std::result::Result<&'static str, String> {
let candidate = platform.unwrap_or(std::env::consts::ARCH);
let candidate = candidate
.split('/')
.rev()
.find(|part| matches!(*part, "x86_64" | "amd64" | "aarch64" | "arm64"))
.unwrap_or(candidate);
crate::targets::normalize_architecture(candidate).map_err(|_| format!(
"Container architecture {candidate:?} is unsupported; Mjolnir supports x86_64 and aarch64 Linux workers."
))
}
fn apple_container_image(config: ConfigStatus<'_>) -> String {
config
.ok()
.and_then(|config| {
config.targets.values().find_map(|target| match target {
TargetTemplate::AppleContainer { container } => Some(container.image.clone()),
_ => None,
})
})
.unwrap_or_else(|| DEFAULT_CONTAINER_IMAGE.into())
}
pub fn apple_container_runtime_check(
platform: &ApplePlatform,
executor: &impl CommandExecutor,
) -> DoctorCheck {
match platform {
ApplePlatform::Linux => {
return DoctorCheck::unsupported(
"runtime.apple-container",
"Apple container runtime",
"macOS only",
);
}
ApplePlatform::Other(current) => {
return DoctorCheck::unsupported(
"runtime.apple-container",
"Apple container runtime",
format!("macOS only (current platform: {current})"),
);
}
ApplePlatform::Macos {
architecture,
major_version,
} if architecture != "aarch64" && architecture != "arm64" => {
return DoctorCheck::unsupported(
"runtime.apple-container",
"Apple container runtime",
"Apple container requires Apple silicon; Intel Macs are unsupported.",
);
}
ApplePlatform::Macos { major_version, .. } if *major_version < 26 => {
return DoctorCheck::unsupported(
"runtime.apple-container",
"Apple container runtime",
format!("Apple container requires macOS 26 or newer (found {major_version})."),
);
}
ApplePlatform::Macos { .. } => {}
}
apple_container_daemon_check(executor)
}
pub fn apple_container_check(
platform: &ApplePlatform,
executor: &impl CommandExecutor,
smoke: bool,
image: String,
) -> DoctorCheck {
let daemon = apple_container_runtime_check(platform, executor);
if daemon.status != CheckStatus::Ready {
return daemon;
}
if !smoke {
return DoctorCheck::fixable(
"runtime.apple-container",
"Apple container runtime",
"The daemon is running, but the required disposable smoke test was not requested.",
"Run `mj doctor --json --smoke`.",
);
}
let target = RuntimeTargetTemplate::AppleContainer(RuntimeContainerTemplate {
build_cache: None,
image,
pull_policy: Default::default(),
extra_run_args: vec![],
workspace_storage: Default::default(),
});
match run_setup_smoke_test(&target, &doctor_smoke_id(), executor) {
Ok(()) => DoctorCheck::ready(
"runtime.apple-container",
"Apple container runtime",
"Installed, daemon running, and disposable run/exec/remove smoke test passed.",
),
Err(error) => DoctorCheck::fixable(
"runtime.apple-container",
"Apple container runtime",
format!("Disposable run/exec/remove smoke test failed: {error:#}"),
"Fix the configured image or container runtime, then run `mj doctor --json --smoke` again.",
),
}
}
fn apple_container_daemon_check(executor: &impl CommandExecutor) -> DoctorCheck {
let installed =
CommandSpec::new("container", ["--version"]).purpose("check Apple container installation");
match executor.execute(&installed) {
Err(error) => {
return DoctorCheck::fixable(
"runtime.apple-container",
"Apple container runtime",
format!(
"The `container` command is not available: {}",
error.root_cause()
),
format!("Install the official signed package: {APPLE_CONTAINER_INSTALL_URL}"),
);
}
Ok(output) if output.status != 0 => {
return DoctorCheck::fixable(
"runtime.apple-container",
"Apple container runtime",
format!(
"The installed `container --version` command failed: {}",
String::from_utf8_lossy(&output.stderr).trim()
),
format!("Reinstall the official signed package: {APPLE_CONTAINER_INSTALL_URL}"),
);
}
Ok(_) => {}
}
let status =
CommandSpec::new("container", ["system", "status"]).purpose("check Apple container daemon");
match executor.execute(&status) {
Ok(output) if output.status == 0 => DoctorCheck::ready(
"runtime.apple-container",
"Apple container runtime",
"Installed, and the Apple container daemon is running.",
),
Ok(output) => DoctorCheck::fixable(
"runtime.apple-container",
"Apple container runtime",
format!(
"The Apple container daemon is stopped: {}",
String::from_utf8_lossy(&output.stderr).trim()
),
"Run `container system start`.",
),
Err(error) => DoctorCheck::fixable(
"runtime.apple-container",
"Apple container runtime",
format!("Could not query the Apple container daemon: {error}"),
"Run `container system start`.",
),
}
}
pub fn current_apple_platform(executor: &impl CommandExecutor) -> ApplePlatform {
if cfg!(target_os = "linux") {
return ApplePlatform::Linux;
}
if !cfg!(target_os = "macos") {
return ApplePlatform::Other(std::env::consts::OS.into());
}
let major_version = executor
.execute(&CommandSpec::new("sw_vers", ["-productVersion"]).purpose("detect macOS version"))
.ok()
.filter(|output| output.status == 0)
.and_then(|output| {
String::from_utf8(output.stdout)
.ok()
.and_then(|value| value.trim().split('.').next()?.parse().ok())
})
.unwrap_or(0);
ApplePlatform::Macos {
architecture: std::env::consts::ARCH.into(),
major_version,
}
}
#[cfg(test)]
mod tests;
#[derive(Debug, Default, PartialEq, Eq)]
pub(crate) struct ReviewResidue {
pub refs: Vec<String>,
pub scratch_indexes: Vec<PathBuf>,
}
impl ReviewResidue {
fn is_empty(&self) -> bool {
self.refs.is_empty() && self.scratch_indexes.is_empty()
}
}
pub(crate) fn review_residue(repository: &Path) -> ReviewResidue {
let mut residue = ReviewResidue::default();
let git_dir = repository.join(".git");
if !git_dir.exists() {
return residue;
}
for reference in ["review-baseline", "review-capture"] {
if git_dir.join("refs/hel").join(reference).is_file() {
residue.refs.push(format!("refs/hel/{reference}"));
}
}
if let Ok(packed) = std::fs::read_to_string(git_dir.join("packed-refs")) {
for line in packed.lines() {
if let Some((_, reference)) = line.split_once(' ')
&& reference.starts_with("refs/hel/")
&& !residue.refs.iter().any(|known| known == reference)
{
residue.refs.push(reference.to_owned());
}
}
}
if let Ok(entries) = std::fs::read_dir(&git_dir) {
for entry in entries.filter_map(Result::ok) {
if entry
.file_name()
.to_str()
.is_some_and(|name| name.starts_with("hel-review-index-"))
{
residue.scratch_indexes.push(entry.path());
}
}
}
residue.refs.sort();
residue.scratch_indexes.sort();
residue
}
pub(crate) fn review_residue_repositories(
configured: impl IntoIterator<Item = PathBuf>,
sessions: &[&mj_core::state::SessionRecord],
) -> Vec<PathBuf> {
use mj_core::state::ManagedCheckoutKind;
let managed_roots = sessions
.iter()
.filter_map(|session| session.managed_worktree.as_ref())
.map(|worktree| worktree.worktree_root.as_path())
.collect::<Vec<_>>();
let mut in_use = Vec::new();
for session in sessions.iter().filter(|session| session.state.is_active()) {
if let Some(directory) = &session.project_directory {
in_use.push(directory.as_path());
}
if let Some(worktree) = &session.managed_worktree
&& worktree.kind == ManagedCheckoutKind::Worktree
{
in_use.push(worktree.source_repository.as_path());
}
}
let mut repositories = configured
.into_iter()
.chain(
sessions
.iter()
.filter_map(|session| session.project_directory.clone()),
)
.filter(|repository| {
!is_inside_managed_checkout(repository)
&& !managed_roots
.iter()
.any(|root| repository.starts_with(root))
&& !in_use.iter().any(|directory| repository == directory)
})
.collect::<Vec<_>>();
repositories.sort();
repositories.dedup();
repositories
}
fn is_inside_managed_checkout(path: &Path) -> bool {
let components = path
.components()
.map(|component| component.as_os_str())
.collect::<Vec<_>>();
components
.windows(3)
.any(|window| window[0] == ".mj" && (window[1] == "clones" || window[1] == "worktrees"))
}
fn review_residue_checks(config: ConfigStatus<'_>) -> Vec<DoctorCheck> {
let Ok(config) = config else {
return Vec::new();
};
let configured = config
.bundles
.values()
.flat_map(|bundle| bundle.repositories.iter())
.filter_map(|repository| repository.local.clone());
let state = crate::database::load_state().ok();
let sessions = state
.as_ref()
.map(|state| state.sessions.values().collect::<Vec<_>>())
.unwrap_or_default();
let repositories = review_residue_repositories(configured, &sessions);
if repositories.is_empty() {
return Vec::new();
}
let found = repositories
.into_iter()
.map(|repository| {
let residue = review_residue(&repository);
(repository, residue)
})
.filter(|(_, residue)| !residue.is_empty())
.collect::<Vec<_>>();
if found.is_empty() {
return vec![DoctorCheck::ready(
"review.residue",
"Review leftovers in your repositories",
"No Mjolnir refs or scratch index files were found in the configured repositories.",
)];
}
let detail = found
.iter()
.map(|(repository, residue)| {
let mut parts = Vec::new();
if !residue.refs.is_empty() {
parts.push(residue.refs.join(", "));
}
if !residue.scratch_indexes.is_empty() {
parts.push(mj_core::text::counted(
residue.scratch_indexes.len(),
"leftover scratch index file",
"leftover scratch index files",
));
}
format!("{}: {}", repository.display(), parts.join("; "))
})
.collect::<Vec<_>>()
.join(". ");
let commands = found
.iter()
.flat_map(|(repository, residue)| {
let repository = repository.display().to_string();
let mut commands = residue
.refs
.iter()
.map(|reference| format!("git -C {repository} update-ref -d {reference}"))
.collect::<Vec<_>>();
commands.extend(
residue
.scratch_indexes
.iter()
.map(|index| format!("rm -f {}", index.display())),
);
commands.push(format!("git -C {repository} gc --prune=now"));
commands
})
.collect::<Vec<_>>()
.join("\n");
vec![DoctorCheck::fixable(
"review.residue",
"Review leftovers in your repositories",
format!("Mjolnir left these in repositories it does not own: {detail}."),
format!("Remove them yourself when you are ready:\n{commands}"),
)]
}