Skip to main content

leviath_cli/commands/
doctor.rs

1//! `lev doctor` - prove the provider wiring works, one layer at a time.
2//!
3//! Four checks run in order and each one is reported, so a failure names the
4//! layer that broke instead of leaving the caller to guess:
5//!
6//! 1. `config` - the config file parses and a provider registry can be built.
7//! 2. `resolve` - the user's defaults pick a provider that is actually
8//!    registered. This is the check that catches a stage resolving to
9//!    `anthropic/claude-sonnet-4-6` (the hard-coded last resort in
10//!    [`ModelConfig::provider`](leviath_core::blueprint::ModelConfig::provider))
11//!    on a machine with no Anthropic key - the root cause of a fleet of runs
12//!    that spawned, sat at iteration 0, and never took a turn.
13//! 3. `inference` - one real call to that provider, straight through
14//!    [`Provider::infer`]. No world, no run, nothing on disk.
15//! 4. `daemon` - a throwaway one-stage agent spawned over the control socket
16//!    and waited on, then deleted. This is the only check that exercises the
17//!    handoff, which is why it is worth the second billed call: checks 1-3
18//!    passing while this one fails is exactly the "credentials are fine, the
19//!    daemon is wedged" verdict that used to take a hand-built canary agent to
20//!    establish.
21//!
22//! The daemon-touching I/O lives behind [`crate::dispatch::RiskyExecutors`], so
23//! the cores here are driven by unit tests against injected registries and a
24//! scripted control socket. The registry builder is a `&dyn Fn` rather than a
25//! generic for the coverage reason documented on
26//! [`crate::commands::models`]'s equivalent seam.
27
28use std::sync::Arc;
29use std::time::{Duration, Instant};
30
31use anyhow::bail;
32use leviath_core::blueprint::ModelConfig;
33use leviath_providers::{InferenceRequest, Message, Provider};
34use leviath_runtime::ProviderRegistry;
35use leviath_runtime::control_socket::{ControlClient, ControlResponse};
36use leviath_runtime::pipeline::{providers_tried, resolve_stage_model};
37
38use crate::commands::run::session::build_provider_registry_from_config;
39use crate::config::Config;
40use crate::daemon::spawn::model_defaults;
41
42/// `lev doctor --help`. What each check proves, and what a failure at it means.
43pub const DOCTOR_LONG_ABOUT: &str = "\
44Check that provider wiring works, end to end.
45
46Four checks run in order, and the first failure stops the rest. The check that
47fails is the diagnosis:
48
49  config     the config file parses and a provider registry can be built.
50             Fails on a malformed config.toml.
51  resolve    your default provider/model picks a provider that is actually
52             registered. Fails when a key is missing or misspelled - and
53             catches the case where a blueprint with no model falls back to
54             anthropic on a machine that has no Anthropic key.
55  inference  one real call to that provider. Fails on a bad key, an unknown
56             model id, or a billing problem; the provider's own error is
57             printed verbatim, status line and response body included.
58  daemon     a one-stage agent spawned over the control socket, waited on,
59             then deleted. Fails when the handoff is broken even though the
60             credentials are fine.
61
62So config/resolve/inference OK with daemon FAIL means the daemon is the
63problem, not your keys - the distinction this command exists to make.
64
65`--model` takes the same forms `lev run --model` does: `provider/model` picks
66both (the way to reach a Rhai script provider, which cannot be listed), and a
67bare model id pairs with your default_provider. Use it to try a model string
68before wiring it into a blueprint.
69
70Two inferences are billed per run, capped at 64 output tokens each.
71`--no-daemon` stops after the third check and bills one.
72
73Exits non-zero on failure, so it works as a CI gate. --json prints the same
74checks as {\"checks\": [...], \"passed\": bool}.";
75
76/// Arguments for `lev doctor`.
77#[derive(clap::Args, Debug, Clone, Default)]
78pub struct DoctorArgs {
79    /// Model to test (`provider/model`, or a bare model id paired with the
80    /// configured default provider). Defaults to your configured default.
81    #[arg(short, long)]
82    pub model: Option<String>,
83
84    /// Stop after the direct inference check: never contact (or start) the
85    /// daemon, and create no run.
86    #[arg(long)]
87    pub no_daemon: bool,
88
89    /// Print the checks as JSON instead of a table.
90    #[arg(long)]
91    pub json: bool,
92}
93
94/// How long the daemon check waits for its throwaway run to reach a terminal
95/// status before calling the handoff wedged.
96const DAEMON_TIMEOUT: Duration = Duration::from_secs(90);
97
98/// How often the daemon check re-asks for the run's status.
99const DAEMON_POLL: Duration = Duration::from_millis(250);
100
101/// Output cap for both probe inferences. The prompt is four tokens and the
102/// wanted answer is one word, so this only bounds a model that ignores both.
103const PROBE_MAX_TOKENS: usize = 64;
104
105/// What the probe asks for, in both the direct call and the daemon run.
106const PROBE_PROMPT: &str = "Reply with exactly: PONG";
107
108/// The word a cooperating model comes back with. Reported, never required:
109/// a model that answers something else has still proved the wiring, and
110/// failing on it would make this command flaky across providers.
111const PROBE_EXPECTED: &str = "PONG";
112
113// ─── Report types ─────────────────────────────────────────────────────────────
114
115/// Whether a check proved what it set out to prove.
116#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
117#[serde(rename_all = "lowercase")]
118pub enum CheckStatus {
119    /// The layer works.
120    Ok,
121    /// The layer is broken; nothing after it ran.
122    Fail,
123}
124
125impl CheckStatus {
126    /// The cell as it appears in the table.
127    fn label(&self) -> &'static str {
128        match self {
129            Self::Ok => "OK",
130            Self::Fail => "FAIL",
131        }
132    }
133}
134
135/// One layer's verdict, with whatever detail makes it actionable.
136#[derive(Debug, Clone, serde::Serialize)]
137pub struct Check {
138    /// The layer's short name (`config`, `resolve`, `inference`, `daemon`).
139    pub name: &'static str,
140    /// Whether it passed.
141    pub status: CheckStatus,
142    /// What was found: the resolved names, the token usage, or the raw error.
143    pub detail: String,
144    /// Wall-clock cost, for the checks that make a network call.
145    #[serde(skip_serializing_if = "Option::is_none")]
146    pub elapsed_ms: Option<u64>,
147}
148
149impl Check {
150    /// A passing check with no timing (the two offline checks).
151    fn ok(name: &'static str, detail: impl Into<String>) -> Self {
152        Self {
153            name,
154            status: CheckStatus::Ok,
155            detail: detail.into(),
156            elapsed_ms: None,
157        }
158    }
159
160    /// A failing check with no timing.
161    fn fail(name: &'static str, detail: impl Into<String>) -> Self {
162        Self {
163            name,
164            status: CheckStatus::Fail,
165            detail: detail.into(),
166            elapsed_ms: None,
167        }
168    }
169
170    /// Attach how long the call took.
171    fn timed(mut self, elapsed: Duration) -> Self {
172        self.elapsed_ms = Some(elapsed.as_millis() as u64);
173        self
174    }
175}
176
177// ─── Rendering ────────────────────────────────────────────────────────────────
178
179/// Render the checks as the table `lev doctor` prints.
180///
181/// Pure: everything that varies between runs (timings, resolved names) is
182/// already baked into the `Check`s, so the same input always renders the same
183/// output and the tests can assert on it exactly.
184pub fn format_report(checks: &[Check]) -> String {
185    let name_width = checks.iter().map(|c| c.name.len()).max().unwrap_or(0);
186    let status_width = checks
187        .iter()
188        .map(|c| c.status.label().len())
189        .max()
190        .unwrap_or(0);
191
192    let mut out = String::from("\n");
193    for check in checks {
194        out.push_str(&format!(
195            "  {:<name_width$}  {:<status_width$}  {}",
196            check.name,
197            check.status.label(),
198            check.detail,
199        ));
200        if let Some(ms) = check.elapsed_ms {
201            out.push_str(&format!("  ({:.1}s)", ms as f64 / 1000.0));
202        }
203        out.push('\n');
204    }
205    if checks.iter().all(|c| c.status == CheckStatus::Ok) {
206        out.push_str("\ndoctor passed\n");
207    }
208    out
209}
210
211// ─── Check 1: config ──────────────────────────────────────────────────────────
212
213/// Report what the config named and what the registry ended up holding.
214///
215/// Only native providers can be listed - a Rhai script provider is resolved by
216/// name on demand and never enumerated - so the line says so rather than
217/// implying the user's `.rhai` providers are missing.
218fn config_check(config: &Config, registry: &ProviderRegistry) -> Check {
219    let mut names = registry.provider_names();
220    names.sort_unstable();
221    let registered = match names.is_empty() {
222        true => "none".to_string(),
223        false => names.join(", "),
224    };
225    Check::ok(
226        "config",
227        format!(
228            "default_provider={}; registered: {} (script providers resolve by name)",
229            config.default_provider, registered
230        ),
231    )
232}
233
234// ─── Check 2: resolve ─────────────────────────────────────────────────────────
235
236/// What check 2 settled on, when it settled on something usable. The handle is
237/// carried rather than looked up again so the later checks cannot disagree with
238/// the one that reported.
239struct Resolved {
240    provider_name: String,
241    model: String,
242    provider: Arc<dyn Provider>,
243}
244
245/// Run the real stage-model fallback chain against an **empty** [`ModelConfig`],
246/// so what comes back is what the user's config alone would pick for a stage
247/// that states no preference of its own.
248///
249/// The guard afterwards is the one [`leviath_runtime::pipeline::resolve_stages`]
250/// applies at spawn: the last resort in the chain is unchecked and hands back
251/// `anthropic`/`claude-sonnet-4-6` whether or not anything answers to that name.
252/// Resolving through [`ProviderRegistry::get`] rather than `has` makes the same
253/// decision (native first, then the script layer) while keeping the handle, so
254/// the provider cannot go missing between deciding to use it and using it.
255fn resolve_check(
256    config: &Config,
257    model_override: Option<&str>,
258    registry: &ProviderRegistry,
259) -> (Check, Option<Resolved>) {
260    let empty = ModelConfig {
261        models: Vec::new(),
262        allow_user_default: true,
263        parameters: std::collections::HashMap::new(),
264        request_timeout_secs: None,
265    };
266    let defaults = model_defaults(config);
267    let (provider_name, model) = resolve_stage_model(&empty, model_override, &defaults, registry);
268
269    match registry.get(&provider_name) {
270        Some(provider) => (
271            Check::ok("resolve", format!("{provider_name} / {model}")),
272            Some(Resolved {
273                provider_name,
274                model,
275                provider,
276            }),
277        ),
278        None => (
279            Check::fail(
280                "resolve",
281                format!(
282                    "resolved to '{provider_name}', which is not configured (tried: {}). \
283                     Configure it with `lev setup`, or add it to config.toml.",
284                    providers_tried(&empty, model_override, &defaults)
285                ),
286            ),
287            None,
288        ),
289    }
290}
291
292// ─── Check 3: inference ───────────────────────────────────────────────────────
293
294/// One real call to the resolved provider. No context window, no blueprint, no
295/// world: the point is to isolate "can this credential reach this model" from
296/// everything the framework layers on top of it.
297async fn inference_check(provider: &dyn Provider, model: &str) -> Check {
298    let caps = provider.capabilities(model);
299    let request = InferenceRequest {
300        system: Vec::new(),
301        messages: vec![Message {
302            role: "user".to_string(),
303            content: PROBE_PROMPT.into(),
304            cache_breakpoint: false,
305        }],
306        model: model.to_string(),
307        max_tokens: PROBE_MAX_TOKENS.min(caps.max_output_tokens),
308        // Deterministic where it is allowed; providers that reject the
309        // parameter outright get the same value and ignore it.
310        temperature: 0.0,
311        tools: Vec::new(),
312        extra: serde_json::Value::Null,
313        request_timeout_secs: Some(60),
314    };
315
316    let started = Instant::now();
317    match provider.infer(request).await {
318        Ok(response) => {
319            let usage = response.tokens_used;
320            let echo = match response.content.contains(PROBE_EXPECTED) {
321                true => format!("replied {PROBE_EXPECTED}"),
322                // Not a failure. The call succeeded, which is the thing being
323                // checked; what the model chose to say is a note.
324                false => format!("no {PROBE_EXPECTED} in the reply"),
325            };
326            Check::ok(
327                "inference",
328                format!(
329                    "{} in / {} out / {} total, {echo}",
330                    usage.prompt_tokens, usage.completion_tokens, usage.total_tokens
331                ),
332            )
333            .timed(started.elapsed())
334        }
335        // Verbatim. Every provider formats a non-2xx as `HTTP <status>: <body>`,
336        // and that body is the whole diagnosis for the cases this command is
337        // for - a 402 naming the exhausted credit, a 404 naming the model.
338        // Summarising it here would throw away the answer.
339        Err(e) => Check::fail("inference", e.to_string()).timed(started.elapsed()),
340    }
341}
342
343// ─── Check 4: daemon ──────────────────────────────────────────────────────────
344
345/// The throwaway blueprint the daemon check spawns: one autonomous stage, one
346/// iteration, no tools, no transitions.
347///
348/// A single stage with no transitions is a valid "pure linear" blueprint and
349/// goes `Complete` after one text-only turn. Advertising no tools also keeps it
350/// out of the empty-output report - an agent with no way to modify a file is
351/// never judged for not having modified one.
352///
353/// `provider` and `model` are serialised as JSON strings, whose escaping is a
354/// subset of TOML's basic-string escaping, so a name containing a quote or a
355/// backslash cannot break out of the literal.
356fn canary_manifest(provider: &str, model: &str) -> String {
357    let provider = serde_json::to_string(provider).expect("a str always serializes to JSON");
358    let model = serde_json::to_string(model).expect("a str always serializes to JSON");
359    format!(
360        r#"[agent]
361name = "doctor"
362version = "0.0.1"
363description = "One-turn provider probe spawned by `lev doctor`, deleted when it finishes."
364entry_stage = "ping"
365
366[stages.ping]
367mode = "autonomous"
368model = {{ models = [{{ provider = {provider}, model = {model} }}] }}
369description = "Answer once, in text."
370available_tools = []
371max_iterations = 1
372system_prompt = "Reply with exactly: {PROBE_EXPECTED}. Call no tools."
373
374[context.regions]
375task = {{ kind = "pinned", max_tokens = 1000, seed = "task" }}
376conversation = {{ kind = "sliding_window", max_items = 4, max_tokens = 2000 }}
377"#
378    )
379}
380
381/// Delete everything the canary run left behind.
382///
383/// Ordered the way the dashboard's delete is: record the run terminal on disk
384/// *before* removing it, so that if an in-flight persist job loses the race and
385/// recreates the directory, it reappears as a finished run rather than a live
386/// one nobody will ever collect.
387fn cleanup_run(run_id: &str) {
388    let _ = crate::runstate::force_cancel(run_id);
389    let _ = std::fs::remove_dir_all(crate::runstate::run_dir(run_id));
390    let _ = leviath_core::paths::data_dir().map(|d| {
391        let _ = std::fs::remove_dir_all(d.join("state").join(run_id));
392    });
393}
394
395/// The daemon check's own outcome, before it is turned into a [`Check`], so the
396/// spawn/wait/cleanup steps each have one thing to say.
397enum DaemonOutcome {
398    /// The run finished successfully; carries the summary line.
399    Complete(String),
400    /// The run reached the daemon but did not end well.
401    Failed(String),
402}
403
404/// Write the canary manifest under `root`, returning its path.
405///
406/// The manifest's parent directory names the agent, and so prefixes the run id:
407/// the canary is identifiable as `doctor-...` for as long as it exists.
408fn stage_canary(
409    root: &std::path::Path,
410    provider: &str,
411    model: &str,
412) -> std::io::Result<std::path::PathBuf> {
413    let agent_dir = root.join("doctor");
414    std::fs::create_dir_all(&agent_dir)?;
415    let manifest = agent_dir.join("agent.leviath");
416    std::fs::write(&manifest, canary_manifest(provider, model))?;
417    Ok(manifest)
418}
419
420/// Spawn the canary, wait for it, and report. The run is deleted on every path
421/// out of here, including the failing ones.
422async fn daemon_check(
423    client: &ControlClient,
424    provider_name: &str,
425    model: &str,
426    timeout: Duration,
427    poll: Duration,
428    root: &std::path::Path,
429) -> Check {
430    let started = Instant::now();
431    let manifest = match stage_canary(root, provider_name, model) {
432        Ok(manifest) => manifest,
433        Err(e) => return Check::fail("daemon", format!("could not stage a probe agent: {e}")),
434    };
435
436    match spawn_and_wait(client, &manifest, root, timeout, poll).await {
437        DaemonOutcome::Complete(detail) => Check::ok("daemon", detail).timed(started.elapsed()),
438        DaemonOutcome::Failed(detail) => Check::fail("daemon", detail).timed(started.elapsed()),
439    }
440}
441
442/// Ask the daemon to run the staged canary and wait for a terminal status.
443async fn spawn_and_wait(
444    client: &ControlClient,
445    manifest: &std::path::Path,
446    workdir: &std::path::Path,
447    timeout: Duration,
448    poll: Duration,
449) -> DaemonOutcome {
450    // `--yolo`: a probe that stops to ask a person for a tool approval has
451    // stopped being a probe. It advertises no tools, so this waives nothing
452    // that could actually run.
453    let args = crate::daemon::client::resolve_spawn_args(
454        &manifest.to_string_lossy(),
455        Some(PROBE_PROMPT),
456        // The probe brings its own task, so the editor fallback is unreachable.
457        // Answering "not a terminal" anyway makes that structural rather than
458        // incidental: a doctor that opened an editor would be a bad joke.
459        &|| false,
460        None,
461        &workdir.to_string_lossy(),
462        true,
463        Vec::new(),
464        None,
465        std::collections::HashMap::new(),
466        false,
467    );
468    let args = match args {
469        Ok(args) => args,
470        Err(e) => return DaemonOutcome::Failed(format!("could not build the spawn request: {e}")),
471    };
472    let run_id = args.run_id.clone();
473
474    let spawned = match client.spawn(args).await {
475        Ok(ControlResponse::Spawned { run_id }) => Ok(run_id),
476        Ok(ControlResponse::Error { message }) => {
477            Err(format!("the daemon refused the spawn: {message}"))
478        }
479        Ok(other) => Err(format!("unexpected daemon response to spawn: {other:?}")),
480        Err(e) => Err(format!(
481            "the daemon is not reachable ({e}); start it with `lev daemon`"
482        )),
483    };
484    let run_id = match spawned {
485        Ok(id) => id,
486        Err(detail) => {
487            // The daemon may have staked out the run directory before failing.
488            cleanup_run(&run_id);
489            return DaemonOutcome::Failed(detail);
490        }
491    };
492
493    let outcome = wait_for_run(client, &run_id, timeout, poll).await;
494    cleanup_run(&run_id);
495    outcome
496}
497
498/// Poll the run until it reaches a terminal status, or the deadline passes.
499async fn wait_for_run(
500    client: &ControlClient,
501    run_id: &str,
502    timeout: Duration,
503    poll: Duration,
504) -> DaemonOutcome {
505    let started = Instant::now();
506    loop {
507        let still = match client.status(run_id).await {
508            Ok(ControlResponse::Status {
509                status: Some(status),
510            }) => {
511                if leviath_runtime::pipeline::is_terminal_status(&status) {
512                    return finished(run_id, &status);
513                }
514                status.label()
515            }
516            // The daemon reaps a finished run, so a run that was live a moment
517            // ago and is now unknown has ended - and its meta.json says how.
518            Ok(ControlResponse::Status { status: None }) => return reaped(run_id),
519            Ok(other) => {
520                return DaemonOutcome::Failed(format!("unexpected daemon response: {other:?}"));
521            }
522            Err(e) => {
523                return DaemonOutcome::Failed(format!("lost contact with the daemon: {e}"));
524            }
525        };
526        if started.elapsed() >= timeout {
527            return DaemonOutcome::Failed(format!(
528                "the run was still '{still}' after {}s - the daemon took the spawn but is not \
529                 getting anywhere. Check `lev ps` for the lane footer.",
530                timeout.as_secs()
531            ));
532        }
533        tokio::time::sleep(poll).await;
534    }
535}
536
537/// Turn a terminal [`AgentStatus`](leviath_runtime::components::AgentStatus)
538/// into the daemon check's verdict, reading the iteration count off disk.
539fn finished(run_id: &str, status: &leviath_runtime::components::AgentStatus) -> DaemonOutcome {
540    use leviath_runtime::components::AgentStatus;
541    let iterations = crate::runstate::read_meta(run_id)
542        .map(|m| m.iteration)
543        .unwrap_or(0);
544    match status {
545        AgentStatus::Complete => DaemonOutcome::Complete(format!(
546            "run {run_id} complete after {iterations} iteration(s)"
547        )),
548        AgentStatus::Error { message } => {
549            DaemonOutcome::Failed(format!("run {run_id} ended in error: {message}"))
550        }
551        other => DaemonOutcome::Failed(format!("run {run_id} ended {}", other.label())),
552    }
553}
554
555/// The run is no longer known to the daemon: fall back to what it wrote.
556fn reaped(run_id: &str) -> DaemonOutcome {
557    match crate::runstate::read_meta(run_id) {
558        Ok(meta) if crate::runstate::is_terminal_status(&meta.status) => match meta.error {
559            Some(err) => DaemonOutcome::Failed(format!("run {run_id} ended in error: {err}")),
560            None => DaemonOutcome::Complete(format!(
561                "run {run_id} {} after {} iteration(s)",
562                meta.status, meta.iteration
563            )),
564        },
565        // Never seen, or seen and still unfinished: either way the daemon took
566        // the spawn and then lost the run, which is a handoff failure.
567        _ => DaemonOutcome::Failed(format!(
568            "run {run_id} vanished before it finished; the daemon accepted the spawn but \
569             never completed it"
570        )),
571    }
572}
573
574// ─── Orchestration ────────────────────────────────────────────────────────────
575
576/// What the fourth check has to work with.
577///
578/// `Unavailable` exists so a daemon that will not start is still *reported* as
579/// a daemon failure rather than aborting the command: the caller auto-starts
580/// one before the checks begin, and if that abort propagated, `lev doctor`
581/// would refuse to tell you whether your credentials were fine - which is most
582/// of what it is for.
583pub enum DaemonTarget<'a> {
584    /// Do not run the fourth check at all (`--no-daemon`).
585    Skip,
586    /// Run it against this daemon.
587    Client(&'a ControlClient),
588    /// There is no daemon to hand off to, and this is why.
589    Unavailable(String),
590}
591
592/// Run the checks in order, stopping at the first failure.
593pub async fn run_checks(
594    args: &DoctorArgs,
595    build_registry: &dyn Fn(&Config) -> ProviderRegistry,
596    daemon: DaemonTarget<'_>,
597) -> Vec<Check> {
598    let mut checks = Vec::new();
599
600    // A config that will not parse is itself a finding, and the most common
601    // one there is - reporting it as `config FAIL` beats the bare load error.
602    let config = match Config::load() {
603        Ok(config) => config,
604        Err(e) => {
605            checks.push(Check::fail("config", e.to_string()));
606            return checks;
607        }
608    };
609    for warning in config.validate_keys() {
610        eprintln!("Warning: {warning}");
611    }
612
613    let registry = build_registry(&config);
614    checks.push(config_check(&config, &registry));
615
616    let (check, resolved) = resolve_check(&config, args.model.as_deref(), &registry);
617    checks.push(check);
618    let Some(resolved) = resolved else {
619        return checks;
620    };
621
622    let check = inference_check(resolved.provider.as_ref(), &resolved.model).await;
623    let inference_failed = check.status == CheckStatus::Fail;
624    checks.push(check);
625    if inference_failed {
626        return checks;
627    }
628
629    match daemon {
630        DaemonTarget::Skip => {}
631        DaemonTarget::Unavailable(reason) => checks.push(Check::fail("daemon", reason)),
632        DaemonTarget::Client(client) => {
633            // `.expect`: a temp directory that cannot be created means the
634            // machine has no writable scratch space at all, which every other
635            // part of a run would hit first. Nothing here could report it more
636            // usefully.
637            let stage = tempfile::tempdir().expect("the system temp directory is writable");
638            checks.push(
639                daemon_check(
640                    client,
641                    &resolved.provider_name,
642                    &resolved.model,
643                    DAEMON_TIMEOUT,
644                    DAEMON_POLL,
645                    stage.path(),
646                )
647                .await,
648            );
649        }
650    }
651    checks
652}
653
654/// Print the checks and report the first failure as the command's error, so the
655/// process exits non-zero and `lev doctor` works as a CI gate.
656async fn execute_with_registry(
657    args: DoctorArgs,
658    build_registry: &dyn Fn(&Config) -> ProviderRegistry,
659    daemon: DaemonTarget<'_>,
660) -> anyhow::Result<()> {
661    let checks = run_checks(&args, build_registry, daemon).await;
662    let failed = checks.iter().find(|c| c.status == CheckStatus::Fail);
663
664    if args.json {
665        let report = serde_json::json!({
666            "checks": checks,
667            "passed": failed.is_none(),
668        });
669        println!(
670            "{}",
671            serde_json::to_string_pretty(&report).expect("a Check report always serializes")
672        );
673    } else {
674        print!("{}", format_report(&checks));
675    }
676
677    match failed {
678        Some(check) => bail!("doctor failed at: {}", check.name),
679        None => Ok(()),
680    }
681}
682
683/// `lev doctor`. The binary decides what the fourth check gets to talk to; see
684/// [`DaemonTarget`].
685pub async fn execute(args: DoctorArgs, daemon: DaemonTarget<'_>) -> anyhow::Result<()> {
686    execute_with_registry(args, &build_provider_registry_from_config, daemon).await
687}
688
689#[cfg(test)]
690mod tests;