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 ClaudeAgent {
130 #[serde(with = "crate::platform::path_serde::option")]
131 program: Option<PathBuf>,
132 },
133 Desktop {
135 #[serde(with = "crate::platform::path_serde::option")]
136 path: Option<PathBuf>,
137 },
138}
139
140const 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
155pub 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 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 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 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 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 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 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
446fn 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
519fn 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 let state_id = store::read_state_id(state).ok().flatten();
538 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
566fn 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}