Skip to main content

artifactize_core/diagnostics/
doctor.rs

1#[cfg(test)]
2mod tests;
3
4use std::{
5    fs,
6    path::{Path, PathBuf},
7};
8
9use serde::Serialize;
10
11use crate::{
12    auth,
13    config::{Backend, Provider, read_workspace_config},
14    llm, release, store, workspace,
15};
16
17#[derive(Debug, Serialize)]
18#[serde(rename_all = "camelCase")]
19pub struct DoctorReport {
20    pub ok: bool,
21    #[serde(with = "crate::platform::path_serde")]
22    pub state_dir: PathBuf,
23    pub checks: Vec<Check>,
24}
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
27#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
28pub enum CheckStatus {
29    Pass,
30    Warn,
31    Fail,
32}
33impl CheckStatus {
34    fn ready(self) -> bool {
35        match self {
36            Self::Pass | Self::Warn => true,
37            Self::Fail => false,
38        }
39    }
40}
41impl std::fmt::Display for CheckStatus {
42    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
43        formatter.write_str(match self {
44            Self::Pass => "PASS",
45            Self::Warn => "WARN",
46            Self::Fail => "FAIL",
47        })
48    }
49}
50
51#[derive(Debug, Serialize)]
52pub struct Check {
53    pub name: &'static str,
54    pub status: CheckStatus,
55    pub message: String,
56    pub details: Details,
57}
58
59/// A readiness check's finite data domains; JSON is produced only by serialization.
60#[derive(Debug, Serialize)]
61#[serde(untagged)]
62pub enum Details {
63    None,
64    Config {
65        artifacts: usize,
66        evals: usize,
67    },
68    State {
69        writable: bool,
70    },
71    Schema {
72        schema: Option<u32>,
73    },
74    UnsupportedSchema {
75        schema: u32,
76        supported: u32,
77    },
78    Limits {
79        backends: std::collections::BTreeMap<Backend, u32>,
80    },
81    ApiKey {
82        present: bool,
83        #[serde(rename = "testEndpoint", skip_serializing_if = "Option::is_none")]
84        test_endpoint: Option<llm::TestEndpoint>,
85    },
86    Providers {
87        custom: std::collections::BTreeMap<Backend, auth::providers::CustomView>,
88    },
89    NoRemote {
90        configured: bool,
91    },
92    Remote {
93        configured: bool,
94        url: url::Url,
95        share: auth::remote::Share,
96        #[serde(rename = "tokenSource")]
97        token_source: auth::remote::TokenSource,
98    },
99    Sessions {
100        enabled: bool,
101        sessions: usize,
102        bytes: u64,
103        #[serde(rename = "maxBytes")]
104        max_bytes: u64,
105        #[serde(rename = "targetBytes")]
106        target_bytes: u64,
107        #[serde(with = "crate::platform::path_serde")]
108        directory: PathBuf,
109        #[serde(rename = "stateId")]
110        state_id: Option<crate::types::StateId>,
111    },
112    Release {
113        channel: release::Channel,
114        running: semver::Version,
115        #[serde(skip_serializing_if = "Option::is_none")]
116        latest: Option<semver::Version>,
117        #[serde(skip_serializing_if = "Option::is_none")]
118        update: Option<release::Update>,
119    },
120    Codex {
121        #[serde(flatten)]
122        status: auth::codex::Status,
123        #[serde(rename = "testEndpoint", skip_serializing_if = "Option::is_none")]
124        test_endpoint: Option<llm::TestEndpoint>,
125        #[serde(rename = "testAuthEndpoint", skip_serializing_if = "Option::is_none")]
126        test_auth_endpoint: Option<llm::TestEndpoint>,
127    },
128    /// Claude Code's executable on `PATH`, or `null` without one.
129    ClaudeAgent {
130        #[serde(with = "crate::platform::path_serde::option")]
131        program: Option<PathBuf>,
132    },
133    /// The installed desktop app, or `null` without one.
134    Desktop {
135        #[serde(with = "crate::platform::path_serde::option")]
136        path: Option<PathBuf>,
137    },
138}
139
140/// Where a person without the desktop app installs it.
141const DESKTOP_INSTALL_PAGE: &str = "https://artifactize.dev/#start";
142
143impl DoctorReport {
144    fn add(&mut self, name: &'static str, status: CheckStatus, message: &str, details: Details) {
145        self.ok &= status.ready();
146        self.checks.push(Check {
147            name,
148            status,
149            message: message.into(),
150            details,
151        });
152    }
153}
154
155/// Local readiness, and whether a newer release is out. Missing optional backends and an
156/// older release are warnings, not failed reviews.
157pub async fn doctor(state: Option<&Path>, repo: Option<&Path>) -> Result<DoctorReport, String> {
158    let state = store::state_dir(state)?;
159    let mut report = DoctorReport {
160        ok: true,
161        state_dir: state.clone(),
162        checks: Vec::new(),
163    };
164    let config = repo.map(read_workspace_config).transpose();
165    if repo.is_some() {
166        match &config {
167            Ok(Some(config)) => report.add(
168                "config",
169                CheckStatus::Pass,
170                "Folder configuration is valid.",
171                Details::Config {
172                    artifacts: config.artifacts.len(),
173                    evals: config.evals.len(),
174                },
175            ),
176            Err(error) => report.add(
177                "config",
178                CheckStatus::Fail,
179                &error.to_string(),
180                Details::None,
181            ),
182            _ => unreachable!(),
183        }
184    }
185    let writable = (|| {
186        let state = workspace::canonical_target(&state)?;
187        if let Some(repo) = repo {
188            let repo = workspace::canonical_target(repo)?;
189            let repo = if repo.is_file() {
190                repo.parent().unwrap()
191            } else {
192                &repo
193            };
194            workspace::outside_workspace(repo, &state)?;
195        } else {
196            workspace::outside_artifact_workspaces(&state)?;
197        }
198        probe_writable(&state)
199    })();
200    match writable {
201        Ok(()) => report.add(
202            "state",
203            CheckStatus::Pass,
204            "State directory is writable or can be created.",
205            Details::State { writable: true },
206        ),
207        Err(error) => report.add(
208            "state",
209            CheckStatus::Fail,
210            &error.to_string(),
211            Details::State { writable: false },
212        ),
213    }
214    // Existing-state preflight upgrades supported schemas without creating an absent database.
215    let current = store::STATE_SCHEMA_VERSION;
216    match store::state_schema(&state) {
217        Ok(None) => report.add(
218            "schema",
219            CheckStatus::Pass,
220            "No state database yet.",
221            Details::Schema { schema: None },
222        ),
223        Ok(Some(0)) => report.add(
224            "schema",
225            CheckStatus::Pass,
226            "The state database is not initialized yet.",
227            Details::Schema { schema: Some(0) },
228        ),
229        Ok(Some(found)) if found == current => report.add(
230            "schema",
231            CheckStatus::Pass,
232            &format!("State database schema {current}."),
233            Details::Schema {
234                schema: Some(current),
235            },
236        ),
237        Ok(Some(found)) => report.add(
238            "schema",
239            CheckStatus::Fail,
240            &store::schema_error(found),
241            Details::UnsupportedSchema {
242                schema: found,
243                supported: current,
244            },
245        ),
246        Err(error) => report.add("schema", CheckStatus::Fail, &error, Details::None),
247    }
248    match crate::limits::Limits::read(&state) {
249        Ok(limits) if limits.backends().is_empty() => report.add(
250            "limits",
251            CheckStatus::Pass,
252            "No backend capacity limits (limits.json).",
253            Details::Limits {
254                backends: Default::default(),
255            },
256        ),
257        Ok(limits) => report.add(
258            "limits",
259            CheckStatus::Pass,
260            &format!(
261                "Backend capacity on this machine: {}.",
262                limits
263                    .backends()
264                    .iter()
265                    .map(|(backend, slots)| format!("{backend} {slots}"))
266                    .collect::<Vec<_>>()
267                    .join(", ")
268            ),
269            Details::Limits {
270                backends: limits.backends().clone(),
271            },
272        ),
273        Err(error) => report.add("limits", CheckStatus::Fail, &error, Details::None),
274    }
275    let (status, message, details) = sessions(&state);
276    report.add("sessions", status, &message, details);
277    let providers = auth::providers::list(Some(&state), repo);
278    let keys = match &providers {
279        Ok(providers) => providers.keys.clone(),
280        Err(_) => Vec::new(),
281    };
282    for status in keys {
283        let (name, variable, source) = (status.provider.as_str(), status.variable, status.source);
284        let present = source != auth::providers::KeySource::Absent;
285        let key = match source {
286            auth::providers::KeySource::Saved => format!(
287                "{variable} is absent; a saved {name} key is present (not validated with the provider)"
288            ),
289            _ => format!(
290                "{variable} is {} (not validated with the provider)",
291                if present { "present" } else { "absent" }
292            ),
293        };
294        let backend = status.provider;
295        match llm::test_endpoint(backend) {
296            Ok(None) => report.add(
297                name,
298                if present {
299                    CheckStatus::Pass
300                } else {
301                    CheckStatus::Warn
302                },
303                &format!("{key}."),
304                Details::ApiKey { present, test_endpoint: None },
305            ),
306            // A test endpoint is never a production setup, so it always warns.
307            Ok(Some(endpoint)) => report.add(
308                name,
309                CheckStatus::Warn,
310                &format!(
311                    "{key}; {} sends this backend's requests to the local test endpoint {endpoint}.",
312                    llm::base_url_variable(backend).unwrap_or_default()
313                ),
314                Details::ApiKey { present, test_endpoint: Some(endpoint) },
315            ),
316            Err(error) => report.add(name, CheckStatus::Fail, &error, Details::ApiKey { present, test_endpoint: None }),
317        }
318    }
319    match providers {
320        // Like Codex: nothing is stored there yet, so the refusal is advice.
321        Ok(auth::providers::Providers {
322            refused: Some(refusal),
323            custom,
324            ..
325        }) => report.add(
326            "providers",
327            CheckStatus::Warn,
328            &refusal,
329            Details::Providers { custom },
330        ),
331        Ok(providers) if providers.custom.is_empty() => report.add(
332            "providers",
333            CheckStatus::Pass,
334            "No custom providers are set up.",
335            Details::Providers {
336                custom: providers.custom,
337            },
338        ),
339        Ok(providers) => report.add(
340            "providers",
341            CheckStatus::Pass,
342            &format!(
343                "Custom providers on this machine: {}.",
344                providers
345                    .custom
346                    .iter()
347                    .map(|(name, custom)| match custom {
348                        auth::providers::CustomView::Alias { provider } => {
349                            format!("{name} (alias of {provider})")
350                        }
351                        auth::providers::CustomView::Endpoint { url, .. } => {
352                            format!("{name} ({})", url.as_str())
353                        }
354                    })
355                    .collect::<Vec<_>>()
356                    .join(", ")
357            ),
358            Details::Providers {
359                custom: providers.custom,
360            },
361        ),
362        Err(error) => report.add("providers", CheckStatus::Fail, &error, Details::None),
363    }
364    let (status, message, details) = codex(&state, repo);
365    report.add("codex", status, &message, details);
366    // Offline: only where `claude` is; its login and version are checked when a review or a
367    // model listing starts it.
368    match crate::platform::program::resolve(
369        std::ffi::OsStr::new(llm::claude_agent::command::PROGRAM),
370        &crate::platform::temporary_directory(),
371    ) {
372        Ok(program) => report.add(
373            "claude-agent",
374            CheckStatus::Pass,
375            &format!(
376                "Claude Code is on PATH: {}; it signs in on its own (`claude auth status`).",
377                crate::platform::path_text(&program)
378            ),
379            Details::ClaudeAgent {
380                program: Some(program),
381            },
382        ),
383        Err(_) => report.add(
384            "claude-agent",
385            CheckStatus::Warn,
386            "Claude Code (`claude`) is not on PATH; the claude-agent backend needs it, signed in with `claude auth login`.",
387            Details::ClaudeAgent { program: None },
388        ),
389    }
390    // Offline: configuration and token storage only; `remote status` checks reachability.
391    match auth::remote::remote(Some(&state), repo) {
392        Ok(None) => report.add(
393            "remote",
394            CheckStatus::Pass,
395            "No remote review store is configured.",
396            Details::NoRemote { configured: false },
397        ),
398        Ok(Some(remote)) => {
399            let details = Details::Remote {
400                configured: true,
401                url: remote.url,
402                share: remote.share,
403                token_source: remote.token_source,
404            };
405            if remote.token_source == auth::remote::TokenSource::None {
406                report.add(
407                    "remote",
408                    CheckStatus::Warn,
409                    "No remote token; run `artifactize remote login URL` or set ARTIFACTIZE_REMOTE_TOKEN.",
410                    details,
411                );
412            } else {
413                report.add(
414                    "remote",
415                    CheckStatus::Pass,
416                    "Remote review store is configured; `remote status` checks reachability.",
417                    details,
418                );
419            }
420        }
421        Err(error) => report.add("remote", CheckStatus::Fail, &error, Details::None),
422    }
423    // The commands work without the app, so a missing app only warns.
424    match crate::platform::desktop_app::installed() {
425        Some(path) => report.add(
426            "desktop",
427            CheckStatus::Pass,
428            &format!(
429                "The desktop app is installed: {}.",
430                crate::platform::path_text(&path)
431            ),
432            Details::Desktop { path: Some(path) },
433        ),
434        None => report.add(
435            "desktop",
436            CheckStatus::Warn,
437            &format!("The desktop app is not installed; install it from {DESKTOP_INSTALL_PAGE}."),
438            Details::Desktop { path: None },
439        ),
440    }
441    let (status, message, details) = release_check(release::check(&state).await);
442    report.add("release", status, &message, details);
443    Ok(report)
444}
445
446/// Whether a newer release is out and how to update to it. Never a hard error: an older
447/// copy still works, and a failed lookup is asked again later.
448fn release_check(checked: release::Status) -> (CheckStatus, String, Details) {
449    use release::{Outcome, Unchecked, Update};
450    let release::Status {
451        running,
452        channel,
453        outcome,
454    } = checked;
455    let (status, message, latest, update) = match outcome {
456        Outcome::Unchecked(Unchecked::Store) => (
457            CheckStatus::Pass,
458            format!("The Microsoft Store keeps artifactize {running} up to date."),
459            None,
460            None,
461        ),
462        Outcome::Unchecked(Unchecked::Off) => (
463            CheckStatus::Pass,
464            format!(
465                "{} is set; releases are not looked up.",
466                release::OFF_VARIABLE
467            ),
468            None,
469            None,
470        ),
471        Outcome::Unchecked(Unchecked::Development) => (
472            CheckStatus::Pass,
473            format!("artifactize {running} is a development build; releases are not looked up."),
474            None,
475            None,
476        ),
477        Outcome::Latest(latest) => (
478            CheckStatus::Pass,
479            format!("artifactize {running} is up to date; the latest release is {latest}."),
480            Some(latest),
481            None,
482        ),
483        Outcome::Newer { latest, update } => {
484            let how = match &update {
485                Update::Command { command } => format!("run `{command}`"),
486                Update::Download { url, command: None } => format!("download and install {url}"),
487                Update::Download {
488                    url,
489                    command: Some(command),
490                } => format!("download {url}, then run `{command}`"),
491                Update::Page { url } => format!("see {url}"),
492            };
493            (
494                CheckStatus::Warn,
495                format!("artifactize {latest} is out (this is {running}); to update, {how}."),
496                Some(latest),
497                Some(update),
498            )
499        }
500        Outcome::Failed(error) => (
501            CheckStatus::Warn,
502            format!("Cannot look up the latest release: {error}"),
503            None,
504            None,
505        ),
506    };
507    (
508        status,
509        message,
510        Details::Release {
511            channel,
512            running,
513            latest,
514            update,
515        },
516    )
517}
518
519/// The saved Agent conversations: whether new ones are saved, the store's size against its
520/// bounds, and the state id their references name.
521fn sessions(state: &Path) -> (CheckStatus, String, Details) {
522    let bounds = match crate::limits::Limits::read(state) {
523        Ok(limits) => limits.agent_sessions(),
524        Err(_) => {
525            return (
526                CheckStatus::Fail,
527                "limits.json is invalid; see limits.".into(),
528                Details::None,
529            );
530        }
531    };
532    let usage = match crate::agent::session::usage(state) {
533        Ok(usage) => usage,
534        Err(error) => return (CheckStatus::Fail, error, Details::None),
535    };
536    // A database the schema check refuses has no id to report here.
537    let state_id = store::read_state_id(state).ok().flatten();
538    // Session storage uses binary mebibytes (2^20 bytes), not decimal megabytes.
539    const BYTES_PER_MIB: u64 = 1_048_576;
540    let mib = |bytes: u64| format!("{:.1} MiB", bytes as f64 / BYTES_PER_MIB as f64);
541    let message = format!(
542        "{} Agent session{} take {} of {}; a collection brings them down to {}.{}",
543        usage.sessions,
544        if usage.sessions == 1 { "" } else { "s" },
545        mib(usage.bytes),
546        mib(bounds.max_bytes),
547        mib(bounds.target_bytes),
548        if bounds.enabled {
549            ""
550        } else {
551            " Saving is off (limits.json agentSessions.enabled)."
552        }
553    );
554    let details = Details::Sessions {
555        enabled: bounds.enabled,
556        sessions: usage.sessions,
557        bytes: usage.bytes,
558        max_bytes: bounds.max_bytes,
559        target_bytes: bounds.target_bytes,
560        directory: crate::agent::session::directory(state),
561        state_id,
562    };
563    (CheckStatus::Pass, message, details)
564}
565
566/// The Codex sign-in, read offline: no lock, refresh or network, and an auth file is
567/// only read.
568fn codex(state: &Path, repo: Option<&Path>) -> (CheckStatus, String, Details) {
569    let endpoints = llm::test_endpoint(Provider::Codex).and_then(|base| {
570        Ok((
571            base,
572            llm::variable_endpoint(auth::codex::AUTH_URL_VARIABLE)?,
573        ))
574    });
575    let ((base, sign_in), status) = match (endpoints, auth::codex::status(Some(state), repo)) {
576        (Err(error), _) | (_, Err(error)) => return (CheckStatus::Fail, error, Details::None),
577        (Ok(endpoints), Ok(status)) => (endpoints, status),
578    };
579    use auth::codex::{FileExpiry, Status, StoredExpiry};
580    let (mut level, mut message) = match &status {
581        Status::Refused { reason } => (CheckStatus::Warn, reason.clone()),
582        Status::Absent => (
583            CheckStatus::Warn,
584            "No Codex sign-in; run `artifactize login codex` or set ARTIFACTIZE_CODEX_AUTH_FILE."
585                .to_owned(),
586        ),
587        Status::File {
588            path,
589            expiry: FileExpiry::Usable { .. },
590        } => (
591            CheckStatus::Pass,
592            format!(
593                "ARTIFACTIZE_CODEX_AUTH_FILE is read, never refreshed: {}.",
594                crate::platform::path_text(path)
595            ),
596        ),
597        Status::File {
598            path,
599            expiry: FileExpiry::Expired,
600        } => (
601            CheckStatus::Warn,
602            format!(
603                "The Codex access token in {} has expired; sign in with Codex again.",
604                crate::platform::path_text(path)
605            ),
606        ),
607        Status::Stored {
608            expiry: StoredExpiry::Usable { .. },
609        } => (
610            CheckStatus::Pass,
611            "Codex sign-in is present; no provider validation or refresh was attempted.".to_owned(),
612        ),
613        Status::Stored {
614            expiry: StoredExpiry::Expired { .. },
615        } => (
616            CheckStatus::Pass,
617            "Codex sign-in is present; its access token has expired and is refreshed on next use."
618                .to_owned(),
619        ),
620    };
621    for (variable, endpoint) in [
622        (
623            llm::base_url_variable(Provider::Codex).expect("Codex has a test endpoint"),
624            &base,
625        ),
626        (auth::codex::AUTH_URL_VARIABLE, &sign_in),
627    ] {
628        if let Some(endpoint) = endpoint {
629            level = CheckStatus::Warn;
630            message.push_str(&format!(
631                " {variable} points at the local test endpoint {endpoint}."
632            ));
633        }
634    }
635    let details = Details::Codex {
636        status,
637        test_endpoint: base,
638        test_auth_endpoint: sign_in,
639    };
640    (level, message, details)
641}
642
643fn probe_writable(state: &Path) -> std::io::Result<()> {
644    let mut parent = state;
645    while !parent.try_exists()? {
646        parent = parent
647            .parent()
648            .ok_or_else(|| std::io::Error::other("No existing state directory ancestor."))?;
649    }
650    if !fs::metadata(parent)?.is_dir() {
651        return Err(std::io::Error::other(
652            "State directory or its ancestor is not a directory.",
653        ));
654    }
655    let probe = tempfile::Builder::new()
656        .prefix(".artifactize-doctor-")
657        .tempdir_in(parent)?;
658    probe.close()
659}