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