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#[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 Desktop {
130 #[serde(with = "crate::platform::path_serde::option")]
131 path: Option<PathBuf>,
132 },
133}
134
135const 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
150pub 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 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 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 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 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 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
417fn 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
490fn 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 let state_id = store::read_state_id(state).ok().flatten();
509 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
537fn 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}