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    /// The installed desktop app, or `null` without one.
129    Desktop {
130        #[serde(with = "crate::platform::path_serde::option")]
131        path: Option<PathBuf>,
132    },
133}
134
135/// Where a person without the desktop app installs it.
136const DESKTOP_INSTALL_PAGE: &str = "https://artifactize.dev/#start";
137
138impl DoctorReport {
139    fn add(&mut self, name: &'static str, status: CheckStatus, message: &str, details: Details) {
140        self.ok &= status.ready();
141        self.checks.push(Check {
142            name,
143            status,
144            message: message.into(),
145            details,
146        });
147    }
148}
149
150/// Local readiness, and whether a newer release is out. Missing optional backends and an
151/// older release are warnings, not failed reviews.
152pub async fn doctor(state: Option<&Path>, repo: Option<&Path>) -> Result<DoctorReport, String> {
153    let state = store::state_dir(state)?;
154    let mut report = DoctorReport {
155        ok: true,
156        state_dir: state.clone(),
157        checks: Vec::new(),
158    };
159    let config = repo.map(read_workspace_config).transpose();
160    if repo.is_some() {
161        match &config {
162            Ok(Some(config)) => report.add(
163                "config",
164                CheckStatus::Pass,
165                "Folder configuration is valid.",
166                Details::Config {
167                    artifacts: config.artifacts.len(),
168                    evals: config.evals.len(),
169                },
170            ),
171            Err(error) => report.add(
172                "config",
173                CheckStatus::Fail,
174                &error.to_string(),
175                Details::None,
176            ),
177            _ => unreachable!(),
178        }
179    }
180    let writable = (|| {
181        let state = workspace::canonical_target(&state)?;
182        if let Some(repo) = repo {
183            let repo = workspace::canonical_target(repo)?;
184            let repo = if repo.is_file() {
185                repo.parent().unwrap()
186            } else {
187                &repo
188            };
189            workspace::outside_workspace(repo, &state)?;
190        } else {
191            workspace::outside_artifact_workspaces(&state)?;
192        }
193        probe_writable(&state)
194    })();
195    match writable {
196        Ok(()) => report.add(
197            "state",
198            CheckStatus::Pass,
199            "State directory is writable or can be created.",
200            Details::State { writable: true },
201        ),
202        Err(error) => report.add(
203            "state",
204            CheckStatus::Fail,
205            &error.to_string(),
206            Details::State { writable: false },
207        ),
208    }
209    // Existing-state preflight upgrades supported schemas without creating an absent database.
210    let current = store::STATE_SCHEMA_VERSION;
211    match store::state_schema(&state) {
212        Ok(None) => report.add(
213            "schema",
214            CheckStatus::Pass,
215            "No state database yet.",
216            Details::Schema { schema: None },
217        ),
218        Ok(Some(0)) => report.add(
219            "schema",
220            CheckStatus::Pass,
221            "The state database is not initialized yet.",
222            Details::Schema { schema: Some(0) },
223        ),
224        Ok(Some(found)) if found == current => report.add(
225            "schema",
226            CheckStatus::Pass,
227            &format!("State database schema {current}."),
228            Details::Schema {
229                schema: Some(current),
230            },
231        ),
232        Ok(Some(found)) => report.add(
233            "schema",
234            CheckStatus::Fail,
235            &store::schema_error(found),
236            Details::UnsupportedSchema {
237                schema: found,
238                supported: current,
239            },
240        ),
241        Err(error) => report.add("schema", CheckStatus::Fail, &error, Details::None),
242    }
243    match crate::limits::Limits::read(&state) {
244        Ok(limits) if limits.backends().is_empty() => report.add(
245            "limits",
246            CheckStatus::Pass,
247            "No backend capacity limits (limits.json).",
248            Details::Limits {
249                backends: Default::default(),
250            },
251        ),
252        Ok(limits) => report.add(
253            "limits",
254            CheckStatus::Pass,
255            &format!(
256                "Backend capacity on this machine: {}.",
257                limits
258                    .backends()
259                    .iter()
260                    .map(|(backend, slots)| format!("{backend} {slots}"))
261                    .collect::<Vec<_>>()
262                    .join(", ")
263            ),
264            Details::Limits {
265                backends: limits.backends().clone(),
266            },
267        ),
268        Err(error) => report.add("limits", CheckStatus::Fail, &error, Details::None),
269    }
270    let (status, message, details) = sessions(&state);
271    report.add("sessions", status, &message, details);
272    let providers = auth::providers::list(Some(&state), repo);
273    let keys = match &providers {
274        Ok(providers) => providers.keys.clone(),
275        Err(_) => Vec::new(),
276    };
277    for status in keys {
278        let (name, variable, source) = (status.provider.as_str(), status.variable, status.source);
279        let present = source != auth::providers::KeySource::Absent;
280        let key = match source {
281            auth::providers::KeySource::Saved => format!(
282                "{variable} is absent; a saved {name} key is present (not validated with the provider)"
283            ),
284            _ => format!(
285                "{variable} is {} (not validated with the provider)",
286                if present { "present" } else { "absent" }
287            ),
288        };
289        let backend = status.provider;
290        match llm::test_endpoint(backend) {
291            Ok(None) => report.add(
292                name,
293                if present {
294                    CheckStatus::Pass
295                } else {
296                    CheckStatus::Warn
297                },
298                &format!("{key}."),
299                Details::ApiKey { present, test_endpoint: None },
300            ),
301            // A test endpoint is never a production setup, so it always warns.
302            Ok(Some(endpoint)) => report.add(
303                name,
304                CheckStatus::Warn,
305                &format!(
306                    "{key}; {} sends this backend's requests to the local test endpoint {endpoint}.",
307                    llm::base_url_variable(backend)
308                ),
309                Details::ApiKey { present, test_endpoint: Some(endpoint) },
310            ),
311            Err(error) => report.add(name, CheckStatus::Fail, &error, Details::ApiKey { present, test_endpoint: None }),
312        }
313    }
314    match providers {
315        // Like Codex: nothing is stored there yet, so the refusal is advice.
316        Ok(auth::providers::Providers {
317            refused: Some(refusal),
318            custom,
319            ..
320        }) => report.add(
321            "providers",
322            CheckStatus::Warn,
323            &refusal,
324            Details::Providers { custom },
325        ),
326        Ok(providers) if providers.custom.is_empty() => report.add(
327            "providers",
328            CheckStatus::Pass,
329            "No custom providers are set up.",
330            Details::Providers {
331                custom: providers.custom,
332            },
333        ),
334        Ok(providers) => report.add(
335            "providers",
336            CheckStatus::Pass,
337            &format!(
338                "Custom providers on this machine: {}.",
339                providers
340                    .custom
341                    .iter()
342                    .map(|(name, custom)| match custom {
343                        auth::providers::CustomView::Alias { provider } => {
344                            format!("{name} (alias of {provider})")
345                        }
346                        auth::providers::CustomView::Endpoint { url, .. } => {
347                            format!("{name} ({})", url.as_str())
348                        }
349                    })
350                    .collect::<Vec<_>>()
351                    .join(", ")
352            ),
353            Details::Providers {
354                custom: providers.custom,
355            },
356        ),
357        Err(error) => report.add("providers", CheckStatus::Fail, &error, Details::None),
358    }
359    let (status, message, details) = codex(&state, repo);
360    report.add("codex", status, &message, details);
361    // Offline: configuration and token storage only; `remote status` checks reachability.
362    match auth::remote::remote(Some(&state), repo) {
363        Ok(None) => report.add(
364            "remote",
365            CheckStatus::Pass,
366            "No remote review store is configured.",
367            Details::NoRemote { configured: false },
368        ),
369        Ok(Some(remote)) => {
370            let details = Details::Remote {
371                configured: true,
372                url: remote.url,
373                share: remote.share,
374                token_source: remote.token_source,
375            };
376            if remote.token_source == auth::remote::TokenSource::None {
377                report.add(
378                    "remote",
379                    CheckStatus::Warn,
380                    "No remote token; run `artifactize remote login URL` or set ARTIFACTIZE_REMOTE_TOKEN.",
381                    details,
382                );
383            } else {
384                report.add(
385                    "remote",
386                    CheckStatus::Pass,
387                    "Remote review store is configured; `remote status` checks reachability.",
388                    details,
389                );
390            }
391        }
392        Err(error) => report.add("remote", CheckStatus::Fail, &error, Details::None),
393    }
394    // The commands work without the app, so a missing app only warns.
395    match crate::platform::desktop_app::installed() {
396        Some(path) => report.add(
397            "desktop",
398            CheckStatus::Pass,
399            &format!(
400                "The desktop app is installed: {}.",
401                crate::platform::path_text(&path)
402            ),
403            Details::Desktop { path: Some(path) },
404        ),
405        None => report.add(
406            "desktop",
407            CheckStatus::Warn,
408            &format!("The desktop app is not installed; install it from {DESKTOP_INSTALL_PAGE}."),
409            Details::Desktop { path: None },
410        ),
411    }
412    let (status, message, details) = release_check(release::check(&state).await);
413    report.add("release", status, &message, details);
414    Ok(report)
415}
416
417/// Whether a newer release is out and how to update to it. Never a hard error: an older
418/// copy still works, and a failed lookup is asked again later.
419fn release_check(checked: release::Status) -> (CheckStatus, String, Details) {
420    use release::{Outcome, Unchecked, Update};
421    let release::Status {
422        running,
423        channel,
424        outcome,
425    } = checked;
426    let (status, message, latest, update) = match outcome {
427        Outcome::Unchecked(Unchecked::Store) => (
428            CheckStatus::Pass,
429            format!("The Microsoft Store keeps artifactize {running} up to date."),
430            None,
431            None,
432        ),
433        Outcome::Unchecked(Unchecked::Off) => (
434            CheckStatus::Pass,
435            format!(
436                "{} is set; releases are not looked up.",
437                release::OFF_VARIABLE
438            ),
439            None,
440            None,
441        ),
442        Outcome::Unchecked(Unchecked::Development) => (
443            CheckStatus::Pass,
444            format!("artifactize {running} is a development build; releases are not looked up."),
445            None,
446            None,
447        ),
448        Outcome::Latest(latest) => (
449            CheckStatus::Pass,
450            format!("artifactize {running} is up to date; the latest release is {latest}."),
451            Some(latest),
452            None,
453        ),
454        Outcome::Newer { latest, update } => {
455            let how = match &update {
456                Update::Command { command } => format!("run `{command}`"),
457                Update::Download { url, command: None } => format!("download and install {url}"),
458                Update::Download {
459                    url,
460                    command: Some(command),
461                } => format!("download {url}, then run `{command}`"),
462                Update::Page { url } => format!("see {url}"),
463            };
464            (
465                CheckStatus::Warn,
466                format!("artifactize {latest} is out (this is {running}); to update, {how}."),
467                Some(latest),
468                Some(update),
469            )
470        }
471        Outcome::Failed(error) => (
472            CheckStatus::Warn,
473            format!("Cannot look up the latest release: {error}"),
474            None,
475            None,
476        ),
477    };
478    (
479        status,
480        message,
481        Details::Release {
482            channel,
483            running,
484            latest,
485            update,
486        },
487    )
488}
489
490/// The saved Agent conversations: whether new ones are saved, the store's size against its
491/// bounds, and the state id their references name.
492fn sessions(state: &Path) -> (CheckStatus, String, Details) {
493    let bounds = match crate::limits::Limits::read(state) {
494        Ok(limits) => limits.agent_sessions(),
495        Err(_) => {
496            return (
497                CheckStatus::Fail,
498                "limits.json is invalid; see limits.".into(),
499                Details::None,
500            );
501        }
502    };
503    let usage = match crate::agent::session::usage(state) {
504        Ok(usage) => usage,
505        Err(error) => return (CheckStatus::Fail, error, Details::None),
506    };
507    // A database the schema check refuses has no id to report here.
508    let state_id = store::read_state_id(state).ok().flatten();
509    // Session storage uses binary mebibytes (2^20 bytes), not decimal megabytes.
510    const BYTES_PER_MIB: u64 = 1_048_576;
511    let mib = |bytes: u64| format!("{:.1} MiB", bytes as f64 / BYTES_PER_MIB as f64);
512    let message = format!(
513        "{} Agent session{} take {} of {}; a collection brings them down to {}.{}",
514        usage.sessions,
515        if usage.sessions == 1 { "" } else { "s" },
516        mib(usage.bytes),
517        mib(bounds.max_bytes),
518        mib(bounds.target_bytes),
519        if bounds.enabled {
520            ""
521        } else {
522            " Saving is off (limits.json agentSessions.enabled)."
523        }
524    );
525    let details = Details::Sessions {
526        enabled: bounds.enabled,
527        sessions: usage.sessions,
528        bytes: usage.bytes,
529        max_bytes: bounds.max_bytes,
530        target_bytes: bounds.target_bytes,
531        directory: crate::agent::session::directory(state),
532        state_id,
533    };
534    (CheckStatus::Pass, message, details)
535}
536
537/// The Codex sign-in, read offline: no lock, refresh or network, and an auth file is
538/// only read.
539fn codex(state: &Path, repo: Option<&Path>) -> (CheckStatus, String, Details) {
540    let endpoints = llm::test_endpoint(Provider::Codex).and_then(|base| {
541        Ok((
542            base,
543            llm::variable_endpoint(auth::codex::AUTH_URL_VARIABLE)?,
544        ))
545    });
546    let ((base, sign_in), status) = match (endpoints, auth::codex::status(Some(state), repo)) {
547        (Err(error), _) | (_, Err(error)) => return (CheckStatus::Fail, error, Details::None),
548        (Ok(endpoints), Ok(status)) => (endpoints, status),
549    };
550    use auth::codex::{FileExpiry, Status, StoredExpiry};
551    let (mut level, mut message) = match &status {
552        Status::Refused { reason } => (CheckStatus::Warn, reason.clone()),
553        Status::Absent => (
554            CheckStatus::Warn,
555            "No Codex sign-in; run `artifactize login codex` or set ARTIFACTIZE_CODEX_AUTH_FILE."
556                .to_owned(),
557        ),
558        Status::File {
559            path,
560            expiry: FileExpiry::Usable { .. },
561        } => (
562            CheckStatus::Pass,
563            format!(
564                "ARTIFACTIZE_CODEX_AUTH_FILE is read, never refreshed: {}.",
565                crate::platform::path_text(path)
566            ),
567        ),
568        Status::File {
569            path,
570            expiry: FileExpiry::Expired,
571        } => (
572            CheckStatus::Warn,
573            format!(
574                "The Codex access token in {} has expired; sign in with Codex again.",
575                crate::platform::path_text(path)
576            ),
577        ),
578        Status::Stored {
579            expiry: StoredExpiry::Usable { .. },
580        } => (
581            CheckStatus::Pass,
582            "Codex sign-in is present; no provider validation or refresh was attempted.".to_owned(),
583        ),
584        Status::Stored {
585            expiry: StoredExpiry::Expired { .. },
586        } => (
587            CheckStatus::Pass,
588            "Codex sign-in is present; its access token has expired and is refreshed on next use."
589                .to_owned(),
590        ),
591    };
592    for (variable, endpoint) in [
593        (llm::base_url_variable(Provider::Codex), &base),
594        (auth::codex::AUTH_URL_VARIABLE, &sign_in),
595    ] {
596        if let Some(endpoint) = endpoint {
597            level = CheckStatus::Warn;
598            message.push_str(&format!(
599                " {variable} points at the local test endpoint {endpoint}."
600            ));
601        }
602    }
603    let details = Details::Codex {
604        status,
605        test_endpoint: base,
606        test_auth_endpoint: sign_in,
607    };
608    (level, message, details)
609}
610
611fn probe_writable(state: &Path) -> std::io::Result<()> {
612    let mut parent = state;
613    while !parent.try_exists()? {
614        parent = parent
615            .parent()
616            .ok_or_else(|| std::io::Error::other("No existing state directory ancestor."))?;
617    }
618    if !fs::metadata(parent)?.is_dir() {
619        return Err(std::io::Error::other(
620            "State directory or its ancestor is not a directory.",
621        ));
622    }
623    let probe = tempfile::Builder::new()
624        .prefix(".artifactize-doctor-")
625        .tempdir_in(parent)?;
626    probe.close()
627}