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#[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
131pub 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 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 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 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
324fn 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 let state_id = store::read_state_id(state).ok().flatten();
343 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
371fn 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}