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, read_workspace_config},
14    llm, 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<String>,
85    },
86    NoRemote {
87        configured: bool,
88    },
89    Remote {
90        configured: bool,
91        url: url::Url,
92        share: auth::remote::Share,
93        #[serde(rename = "tokenSource")]
94        token_source: auth::remote::TokenSource,
95    },
96    Sessions {
97        enabled: bool,
98        sessions: usize,
99        bytes: u64,
100        #[serde(rename = "maxBytes")]
101        max_bytes: u64,
102        #[serde(rename = "targetBytes")]
103        target_bytes: u64,
104        #[serde(with = "crate::platform::path_serde")]
105        directory: PathBuf,
106        #[serde(rename = "stateId")]
107        state_id: Option<crate::types::StateId>,
108    },
109    Codex {
110        #[serde(flatten)]
111        status: auth::codex::Status,
112        #[serde(rename = "testEndpoint", skip_serializing_if = "Option::is_none")]
113        test_endpoint: Option<String>,
114        #[serde(rename = "testAuthEndpoint", skip_serializing_if = "Option::is_none")]
115        test_auth_endpoint: Option<String>,
116    },
117}
118
119impl DoctorReport {
120    fn add(&mut self, name: &'static str, status: CheckStatus, message: &str, details: Details) {
121        self.ok &= status.ready();
122        self.checks.push(Check {
123            name,
124            status,
125            message: message.into(),
126            details,
127        });
128    }
129}
130
131/// Local readiness only. Missing optional backends are warnings, not failed reviews.
132pub async fn doctor(state: Option<&Path>, repo: Option<&Path>) -> Result<DoctorReport, String> {
133    let state = store::state_dir(state)?;
134    let mut report = DoctorReport {
135        ok: true,
136        state_dir: state.clone(),
137        checks: Vec::new(),
138    };
139    let config = repo.map(read_workspace_config).transpose();
140    if repo.is_some() {
141        match &config {
142            Ok(Some(config)) => report.add(
143                "config",
144                CheckStatus::Pass,
145                "Folder configuration is valid.",
146                Details::Config {
147                    artifacts: config.artifacts.len(),
148                    evals: config.evals.len(),
149                },
150            ),
151            Err(error) => report.add(
152                "config",
153                CheckStatus::Fail,
154                &error.to_string(),
155                Details::None,
156            ),
157            _ => unreachable!(),
158        }
159    }
160    let writable = (|| {
161        let state = workspace::canonical_target(&state)?;
162        if let Some(repo) = repo {
163            let repo = workspace::canonical_target(repo)?;
164            let repo = if repo.is_file() {
165                repo.parent().unwrap()
166            } else {
167                &repo
168            };
169            workspace::outside_workspace(repo, &state)?;
170        } else {
171            workspace::outside_artifact_workspaces(&state)?;
172        }
173        probe_writable(&state)
174    })();
175    match writable {
176        Ok(()) => report.add(
177            "state",
178            CheckStatus::Pass,
179            "State directory is writable or can be created.",
180            Details::State { writable: true },
181        ),
182        Err(error) => report.add(
183            "state",
184            CheckStatus::Fail,
185            &error.to_string(),
186            Details::State { writable: false },
187        ),
188    }
189    // Existing-state preflight upgrades supported schemas without creating an absent database.
190    let current = store::STATE_SCHEMA_VERSION;
191    match store::state_schema(&state) {
192        Ok(None) => report.add(
193            "schema",
194            CheckStatus::Pass,
195            "No state database yet.",
196            Details::Schema { schema: None },
197        ),
198        Ok(Some(0)) => report.add(
199            "schema",
200            CheckStatus::Pass,
201            "The state database is not initialized yet.",
202            Details::Schema { schema: Some(0) },
203        ),
204        Ok(Some(found)) if found == current => report.add(
205            "schema",
206            CheckStatus::Pass,
207            &format!("State database schema {current}."),
208            Details::Schema {
209                schema: Some(current),
210            },
211        ),
212        Ok(Some(found)) => report.add(
213            "schema",
214            CheckStatus::Fail,
215            &store::schema_error(found),
216            Details::UnsupportedSchema {
217                schema: found,
218                supported: current,
219            },
220        ),
221        Err(error) => report.add("schema", CheckStatus::Fail, &error, Details::None),
222    }
223    match crate::limits::Limits::read(&state) {
224        Ok(limits) if limits.backends().is_empty() => report.add(
225            "limits",
226            CheckStatus::Pass,
227            "No backend capacity limits (limits.json).",
228            Details::Limits {
229                backends: Default::default(),
230            },
231        ),
232        Ok(limits) => report.add(
233            "limits",
234            CheckStatus::Pass,
235            &format!(
236                "Backend capacity on this machine: {}.",
237                limits
238                    .backends()
239                    .iter()
240                    .map(|(backend, slots)| format!("{backend} {slots}"))
241                    .collect::<Vec<_>>()
242                    .join(", ")
243            ),
244            Details::Limits {
245                backends: limits.backends().clone(),
246            },
247        ),
248        Err(error) => report.add("limits", CheckStatus::Fail, &error, Details::None),
249    }
250    let (status, message, details) = sessions(&state);
251    report.add("sessions", status, &message, details);
252    for (name, backend) in [
253        ("openai", Backend::Openai),
254        ("anthropic", Backend::Anthropic),
255    ] {
256        let variable = llm::key_variable(backend).expect("an API-key backend");
257        let present = llm::key_present(backend);
258        let key = format!(
259            "{variable} is {} (not validated with the provider)",
260            if present { "present" } else { "absent" }
261        );
262        match llm::test_endpoint(backend) {
263            Ok(None) => report.add(
264                name,
265                if present {
266                    CheckStatus::Pass
267                } else {
268                    CheckStatus::Warn
269                },
270                &format!("{key}."),
271                Details::ApiKey { present, test_endpoint: None },
272            ),
273            // A test endpoint is never a production setup, so it always warns.
274            Ok(Some(endpoint)) => report.add(
275                name,
276                CheckStatus::Warn,
277                &format!(
278                    "{key}; {} sends this backend's requests to the local test endpoint {endpoint}.",
279                    llm::base_url_variable(backend)
280                ),
281                Details::ApiKey { present, test_endpoint: Some(endpoint) },
282            ),
283            Err(error) => report.add(name, CheckStatus::Fail, &error, Details::ApiKey { present, test_endpoint: None }),
284        }
285    }
286    let (status, message, details) = codex(&state, repo);
287    report.add("codex", status, &message, details);
288    // Offline: configuration and token storage only; `remote status` checks reachability.
289    match auth::remote::remote(Some(&state), repo) {
290        Ok(None) => report.add(
291            "remote",
292            CheckStatus::Pass,
293            "No remote review store is configured.",
294            Details::NoRemote { configured: false },
295        ),
296        Ok(Some(remote)) => {
297            let details = Details::Remote {
298                configured: true,
299                url: remote.url,
300                share: remote.share,
301                token_source: remote.token_source,
302            };
303            if remote.token_source == auth::remote::TokenSource::None {
304                report.add(
305                    "remote",
306                    CheckStatus::Warn,
307                    "No remote token; run `artifactize remote login URL` or set ARTIFACTIZE_REMOTE_TOKEN.",
308                    details,
309                );
310            } else {
311                report.add(
312                    "remote",
313                    CheckStatus::Pass,
314                    "Remote review store is configured; `remote status` checks reachability.",
315                    details,
316                );
317            }
318        }
319        Err(error) => report.add("remote", CheckStatus::Fail, &error, Details::None),
320    }
321    Ok(report)
322}
323
324/// The saved Agent conversations: whether new ones are saved, the store's size against its
325/// bounds, and the state id their references name.
326fn sessions(state: &Path) -> (CheckStatus, String, Details) {
327    let bounds = match crate::limits::Limits::read(state) {
328        Ok(limits) => limits.agent_sessions(),
329        Err(_) => {
330            return (
331                CheckStatus::Fail,
332                "limits.json is invalid; see limits.".into(),
333                Details::None,
334            );
335        }
336    };
337    let usage = match crate::agent::session::usage(state) {
338        Ok(usage) => usage,
339        Err(error) => return (CheckStatus::Fail, error, Details::None),
340    };
341    // A database the schema check refuses has no id to report here.
342    let state_id = store::read_state_id(state).ok().flatten();
343    // Session storage uses binary mebibytes (2^20 bytes), not decimal megabytes.
344    const BYTES_PER_MIB: u64 = 1_048_576;
345    let mib = |bytes: u64| format!("{:.1} MiB", bytes as f64 / BYTES_PER_MIB as f64);
346    let message = format!(
347        "{} Agent session{} take {} of {}; a collection brings them down to {}.{}",
348        usage.sessions,
349        if usage.sessions == 1 { "" } else { "s" },
350        mib(usage.bytes),
351        mib(bounds.max_bytes),
352        mib(bounds.target_bytes),
353        if bounds.enabled {
354            ""
355        } else {
356            " Saving is off (limits.json agentSessions.enabled)."
357        }
358    );
359    let details = Details::Sessions {
360        enabled: bounds.enabled,
361        sessions: usage.sessions,
362        bytes: usage.bytes,
363        max_bytes: bounds.max_bytes,
364        target_bytes: bounds.target_bytes,
365        directory: crate::agent::session::directory(state),
366        state_id,
367    };
368    (CheckStatus::Pass, message, details)
369}
370
371/// The Codex sign-in, read offline: no lock, refresh or network, and an auth file is
372/// only read.
373fn codex(state: &Path, repo: Option<&Path>) -> (CheckStatus, String, Details) {
374    let endpoints = llm::test_endpoint(Backend::Codex).and_then(|base| {
375        Ok((
376            base,
377            llm::variable_endpoint(auth::codex::AUTH_URL_VARIABLE)?,
378        ))
379    });
380    let ((base, sign_in), status) = match (endpoints, auth::codex::status(Some(state), repo)) {
381        (Err(error), _) | (_, Err(error)) => return (CheckStatus::Fail, error, Details::None),
382        (Ok(endpoints), Ok(status)) => (endpoints, status),
383    };
384    use auth::codex::{FileExpiry, Status, StoredExpiry};
385    let (mut level, mut message) = match &status {
386        Status::Refused { reason } => (CheckStatus::Warn, reason.clone()),
387        Status::Absent => (
388            CheckStatus::Warn,
389            "No Codex sign-in; run `artifactize login codex` or set ARTIFACTIZE_CODEX_AUTH_FILE."
390                .to_owned(),
391        ),
392        Status::File {
393            path,
394            expiry: FileExpiry::Usable { .. },
395        } => (
396            CheckStatus::Pass,
397            format!(
398                "ARTIFACTIZE_CODEX_AUTH_FILE is read, never refreshed: {}.",
399                crate::platform::path_text(path)
400            ),
401        ),
402        Status::File {
403            path,
404            expiry: FileExpiry::Expired,
405        } => (
406            CheckStatus::Warn,
407            format!(
408                "The Codex access token in {} has expired; sign in with Codex again.",
409                crate::platform::path_text(path)
410            ),
411        ),
412        Status::Stored {
413            expiry: StoredExpiry::Usable { .. },
414        } => (
415            CheckStatus::Pass,
416            "Codex sign-in is present; no provider validation or refresh was attempted.".to_owned(),
417        ),
418        Status::Stored {
419            expiry: StoredExpiry::Expired { .. },
420        } => (
421            CheckStatus::Pass,
422            "Codex sign-in is present; its access token has expired and is refreshed on next use."
423                .to_owned(),
424        ),
425    };
426    for (variable, endpoint) in [
427        (llm::base_url_variable(Backend::Codex), &base),
428        (auth::codex::AUTH_URL_VARIABLE, &sign_in),
429    ] {
430        if let Some(endpoint) = endpoint {
431            level = CheckStatus::Warn;
432            message.push_str(&format!(
433                " {variable} points at the local test endpoint {endpoint}."
434            ));
435        }
436    }
437    let details = Details::Codex {
438        status,
439        test_endpoint: base,
440        test_auth_endpoint: sign_in,
441    };
442    (level, message, details)
443}
444
445fn probe_writable(state: &Path) -> std::io::Result<()> {
446    let mut parent = state;
447    while !parent.try_exists()? {
448        parent = parent
449            .parent()
450            .ok_or_else(|| std::io::Error::other("No existing state directory ancestor."))?;
451    }
452    if !fs::metadata(parent)?.is_dir() {
453        return Err(std::io::Error::other(
454            "State directory or its ancestor is not a directory.",
455        ));
456    }
457    let probe = tempfile::Builder::new()
458        .prefix(".artifactize-doctor-")
459        .tempdir_in(parent)?;
460    probe.close()
461}