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(
272                "resolve",
273                format!(
274                    "{provider_name} / {model}{}",
275                    default_provider_note(config, &provider_name, model_override, registry)
276                ),
277            ),
278            Some(Resolved {
279                provider_name,
280                model,
281                provider,
282            }),
283        ),
284        None => (
285            Check::fail(
286                "resolve",
287                format!(
288                    "resolved to '{provider_name}', which is not configured (tried: {}). \
289                     Configure it with `lev setup`, or add it to config.toml.",
290                    providers_tried(&empty, model_override, &defaults)
291                ),
292            ),
293            None,
294        ),
295    }
296}
297
298/// The note appended when the resolved provider is not the one the user named
299/// as their default.
300///
301/// `default_provider` without a `default_model` is a half-configuration that
302/// silently does nothing: the resolver needs a model to send and has none, so
303/// it falls through to the hard-coded last resort. Someone who set
304/// `default_provider = "openrouter"`, pasted their key, and watched every run
305/// go to Anthropic (or, with no Anthropic key either, to a localhost Ollama
306/// that was not running) had no way to see that from here - the check said
307/// `resolve OK` and printed a provider they never asked for.
308///
309/// Not a failure: the resolution is legitimate and the run will work. It is
310/// only worth saying because it is not what the config appears to ask for.
311/// Silent while `--model` is in play, which is the caller overriding on purpose.
312fn default_provider_note(
313    config: &Config,
314    resolved: &str,
315    model_override: Option<&str>,
316    registry: &ProviderRegistry,
317) -> String {
318    if model_override.is_some() || resolved == config.default_provider {
319        return String::new();
320    }
321    // The missing model is the only reason a registered default provider loses
322    // from here: this check resolves an empty `ModelConfig`, so one with a
323    // model set has no competition to lose to. An *unregistered* default
324    // provider is a different complaint, and one the `config` line already
325    // makes by listing what is registered.
326    if config.default_model.is_some() || !registry.has(&config.default_provider) {
327        return String::new();
328    }
329    let named = &config.default_provider;
330    format!(
331        "  (note: default_provider is '{named}' but no default_model is set, \
332         so it is never chosen - add `default_model` to config.toml)"
333    )
334}
335
336// ─── Check 3: inference ───────────────────────────────────────────────────────
337
338/// One real call to the resolved provider. No context window, no blueprint, no
339/// world: the point is to isolate "can this credential reach this model" from
340/// everything the framework layers on top of it.
341async fn inference_check(provider: &dyn Provider, model: &str) -> Check {
342    let caps = provider.capabilities(model);
343    let request = InferenceRequest {
344        system: Vec::new(),
345        messages: vec![Message {
346            role: "user".to_string(),
347            content: PROBE_PROMPT.into(),
348            cache_breakpoint: false,
349        }],
350        model: model.to_string(),
351        max_tokens: PROBE_MAX_TOKENS.min(caps.max_output_tokens),
352        // Deterministic where it is allowed; providers that reject the
353        // parameter outright get the same value and ignore it.
354        temperature: 0.0,
355        tools: Vec::new(),
356        extra: serde_json::Value::Null,
357        request_timeout_secs: Some(60),
358    };
359
360    let started = Instant::now();
361    match provider.infer(request).await {
362        Ok(response) => {
363            let usage = response.tokens_used;
364            let echo = match response.content.contains(PROBE_EXPECTED) {
365                true => format!("replied {PROBE_EXPECTED}"),
366                // Not a failure. The call succeeded, which is the thing being
367                // checked; what the model chose to say is a note.
368                false => format!("no {PROBE_EXPECTED} in the reply"),
369            };
370            Check::ok(
371                "inference",
372                format!(
373                    "{} in / {} out / {} total, {echo}",
374                    usage.prompt_tokens, usage.completion_tokens, usage.total_tokens
375                ),
376            )
377            .timed(started.elapsed())
378        }
379        // Verbatim. Every provider formats a non-2xx as `HTTP <status>: <body>`,
380        // and that body is the whole diagnosis for the cases this command is
381        // for - a 402 naming the exhausted credit, a 404 naming the model.
382        // Summarising it here would throw away the answer.
383        Err(e) => Check::fail("inference", e.to_string()).timed(started.elapsed()),
384    }
385}
386
387// ─── Check 4: daemon ──────────────────────────────────────────────────────────
388
389/// The throwaway blueprint the daemon check spawns: one autonomous stage, one
390/// iteration, no tools, no transitions.
391///
392/// A single stage with no transitions is a valid "pure linear" blueprint and
393/// goes `Complete` after one text-only turn. Advertising no tools also keeps it
394/// out of the empty-output report - an agent with no way to modify a file is
395/// never judged for not having modified one.
396///
397/// `provider` and `model` are serialised as JSON strings, whose escaping is a
398/// subset of TOML's basic-string escaping, so a name containing a quote or a
399/// backslash cannot break out of the literal.
400fn canary_manifest(provider: &str, model: &str) -> String {
401    let provider = serde_json::to_string(provider).expect("a str always serializes to JSON");
402    let model = serde_json::to_string(model).expect("a str always serializes to JSON");
403    format!(
404        r#"[agent]
405name = "doctor"
406version = "0.0.1"
407description = "One-turn provider probe spawned by `lev doctor`, deleted when it finishes."
408entry_stage = "ping"
409
410[stages.ping]
411mode = "autonomous"
412model = {{ models = [{{ provider = {provider}, model = {model} }}] }}
413description = "Answer once, in text."
414available_tools = []
415max_iterations = 1
416system_prompt = "Reply with exactly: {PROBE_EXPECTED}. Call no tools."
417
418[context.regions]
419task = {{ kind = "pinned", max_tokens = 1000, seed = "task" }}
420conversation = {{ kind = "sliding_window", max_items = 4, max_tokens = 2000 }}
421"#
422    )
423}
424
425/// Delete everything the canary run left behind.
426///
427/// Ordered the way the dashboard's delete is: record the run terminal on disk
428/// *before* removing it, so that if an in-flight persist job loses the race and
429/// recreates the directory, it reappears as a finished run rather than a live
430/// one nobody will ever collect.
431fn cleanup_run(run_id: &str) {
432    let _ = crate::runstate::force_cancel(run_id);
433    let _ = std::fs::remove_dir_all(crate::runstate::run_dir(run_id));
434    let _ = leviath_core::paths::data_dir().map(|d| {
435        let _ = std::fs::remove_dir_all(d.join("state").join(run_id));
436    });
437}
438
439/// The daemon check's own outcome, before it is turned into a [`Check`], so the
440/// spawn/wait/cleanup steps each have one thing to say.
441enum DaemonOutcome {
442    /// The run finished successfully; carries the summary line.
443    Complete(String),
444    /// The run reached the daemon but did not end well.
445    Failed(String),
446}
447
448/// Write the canary manifest under `root`, returning its path.
449///
450/// The manifest's parent directory names the agent, and so prefixes the run id:
451/// the canary is identifiable as `doctor-...` for as long as it exists.
452fn stage_canary(
453    root: &std::path::Path,
454    provider: &str,
455    model: &str,
456) -> std::io::Result<std::path::PathBuf> {
457    let agent_dir = root.join("doctor");
458    std::fs::create_dir_all(&agent_dir)?;
459    let manifest = agent_dir.join("agent.leviath");
460    std::fs::write(&manifest, canary_manifest(provider, model))?;
461    Ok(manifest)
462}
463
464/// Spawn the canary, wait for it, and report. The run is deleted on every path
465/// out of here, including the failing ones.
466async fn daemon_check(
467    client: &ControlClient,
468    provider_name: &str,
469    model: &str,
470    timeout: Duration,
471    poll: Duration,
472    root: &std::path::Path,
473) -> Check {
474    let started = Instant::now();
475    let manifest = match stage_canary(root, provider_name, model) {
476        Ok(manifest) => manifest,
477        Err(e) => return Check::fail("daemon", format!("could not stage a probe agent: {e}")),
478    };
479
480    match spawn_and_wait(client, &manifest, root, timeout, poll).await {
481        DaemonOutcome::Complete(detail) => Check::ok("daemon", detail).timed(started.elapsed()),
482        DaemonOutcome::Failed(detail) => Check::fail("daemon", detail).timed(started.elapsed()),
483    }
484}
485
486/// Ask the daemon to run the staged canary and wait for a terminal status.
487async fn spawn_and_wait(
488    client: &ControlClient,
489    manifest: &std::path::Path,
490    workdir: &std::path::Path,
491    timeout: Duration,
492    poll: Duration,
493) -> DaemonOutcome {
494    // `--yolo`: a probe that stops to ask a person for a tool approval has
495    // stopped being a probe. It advertises no tools, so this waives nothing
496    // that could actually run.
497    let args = crate::daemon::client::resolve_spawn_args(
498        &manifest.to_string_lossy(),
499        Some(PROBE_PROMPT),
500        // The probe brings its own task, so the editor fallback is unreachable.
501        // Answering "not a terminal" anyway makes that structural rather than
502        // incidental: a doctor that opened an editor would be a bad joke.
503        &|| false,
504        None,
505        &workdir.to_string_lossy(),
506        true,
507        Vec::new(),
508        None,
509        std::collections::HashMap::new(),
510        false,
511    );
512    let args = match args {
513        Ok(args) => args,
514        Err(e) => return DaemonOutcome::Failed(format!("could not build the spawn request: {e}")),
515    };
516    let run_id = args.run_id.clone();
517
518    let spawned = match client.spawn(args).await {
519        Ok(ControlResponse::Spawned { run_id }) => Ok(run_id),
520        Ok(ControlResponse::Error { message }) => {
521            Err(format!("the daemon refused the spawn: {message}"))
522        }
523        Ok(other) => Err(format!("unexpected daemon response to spawn: {other:?}")),
524        Err(e) => Err(format!(
525            "the daemon is not reachable ({e}); start it with `lev daemon`"
526        )),
527    };
528    let run_id = match spawned {
529        Ok(id) => id,
530        Err(detail) => {
531            // The daemon may have staked out the run directory before failing.
532            cleanup_run(&run_id);
533            return DaemonOutcome::Failed(detail);
534        }
535    };
536
537    let outcome = wait_for_run(client, &run_id, timeout, poll).await;
538    cleanup_run(&run_id);
539    outcome
540}
541
542/// Poll the run until it reaches a terminal status, or the deadline passes.
543async fn wait_for_run(
544    client: &ControlClient,
545    run_id: &str,
546    timeout: Duration,
547    poll: Duration,
548) -> DaemonOutcome {
549    let started = Instant::now();
550    loop {
551        let still = match client.status(run_id).await {
552            Ok(ControlResponse::Status {
553                status: Some(status),
554            }) => {
555                if leviath_runtime::pipeline::is_terminal_status(&status) {
556                    return finished(run_id, &status);
557                }
558                status.label()
559            }
560            // The daemon reaps a finished run, so a run that was live a moment
561            // ago and is now unknown has ended - and its meta.json says how.
562            Ok(ControlResponse::Status { status: None }) => return reaped(run_id),
563            Ok(other) => {
564                return DaemonOutcome::Failed(format!("unexpected daemon response: {other:?}"));
565            }
566            Err(e) => {
567                return DaemonOutcome::Failed(format!("lost contact with the daemon: {e}"));
568            }
569        };
570        if started.elapsed() >= timeout {
571            return DaemonOutcome::Failed(format!(
572                "the run was still '{still}' after {}s - the daemon took the spawn but is not \
573                 getting anywhere. Check `lev ps` for the lane footer.",
574                timeout.as_secs()
575            ));
576        }
577        tokio::time::sleep(poll).await;
578    }
579}
580
581/// Turn a terminal [`AgentStatus`](leviath_runtime::components::AgentStatus)
582/// into the daemon check's verdict, reading the iteration count off disk.
583fn finished(run_id: &str, status: &leviath_runtime::components::AgentStatus) -> DaemonOutcome {
584    use leviath_runtime::components::AgentStatus;
585    let iterations = crate::runstate::read_meta(run_id)
586        .map(|m| m.iteration)
587        .unwrap_or(0);
588    match status {
589        AgentStatus::Complete => DaemonOutcome::Complete(format!(
590            "run {run_id} complete after {iterations} iteration(s)"
591        )),
592        AgentStatus::Error { message } => {
593            DaemonOutcome::Failed(format!("run {run_id} ended in error: {message}"))
594        }
595        other => DaemonOutcome::Failed(format!("run {run_id} ended {}", other.label())),
596    }
597}
598
599/// The run is no longer known to the daemon: fall back to what it wrote.
600fn reaped(run_id: &str) -> DaemonOutcome {
601    match crate::runstate::read_meta(run_id) {
602        Ok(meta) if crate::runstate::is_terminal_status(&meta.status) => match meta.error {
603            Some(err) => DaemonOutcome::Failed(format!("run {run_id} ended in error: {err}")),
604            None => DaemonOutcome::Complete(format!(
605                "run {run_id} {} after {} iteration(s)",
606                meta.status, meta.iteration
607            )),
608        },
609        // Never seen, or seen and still unfinished: either way the daemon took
610        // the spawn and then lost the run, which is a handoff failure.
611        _ => DaemonOutcome::Failed(format!(
612            "run {run_id} vanished before it finished; the daemon accepted the spawn but \
613             never completed it"
614        )),
615    }
616}
617
618// ─── Orchestration ────────────────────────────────────────────────────────────
619
620/// What the fourth check has to work with.
621///
622/// `Unavailable` exists so a daemon that will not start is still *reported* as
623/// a daemon failure rather than aborting the command: the caller auto-starts
624/// one before the checks begin, and if that abort propagated, `lev doctor`
625/// would refuse to tell you whether your credentials were fine - which is most
626/// of what it is for.
627pub enum DaemonTarget<'a> {
628    /// Do not run the fourth check at all (`--no-daemon`).
629    Skip,
630    /// Run it against this daemon.
631    Client(&'a ControlClient),
632    /// There is no daemon to hand off to, and this is why.
633    Unavailable(String),
634}
635
636/// Run the checks in order, stopping at the first failure.
637///
638/// `+ Sync` on the builder so the returned future is `Send`: `lev serve`'s
639/// `GET /api/doctor` awaits these same checks inside an axum handler, which
640/// requires it. Every caller passes a plain `fn` item, which always is.
641pub async fn run_checks(
642    args: &DoctorArgs,
643    build_registry: &(dyn Fn(&Config) -> ProviderRegistry + Sync),
644    daemon: DaemonTarget<'_>,
645) -> Vec<Check> {
646    let mut checks = Vec::new();
647
648    // A config that will not parse is itself a finding, and the most common
649    // one there is - reporting it as `config FAIL` beats the bare load error.
650    let config = match Config::load() {
651        Ok(config) => config,
652        Err(e) => {
653            checks.push(Check::fail("config", e.to_string()));
654            return checks;
655        }
656    };
657    for warning in config.validate_keys() {
658        eprintln!("Warning: {warning}");
659    }
660
661    let registry = build_registry(&config);
662    checks.push(config_check(&config, &registry));
663
664    let (check, resolved) = resolve_check(&config, args.model.as_deref(), &registry);
665    checks.push(check);
666    let Some(resolved) = resolved else {
667        return checks;
668    };
669
670    let check = inference_check(resolved.provider.as_ref(), &resolved.model).await;
671    let inference_failed = check.status == CheckStatus::Fail;
672    checks.push(check);
673    if inference_failed {
674        return checks;
675    }
676
677    match daemon {
678        DaemonTarget::Skip => {}
679        DaemonTarget::Unavailable(reason) => checks.push(Check::fail("daemon", reason)),
680        DaemonTarget::Client(client) => {
681            // `.expect`: a temp directory that cannot be created means the
682            // machine has no writable scratch space at all, which every other
683            // part of a run would hit first. Nothing here could report it more
684            // usefully.
685            let stage = tempfile::tempdir().expect("the system temp directory is writable");
686            checks.push(
687                daemon_check(
688                    client,
689                    &resolved.provider_name,
690                    &resolved.model,
691                    DAEMON_TIMEOUT,
692                    DAEMON_POLL,
693                    stage.path(),
694                )
695                .await,
696            );
697        }
698    }
699    checks
700}
701
702/// Print the checks and report the first failure as the command's error, so the
703/// process exits non-zero and `lev doctor` works as a CI gate.
704async fn execute_with_registry(
705    args: DoctorArgs,
706    build_registry: &(dyn Fn(&Config) -> ProviderRegistry + Sync),
707    daemon: DaemonTarget<'_>,
708) -> anyhow::Result<()> {
709    let checks = run_checks(&args, build_registry, daemon).await;
710    let failed = checks.iter().find(|c| c.status == CheckStatus::Fail);
711
712    if args.json {
713        let report = serde_json::json!({
714            "checks": checks,
715            "passed": failed.is_none(),
716        });
717        println!(
718            "{}",
719            serde_json::to_string_pretty(&report).expect("a Check report always serializes")
720        );
721    } else {
722        print!("{}", format_report(&checks));
723    }
724
725    match failed {
726        Some(check) => bail!("doctor failed at: {}", check.name),
727        None => Ok(()),
728    }
729}
730
731/// `lev doctor`. The binary decides what the fourth check gets to talk to; see
732/// [`DaemonTarget`].
733pub async fn execute(args: DoctorArgs, daemon: DaemonTarget<'_>) -> anyhow::Result<()> {
734    execute_with_registry(args, &build_provider_registry_from_config, daemon).await
735}
736
737#[cfg(test)]
738mod tests;