1use anyhow::Result;
2use chrono::{DateTime, Utc};
3use std::io::Write;
4use std::path::Path;
5use zeroclaw_config::schema::Config;
6
7const DAEMON_STALE_SECONDS: i64 = 30;
8const SCHEDULER_STALE_SECONDS: i64 = 120;
9const CHANNEL_STALE_SECONDS: i64 = 300;
10const COMMAND_VERSION_PREVIEW_CHARS: usize = 60;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
15#[serde(rename_all = "lowercase")]
16pub enum Severity {
17 Ok,
18 Warn,
19 Error,
20}
21
22#[derive(Debug, Clone, serde::Serialize)]
24pub struct DiagResult {
25 pub severity: Severity,
26 pub category: String,
27 pub message: String,
28}
29
30struct DiagItem {
31 severity: Severity,
32 category: &'static str,
33 message: String,
34}
35
36impl DiagItem {
37 fn ok(category: &'static str, msg: impl Into<String>) -> Self {
38 Self {
39 severity: Severity::Ok,
40 category,
41 message: msg.into(),
42 }
43 }
44 fn warn(category: &'static str, msg: impl Into<String>) -> Self {
45 Self {
46 severity: Severity::Warn,
47 category,
48 message: msg.into(),
49 }
50 }
51 fn error(category: &'static str, msg: impl Into<String>) -> Self {
52 Self {
53 severity: Severity::Error,
54 category,
55 message: msg.into(),
56 }
57 }
58
59 #[cfg(test)]
60 fn icon(&self) -> &'static str {
61 match self.severity {
62 Severity::Ok => "✅",
63 Severity::Warn => "⚠️ ",
64 Severity::Error => "❌",
65 }
66 }
67
68 fn into_result(self) -> DiagResult {
69 DiagResult {
70 severity: self.severity,
71 category: self.category.to_string(),
72 message: self.message,
73 }
74 }
75}
76
77pub fn diagnose(config: &Config) -> Vec<DiagResult> {
81 let mut items: Vec<DiagItem> = Vec::new();
82
83 check_config_semantics(config, &mut items);
84 check_workspace(config, &mut items);
85 check_daemon_state(config, &mut items);
86 check_environment(&mut items);
87 check_cli_tools(&mut items);
88
89 items.into_iter().map(DiagItem::into_result).collect()
90}
91
92async fn probe_models(config: &Config) -> Vec<DiagResult> {
94 let targets = doctor_model_targets(config, None);
95 let mut out = Vec::new();
96
97 for provider_name in &targets {
98 let result = match create_doctor_model_provider(config, provider_name) {
99 Ok(handle) => handle.list_models().await,
100 Err(e) => Err(e),
101 };
102 match result {
103 Ok(models) => out.push(DiagResult {
104 severity: Severity::Ok,
105 category: "providers.models".to_string(),
106 message: format!("{}: {} models", provider_name, models.len()),
107 }),
108 Err(e) => {
109 let text = format_error_chain(&e);
110 let severity = match classify_model_probe_error(&text) {
111 ModelProbeOutcome::Skipped => Severity::Warn,
112 ModelProbeOutcome::AuthOrAccess => Severity::Warn,
113 ModelProbeOutcome::Ok | ModelProbeOutcome::Error => Severity::Error,
114 };
115 out.push(DiagResult {
116 severity,
117 category: "providers.models".to_string(),
118 message: format!("{}: {}", provider_name, truncate_for_display(&text, 120)),
119 });
120 }
121 }
122 }
123
124 out
125}
126
127pub async fn run(config: &Config) -> Result<()> {
128 let mut results = diagnose(config);
129 results.extend(probe_models(config).await);
130
131 println!("🩺 ZeroClaw Doctor (enhanced)");
132 println!();
133
134 let mut current_cat = String::new();
135 for item in &results {
136 if item.category != current_cat {
137 current_cat = item.category.clone();
138 println!(" [{current_cat}]");
139 }
140 let icon = match item.severity {
141 Severity::Ok => "✅",
142 Severity::Warn => "⚠️ ",
143 Severity::Error => "❌",
144 };
145 println!(" {} {}", icon, item.message);
146 }
147
148 let errors = results
149 .iter()
150 .filter(|i| i.severity == Severity::Error)
151 .count();
152 let warns = results
153 .iter()
154 .filter(|i| i.severity == Severity::Warn)
155 .count();
156 let oks = results
157 .iter()
158 .filter(|i| i.severity == Severity::Ok)
159 .count();
160
161 println!();
162 println!(" Summary: {oks} ok, {warns} warnings, {errors} errors");
163
164 if errors > 0 {
165 println!(" 💡 Fix the errors above, then run `zeroclaw doctor` again.");
166 }
167
168 Ok(())
169}
170
171#[derive(Debug, Clone, Copy, PartialEq, Eq)]
172enum ModelProbeOutcome {
173 Ok,
174 Skipped,
175 AuthOrAccess,
176 Error,
177}
178
179fn model_probe_status_label(outcome: ModelProbeOutcome) -> &'static str {
180 match outcome {
181 ModelProbeOutcome::Ok => "ok",
182 ModelProbeOutcome::Skipped => "skipped",
183 ModelProbeOutcome::AuthOrAccess => "auth/access",
184 ModelProbeOutcome::Error => "error",
185 }
186}
187
188fn classify_model_probe_error(err_message: &str) -> ModelProbeOutcome {
189 let lower = err_message.to_lowercase();
190
191 if lower.contains("does not support live model discovery") {
192 return ModelProbeOutcome::Skipped;
193 }
194
195 if [
196 "401",
197 "403",
198 "429",
199 "unauthorized",
200 "forbidden",
201 "api key",
202 "token",
203 "insufficient balance",
204 "insufficient quota",
205 "plan does not include",
206 "rate limit",
207 ]
208 .iter()
209 .any(|hint| lower.contains(hint))
210 {
211 return ModelProbeOutcome::AuthOrAccess;
212 }
213
214 ModelProbeOutcome::Error
215}
216
217fn doctor_model_targets(config: &Config, provider_override: Option<&str>) -> Vec<String> {
218 if let Some(model_provider) = provider_override.map(str::trim).filter(|p| !p.is_empty()) {
219 return vec![model_provider.to_string()];
220 }
221
222 config
223 .providers
224 .models
225 .iter_entries()
226 .map(|(type_k, alias_k, _)| format!("{type_k}.{alias_k}"))
227 .collect()
228}
229
230fn configured_model_provider_api_key<'a>(
231 config: &'a Config,
232 provider_name: &str,
233) -> Option<&'a str> {
234 let (family, alias) = provider_name
235 .split_once('.')
236 .unwrap_or((provider_name, "default"));
237
238 config
239 .providers
240 .models
241 .find(family, alias)
242 .and_then(|entry| entry.api_key.as_deref())
243}
244
245fn create_doctor_model_provider(
246 config: &Config,
247 provider_name: &str,
248) -> anyhow::Result<Box<dyn zeroclaw_api::model_provider::ModelProvider>> {
249 let api_key = configured_model_provider_api_key(config, provider_name);
250 let options = zeroclaw_providers::options_for_provider_ref(
251 config,
252 provider_name,
253 &zeroclaw_providers::ModelProviderRuntimeOptions::default(),
254 );
255
256 match provider_name.split_once('.') {
257 Some((family, alias)) => zeroclaw_providers::create_model_provider_for_alias(
258 config, family, alias, api_key, &options,
259 ),
260 None => {
261 zeroclaw_providers::create_model_provider_with_options(provider_name, api_key, &options)
262 }
263 }
264}
265
266pub async fn run_models(
267 config: &Config,
268 provider_override: Option<&str>,
269 _use_cache: bool,
270 show_model_names: bool,
271) -> Result<()> {
272 let targets = doctor_model_targets(config, provider_override);
273
274 if targets.is_empty() {
275 anyhow::bail!(
276 "No configured model_providers to probe — run `zeroclaw quickstart` to set one up first"
277 );
278 }
279
280 println!("🩺 ZeroClaw Doctor — Model Catalog Probe");
281 println!(" Providers to probe: {}", targets.len());
282 println!();
283
284 let mut ok_count = 0usize;
285 let mut skipped_count = 0usize;
286 let mut auth_count = 0usize;
287 let mut error_count = 0usize;
288 let mut matrix_rows: Vec<(String, ModelProbeOutcome, Option<usize>, String)> = Vec::new();
289
290 for provider_name in &targets {
291 println!(" [{}]", provider_name);
292
293 let outcome = match create_doctor_model_provider(config, provider_name) {
294 Ok(handle) => handle.list_models().await,
295 Err(e) => Err(e),
296 };
297
298 match outcome {
299 Ok(models) => {
300 ok_count += 1;
301 println!(" ✅ {} models", models.len());
302 if show_model_names && !models.is_empty() {
303 for m in &models {
304 println!(" • {}", m);
305 }
306 }
307 matrix_rows.push((
308 provider_name.clone(),
309 ModelProbeOutcome::Ok,
310 Some(models.len()),
311 "catalog fetched".to_string(),
312 ));
313 }
314 Err(error) => {
315 let error_text = format_error_chain(&error);
316 match classify_model_probe_error(&error_text) {
317 ModelProbeOutcome::Skipped => {
318 skipped_count += 1;
319 println!(" ⚪ skipped: {}", truncate_for_display(&error_text, 160));
320 matrix_rows.push((
321 provider_name.clone(),
322 ModelProbeOutcome::Skipped,
323 None,
324 truncate_for_display(&error_text, 120),
325 ));
326 }
327 ModelProbeOutcome::AuthOrAccess => {
328 auth_count += 1;
329 println!(
330 " ⚠️ auth/access: {}",
331 truncate_for_display(&error_text, 160)
332 );
333 matrix_rows.push((
334 provider_name.clone(),
335 ModelProbeOutcome::AuthOrAccess,
336 None,
337 truncate_for_display(&error_text, 120),
338 ));
339 }
340 ModelProbeOutcome::Error | ModelProbeOutcome::Ok => {
341 error_count += 1;
342 println!(" ❌ error: {}", truncate_for_display(&error_text, 160));
343 matrix_rows.push((
344 provider_name.clone(),
345 ModelProbeOutcome::Error,
346 None,
347 truncate_for_display(&error_text, 120),
348 ));
349 }
350 }
351 }
352 }
353
354 println!();
355 }
356
357 println!(
358 " Summary: {} ok, {} skipped, {} auth/access, {} errors",
359 ok_count, skipped_count, auth_count, error_count
360 );
361
362 if !matrix_rows.is_empty() {
363 println!();
364 println!(" Connectivity matrix:");
365 println!(
366 " {:<18} {:<12} {:<8} detail",
367 "model_provider", "status", "models"
368 );
369 println!(
370 " {:<18} {:<12} {:<8} ------",
371 "------------------", "------------", "--------"
372 );
373 for (model_provider, outcome, models_count, detail) in matrix_rows {
374 let models_text = models_count
375 .map(|count| count.to_string())
376 .unwrap_or_else(|| "-".to_string());
377 println!(
378 " {:<18} {:<12} {:<8} {}",
379 model_provider,
380 model_probe_status_label(outcome),
381 models_text,
382 detail
383 );
384 }
385 }
386
387 if auth_count > 0 {
388 println!(
389 " 💡 Some model_providers need valid API keys/plan access before `/models` can be fetched."
390 );
391 }
392
393 if provider_override.is_some() && ok_count == 0 {
394 anyhow::bail!("Model probe failed for target model_provider")
395 }
396
397 Ok(())
398}
399
400pub fn run_traces(
401 config: &Config,
402 id: Option<&str>,
403 event_filter: Option<&str>,
404 contains: Option<&str>,
405 limit: usize,
406) -> Result<()> {
407 let path = crate::observability::runtime_trace::resolve_trace_path(
408 &config.observability,
409 &config.data_dir,
410 );
411
412 if let Some(target_id) = id.map(str::trim).filter(|value| !value.is_empty()) {
413 match crate::observability::runtime_trace::find_event_by_id(&path, target_id)? {
414 Some(event) => {
415 println!("{}", serde_json::to_string_pretty(&event)?);
416 }
417 None => {
418 println!(
419 "No runtime trace event found for id '{}' (path: {}).",
420 target_id,
421 path.display()
422 );
423 }
424 }
425 return Ok(());
426 }
427
428 if !path.exists() {
429 println!(
430 "Runtime trace file not found: {}.\n\
431 Enable [observability] log_persistence = \"rolling\" or \"full\", then reproduce the issue.",
432 path.display()
433 );
434 return Ok(());
435 }
436
437 let safe_limit = limit.max(1);
438 let events = crate::observability::runtime_trace::load_events(
439 &path,
440 safe_limit,
441 event_filter,
442 contains,
443 )?;
444
445 if events.is_empty() {
446 println!(
447 "No runtime trace events matched query (path: {}).",
448 path.display()
449 );
450 return Ok(());
451 }
452
453 println!("Runtime traces (newest first)");
454 println!("Path: {}", path.display().to_string());
455 println!(
456 "Filters: event={} contains={} limit={}",
457 event_filter.unwrap_or("*"),
458 contains.unwrap_or("*"),
459 safe_limit
460 );
461 println!();
462
463 for event in events {
464 let outcome = match event.event.outcome.as_str() {
465 "success" => "ok",
466 "failure" => "fail",
467 _ => "-",
468 };
469 let message = event.message.unwrap_or_default();
470 let preview = truncate_for_display(&message, 80);
471 println!(
472 "- {} | {} | {} | {} | {}",
473 event.timestamp, event.id, event.event.action, outcome, preview
474 );
475 }
476
477 println!();
478 println!("Use `zeroclaw doctor traces --id <trace-id>` to inspect a full event payload.");
479 Ok(())
480}
481
482fn check_config_semantics(config: &Config, items: &mut Vec<DiagItem>) {
485 let cat = "config";
486
487 if config.config_path.exists() {
489 items.push(DiagItem::ok(
490 cat,
491 format!("config file: {}", config.config_path.display().to_string()),
492 ));
493 } else {
494 items.push(DiagItem::error(
495 cat,
496 format!(
497 "config file not found: {}",
498 config.config_path.display().to_string()
499 ),
500 ));
501 }
502
503 {
505 let mut found_any = false;
506 for (family, alias, entry) in config.providers.models.iter_entries() {
507 found_any = true;
508 let label = format!("{family}.{alias}");
509 if let Some(reason) = provider_validation_error(family) {
510 items.push(DiagItem::error(
511 cat,
512 format!("model_provider \"{label}\" is invalid: {reason}"),
513 ));
514 } else {
515 items.push(DiagItem::ok(
516 cat,
517 format!("model_provider \"{label}\" is valid"),
518 ));
519 }
520
521 if family != "ollama" {
523 if entry.api_key.as_deref().is_some() {
524 items.push(DiagItem::ok(cat, format!("{label}: API key configured")));
525 } else {
526 items.push(DiagItem::warn(
527 cat,
528 format!("{label}: no api_key set (may rely on env vars or model_provider defaults)"),
529 ));
530 }
531 }
532
533 if let Some(model) = entry.model.as_deref() {
535 items.push(DiagItem::ok(cat, format!("{label}: model: {model}")));
536 } else {
537 items.push(DiagItem::warn(cat, format!("{label}: no model configured")));
538 }
539
540 match entry.temperature {
542 Some(temperature) if (0.0..=2.0).contains(&temperature) => {
543 items.push(DiagItem::ok(
544 cat,
545 format!(
546 "{label}: temperature {temperature:.1} (valid range 0.0\u{2013}2.0)"
547 ),
548 ));
549 }
550 Some(temperature) => {
551 items.push(DiagItem::error(
552 cat,
553 format!(
554 "{label}: temperature {temperature:.1} is out of range (expected 0.0\u{2013}2.0)"
555 ),
556 ));
557 }
558 None => {
559 items.push(DiagItem::ok(
560 cat,
561 format!("{label}: temperature unset (provider default)"),
562 ));
563 }
564 }
565 }
566 if !found_any {
567 items.push(DiagItem::error(cat, "no model providers configured"));
568 }
569 }
570
571 let port = config.gateway.port;
573 if port > 0 {
574 items.push(DiagItem::ok(cat, format!("gateway port: {port}")));
575 } else {
576 items.push(DiagItem::error(cat, "gateway port is 0 (invalid)"));
577 }
578
579 for route in &config.model_routes {
581 if route.hint.is_empty() {
582 items.push(DiagItem::warn(cat, "model route with empty hint"));
583 }
584 if let Some(reason) = provider_validation_error(&route.model_provider) {
585 items.push(DiagItem::warn(
586 cat,
587 format!(
588 "model route \"{}\" uses invalid model_provider \"{}\": {}",
589 route.hint, route.model_provider, reason
590 ),
591 ));
592 }
593 if route.model.is_empty() {
594 items.push(DiagItem::warn(
595 cat,
596 format!("model route \"{}\" has empty model", route.hint),
597 ));
598 }
599 }
600
601 for route in &config.embedding_routes {
603 if route.hint.trim().is_empty() {
604 items.push(DiagItem::warn(cat, "embedding route with empty hint"));
605 }
606 if let Some(reason) = embedding_provider_validation_error(&route.model_provider) {
607 items.push(DiagItem::warn(
608 cat,
609 format!(
610 "embedding route \"{}\" uses invalid model_provider \"{}\": {}",
611 route.hint, route.model_provider, reason
612 ),
613 ));
614 }
615 if route.model.trim().is_empty() {
616 items.push(DiagItem::warn(
617 cat,
618 format!("embedding route \"{}\" has empty model", route.hint),
619 ));
620 }
621 if route.dimensions.is_some_and(|value| value == 0) {
622 items.push(DiagItem::warn(
623 cat,
624 format!(
625 "embedding route \"{}\" has invalid dimensions=0",
626 route.hint
627 ),
628 ));
629 }
630 }
631
632 if let Some(hint) = config
633 .memory
634 .embedding_model
635 .strip_prefix("hint:")
636 .map(str::trim)
637 .filter(|value| !value.is_empty())
638 && !config
639 .embedding_routes
640 .iter()
641 .any(|route| route.hint.trim() == hint)
642 {
643 items.push(DiagItem::warn(
644 cat,
645 format!(
646 "memory.embedding_model uses hint \"{hint}\" but no matching [[embedding_routes]] entry exists"
647 ),
648 ));
649 }
650
651 check_web_dist_dir(config, items);
656
657 let cc = &config.channels;
659 let has_channel = cc.channels().iter().any(|info| info.configured);
660
661 if has_channel {
662 items.push(DiagItem::ok(cat, "at least one channel configured"));
663 } else {
664 items.push(DiagItem::warn(
665 cat,
666 "no channels configured — run `zeroclaw quickstart` to set one up",
667 ));
668 }
669
670 let mut agent_names: Vec<_> = config.agents.keys().collect();
672 agent_names.sort();
673 for name in agent_names {
674 let agent = config.agents.get(name).unwrap();
675 let provider_type = agent
676 .model_provider
677 .split_once('.')
678 .map_or(agent.model_provider.as_str(), |(t, _)| t);
679 if provider_type.is_empty() {
680 continue;
681 }
682 if let Some(reason) = provider_validation_error(provider_type) {
683 items.push(DiagItem::warn(
684 cat,
685 format!(
686 "agent \"{name}\" uses invalid model_provider \"{provider_type}\": {reason}",
687 ),
688 ));
689 }
690 }
691}
692
693fn check_web_dist_dir(config: &Config, items: &mut Vec<DiagItem>) {
706 let cat = "config";
707 match config.gateway.web_dist_dir.as_deref() {
708 None => {}
709 Some(value) => match web_dist_dir_expansion_reason_key(value) {
710 None => {}
711 Some(reason_key) => {
712 let reason = crate::i18n::get_required_cli_string(reason_key);
713 let message = crate::i18n::get_required_cli_string_with_args(
714 "cli-doctor-web-dist-dir-expansion-warning",
715 &[("path", value), ("reason", reason.as_str())],
716 );
717 items.push(DiagItem::warn(cat, message));
718 }
719 },
720 }
721}
722
723fn web_dist_dir_expansion_reason_key(value: &str) -> Option<&'static str> {
727 if value.starts_with('~') {
728 Some("cli-web-dist-dir-reason-tilde")
729 } else if value.contains('$') {
730 Some("cli-web-dist-dir-reason-dollar")
731 } else {
732 None
733 }
734}
735
736fn provider_validation_error(name: &str) -> Option<String> {
737 match zeroclaw_providers::create_model_provider(name, None) {
738 Ok(_) => None,
739 Err(err) => Some(
740 err.to_string()
741 .lines()
742 .next()
743 .unwrap_or("invalid model_provider")
744 .into(),
745 ),
746 }
747}
748
749fn embedding_provider_validation_error(name: &str) -> Option<String> {
750 let normalized = name.trim();
751 if normalized.eq_ignore_ascii_case("none") || normalized.eq_ignore_ascii_case("openai") {
752 return None;
753 }
754
755 let Some(url) = normalized.strip_prefix("custom:") else {
756 return Some("supported values: none, openai, custom:<url>".into());
757 };
758
759 let url = url.trim();
760 if url.is_empty() {
761 return Some("custom model_provider requires a non-empty URL after 'custom:'".into());
762 }
763
764 match reqwest::Url::parse(url) {
765 Ok(parsed) if matches!(parsed.scheme(), "http" | "https") => None,
766 Ok(parsed) => Some(format!(
767 "custom model_provider URL must use http/https, got '{}'",
768 parsed.scheme()
769 )),
770 Err(err) => Some(format!("invalid custom model_provider URL: {err}")),
771 }
772}
773
774fn check_workspace(config: &Config, items: &mut Vec<DiagItem>) {
777 let cat = "workspace";
778 let ws = &config.data_dir;
779
780 if ws.exists() {
781 items.push(DiagItem::ok(
782 cat,
783 format!("directory exists: {}", ws.display().to_string()),
784 ));
785 } else {
786 items.push(DiagItem::error(
787 cat,
788 format!("directory missing: {}", ws.display().to_string()),
789 ));
790 return;
791 }
792
793 let probe = workspace_probe_path(ws);
795 match std::fs::OpenOptions::new()
796 .write(true)
797 .create_new(true)
798 .open(&probe)
799 {
800 Ok(mut probe_file) => {
801 let write_result = probe_file.write_all(b"probe");
802 drop(probe_file);
803 let _ = std::fs::remove_file(&probe);
804 match write_result {
805 Ok(()) => items.push(DiagItem::ok(cat, "directory is writable")),
806 Err(e) => items.push(DiagItem::error(
807 cat,
808 format!("directory write probe failed: {e}"),
809 )),
810 }
811 }
812 Err(e) => {
813 items.push(DiagItem::error(
814 cat,
815 format!("directory is not writable: {e}"),
816 ));
817 }
818 }
819
820 if let Some(avail_mb) = disk_available_mb(ws) {
822 if avail_mb >= 100 {
823 items.push(DiagItem::ok(
824 cat,
825 format!("disk space: {avail_mb} MB available"),
826 ));
827 } else {
828 items.push(DiagItem::warn(
829 cat,
830 format!("low disk space: only {avail_mb} MB available"),
831 ));
832 }
833 }
834
835 check_file_exists(ws, "SOUL.md", false, cat, items);
837 check_file_exists(ws, "AGENTS.md", false, cat, items);
838}
839
840fn check_file_exists(
841 base: &Path,
842 name: &str,
843 required: bool,
844 cat: &'static str,
845 items: &mut Vec<DiagItem>,
846) {
847 let path = base.join(name);
848 if path.is_file() {
849 items.push(DiagItem::ok(cat, format!("{name} present")));
850 } else if required {
851 items.push(DiagItem::error(cat, format!("{name} missing")));
852 } else {
853 items.push(DiagItem::warn(cat, format!("{name} not found (optional)")));
854 }
855}
856
857fn disk_available_mb(path: &Path) -> Option<u64> {
858 let output = std::process::Command::new("df")
859 .arg("-m")
860 .arg(path)
861 .output()
862 .ok()?;
863 if !output.status.success() {
864 return None;
865 }
866 let stdout = String::from_utf8_lossy(&output.stdout);
867 parse_df_available_mb(&stdout)
868}
869
870fn parse_df_available_mb(stdout: &str) -> Option<u64> {
871 let line = stdout.lines().rev().find(|line| !line.trim().is_empty())?;
872 let avail = line.split_whitespace().nth(3)?;
873 avail.parse::<u64>().ok()
874}
875
876fn workspace_probe_path(workspace_dir: &Path) -> std::path::PathBuf {
877 let nanos = std::time::SystemTime::now()
878 .duration_since(std::time::UNIX_EPOCH)
879 .map_or(0, |duration| duration.as_nanos());
880 workspace_dir.join(format!(
881 ".zeroclaw_doctor_probe_{}_{}",
882 std::process::id(),
883 nanos
884 ))
885}
886
887fn check_daemon_state(config: &Config, items: &mut Vec<DiagItem>) {
890 let cat = "daemon";
891 let state_file = crate::daemon::state_file_path(config);
892
893 if !state_file.exists() {
894 items.push(DiagItem::error(
895 cat,
896 format!(
897 "state file not found: {} — is the daemon running?",
898 state_file.display()
899 ),
900 ));
901 return;
902 }
903
904 let raw = match std::fs::read_to_string(&state_file) {
905 Ok(r) => r,
906 Err(e) => {
907 items.push(DiagItem::error(cat, format!("cannot read state file: {e}")));
908 return;
909 }
910 };
911
912 let snapshot: serde_json::Value = match serde_json::from_str(&raw) {
913 Ok(v) => v,
914 Err(e) => {
915 items.push(DiagItem::error(cat, format!("invalid state JSON: {e}")));
916 return;
917 }
918 };
919
920 let updated_at = snapshot
922 .get("updated_at")
923 .and_then(serde_json::Value::as_str)
924 .unwrap_or("");
925
926 if let Ok(ts) = DateTime::parse_from_rfc3339(updated_at) {
927 let age = Utc::now()
928 .signed_duration_since(ts.with_timezone(&Utc))
929 .num_seconds();
930 if age <= DAEMON_STALE_SECONDS {
931 items.push(DiagItem::ok(cat, format!("heartbeat fresh ({age}s ago)")));
932 } else {
933 items.push(DiagItem::error(
934 cat,
935 format!("heartbeat stale ({age}s ago)"),
936 ));
937 }
938 } else {
939 items.push(DiagItem::error(
940 cat,
941 format!("invalid daemon timestamp: {updated_at}"),
942 ));
943 }
944
945 if let Some(components) = snapshot
947 .get("components")
948 .and_then(serde_json::Value::as_object)
949 {
950 if let Some(scheduler) = components.get("scheduler") {
952 let scheduler_ok = scheduler
953 .get("status")
954 .and_then(serde_json::Value::as_str)
955 .is_some_and(|s| s == "ok");
956 let scheduler_age = scheduler
957 .get("last_ok")
958 .and_then(serde_json::Value::as_str)
959 .and_then(parse_rfc3339)
960 .map_or(i64::MAX, |dt| {
961 Utc::now().signed_duration_since(dt).num_seconds()
962 });
963
964 if scheduler_ok && scheduler_age <= SCHEDULER_STALE_SECONDS {
965 items.push(DiagItem::ok(
966 cat,
967 format!("scheduler healthy (last ok {scheduler_age}s ago)"),
968 ));
969 } else {
970 items.push(DiagItem::error(
971 cat,
972 format!("scheduler unhealthy (ok={scheduler_ok}, age={scheduler_age}s)"),
973 ));
974 }
975 } else {
976 items.push(DiagItem::warn(cat, "scheduler component not tracked yet"));
977 }
978
979 let mut channel_count = 0u32;
981 let mut stale = 0u32;
982 for (name, component) in components {
983 if !name.starts_with("channel:") {
984 continue;
985 }
986 channel_count += 1;
987 let status_ok = component
988 .get("status")
989 .and_then(serde_json::Value::as_str)
990 .is_some_and(|s| s == "ok");
991 let age = component
992 .get("last_ok")
993 .and_then(serde_json::Value::as_str)
994 .and_then(parse_rfc3339)
995 .map_or(i64::MAX, |dt| {
996 Utc::now().signed_duration_since(dt).num_seconds()
997 });
998
999 if status_ok && age <= CHANNEL_STALE_SECONDS {
1000 items.push(DiagItem::ok(cat, format!("{name} fresh ({age}s ago)")));
1001 } else {
1002 stale += 1;
1003 items.push(DiagItem::error(
1004 cat,
1005 format!("{name} stale (ok={status_ok}, age={age}s)"),
1006 ));
1007 }
1008 }
1009
1010 if channel_count == 0 {
1011 items.push(DiagItem::warn(cat, "no channel components tracked yet"));
1012 } else if stale > 0 {
1013 items.push(DiagItem::warn(
1014 cat,
1015 format!("{channel_count} channels, {stale} stale"),
1016 ));
1017 }
1018 }
1019}
1020
1021fn check_environment(items: &mut Vec<DiagItem>) {
1024 let cat = "environment";
1025
1026 check_command_available("git", &["--version"], cat, items);
1028
1029 let shell = std::env::var("SHELL")
1031 .ok()
1032 .filter(|s| !s.is_empty())
1033 .or_else(|| std::env::var("ComSpec").ok().filter(|s| !s.is_empty()));
1034 match shell {
1035 Some(s) => items.push(DiagItem::ok(cat, format!("shell: {s}"))),
1036 None => items.push(DiagItem::warn(cat, "neither $SHELL nor %ComSpec% is set")),
1037 }
1038
1039 if std::env::var("HOME").is_ok() || std::env::var("USERPROFILE").is_ok() {
1041 items.push(DiagItem::ok(cat, "home directory env set"));
1042 } else {
1043 items.push(DiagItem::error(
1044 cat,
1045 "neither $HOME nor $USERPROFILE is set",
1046 ));
1047 }
1048
1049 check_command_available("curl", &["--version"], cat, items);
1051}
1052
1053fn check_cli_tools(items: &mut Vec<DiagItem>) {
1054 let cat = "cli-tools";
1055
1056 let discovered = crate::tools::discover_cli_tools(&[], &[]);
1057
1058 if discovered.is_empty() {
1059 items.push(DiagItem::warn(cat, "No CLI tools found in PATH"));
1060 } else {
1061 for cli in &discovered {
1062 let version_info = cli
1063 .version
1064 .as_deref()
1065 .map(|v| truncate_for_display(v, COMMAND_VERSION_PREVIEW_CHARS))
1066 .unwrap_or_else(|| "unknown version".to_string());
1067 items.push(DiagItem::ok(
1068 cat,
1069 format!("{} ({}) — {}", cli.name, cli.category, version_info),
1070 ));
1071 }
1072 items.push(DiagItem::ok(
1073 cat,
1074 format!("{} CLI tools discovered", discovered.len()),
1075 ));
1076 }
1077}
1078
1079fn check_command_available(cmd: &str, args: &[&str], cat: &'static str, items: &mut Vec<DiagItem>) {
1080 match std::process::Command::new(cmd)
1081 .args(args)
1082 .stdout(std::process::Stdio::piped())
1083 .stderr(std::process::Stdio::piped())
1084 .output()
1085 {
1086 Ok(output) if output.status.success() => {
1087 let ver = String::from_utf8_lossy(&output.stdout);
1088 let first_line = ver.lines().next().unwrap_or("").trim();
1089 let display = truncate_for_display(first_line, COMMAND_VERSION_PREVIEW_CHARS);
1090 items.push(DiagItem::ok(cat, format!("{cmd}: {display}")));
1091 }
1092 Ok(_) => {
1093 items.push(DiagItem::warn(
1094 cat,
1095 format!("{cmd} found but returned non-zero"),
1096 ));
1097 }
1098 Err(_) => {
1099 items.push(DiagItem::warn(cat, format!("{cmd} not found in PATH")));
1100 }
1101 }
1102}
1103
1104fn format_error_chain(error: &anyhow::Error) -> String {
1105 let mut parts = Vec::new();
1106 for cause in error.chain() {
1107 let message = cause.to_string();
1108 if !message.is_empty() {
1109 parts.push(message);
1110 }
1111 }
1112
1113 if parts.is_empty() {
1114 return String::new();
1115 }
1116
1117 parts.join(": ")
1118}
1119
1120fn truncate_for_display(input: &str, max_chars: usize) -> String {
1121 let mut chars = input.chars();
1122 let preview: String = chars.by_ref().take(max_chars).collect();
1123 if chars.next().is_some() {
1124 format!("{preview}…")
1125 } else {
1126 preview
1127 }
1128}
1129
1130fn parse_rfc3339(raw: &str) -> Option<DateTime<Utc>> {
1133 DateTime::parse_from_rfc3339(raw)
1134 .ok()
1135 .map(|dt| dt.with_timezone(&Utc))
1136}
1137
1138#[cfg(test)]
1139mod tests {
1140 use super::*;
1141 use tempfile::TempDir;
1142
1143 #[test]
1144 fn provider_validation_checks_custom_url_shape() {
1145 assert!(provider_validation_error("openrouter").is_none());
1146 assert!(provider_validation_error("custom:https://example.com").is_none());
1147 assert!(provider_validation_error("anthropic-custom:https://example.com").is_none());
1148
1149 let invalid_custom = provider_validation_error("custom:").unwrap_or_default();
1150 assert!(invalid_custom.contains("requires a URL"));
1151
1152 let invalid_unknown = provider_validation_error("totally-fake").unwrap_or_default();
1153 assert!(invalid_unknown.contains("Unknown model_provider"));
1154 }
1155
1156 #[test]
1157 fn diag_item_icons() {
1158 assert_eq!(DiagItem::ok("t", "m").icon(), "✅");
1159 assert_eq!(DiagItem::warn("t", "m").icon(), "⚠️ ");
1160 assert_eq!(DiagItem::error("t", "m").icon(), "❌");
1161 }
1162
1163 #[test]
1164 fn config_validation_catches_bad_temperature() {
1165 let mut config = Config::default();
1170 config
1171 .providers
1172 .models
1173 .ensure("openrouter", "default")
1174 .expect("known model_provider type")
1175 .temperature = Some(5.0);
1176 let mut items = Vec::new();
1177 check_config_semantics(&config, &mut items);
1178 let temp_item = items.iter().find(|i| i.message.contains("temperature"));
1179 assert!(temp_item.is_some());
1180 assert_eq!(temp_item.unwrap().severity, Severity::Error);
1181 }
1182
1183 #[test]
1184 fn config_validation_accepts_valid_temperature() {
1185 let mut config = Config::default();
1186 config
1187 .providers
1188 .models
1189 .ensure("openrouter", "default")
1190 .expect("known model_provider type")
1191 .temperature = Some(0.7);
1192 let mut items = Vec::new();
1193 check_config_semantics(&config, &mut items);
1194 let temp_item = items.iter().find(|i| i.message.contains("temperature"));
1195 assert!(temp_item.is_some());
1196 assert_eq!(temp_item.unwrap().severity, Severity::Ok);
1197 }
1198
1199 #[test]
1200 fn config_validation_warns_no_channels() {
1201 let config = Config::default();
1202 let mut items = Vec::new();
1203 check_config_semantics(&config, &mut items);
1204 let ch_item = items.iter().find(|i| i.message.contains("channel"));
1205 assert!(ch_item.is_some());
1206 assert_eq!(ch_item.unwrap().severity, Severity::Warn);
1207 }
1208
1209 #[test]
1210 fn configured_model_provider_api_key_uses_alias_profile() {
1211 let mut config = Config::default();
1212 config
1213 .providers
1214 .models
1215 .ensure("custom", "local")
1216 .expect("known model_provider type")
1217 .api_key = Some("redacted-test-key".to_string());
1218
1219 assert_eq!(
1220 configured_model_provider_api_key(&config, "custom.local"),
1221 Some("redacted-test-key")
1222 );
1223 assert_eq!(configured_model_provider_api_key(&config, "custom"), None);
1224 }
1225
1226 #[test]
1227 fn doctor_model_provider_uses_alias_profile() {
1228 let mut config = Config::default();
1229 let profile = config
1230 .providers
1231 .models
1232 .ensure("custom", "local")
1233 .expect("known model_provider type");
1234 profile.api_key = Some("redacted-test-key".to_string());
1235 profile.uri = Some("https://models.example.test/v1".to_string());
1236
1237 if let Err(error) = create_doctor_model_provider(&config, "custom.local") {
1238 panic!("doctor model probe should build custom providers from alias config: {error}");
1239 }
1240 }
1241
1242 #[test]
1243 fn config_validation_catches_unknown_provider() {
1244 let mut config = Config::default();
1249 config.agents.insert(
1250 "broken".to_string(),
1251 zeroclaw_config::schema::AliasedAgentConfig {
1252 model_provider: "totally-fake.default".into(),
1253 risk_profile: "default".to_string(),
1254 ..Default::default()
1255 },
1256 );
1257 let mut items = Vec::new();
1258 check_config_semantics(&config, &mut items);
1259 let prov_item = items.iter().find(|i| {
1260 i.message
1261 .contains("agent \"broken\" uses invalid model_provider \"totally-fake\"")
1262 });
1263 assert!(
1264 prov_item.is_some(),
1265 "doctor should flag unknown agent model_provider"
1266 );
1267 assert_eq!(prov_item.unwrap().severity, Severity::Warn);
1268 }
1269
1270 #[test]
1278 fn config_validation_warns_empty_model_route() {
1279 let config = Config {
1280 model_routes: vec![zeroclaw_config::schema::ModelRouteConfig {
1281 hint: "fast".into(),
1282 model_provider: "groq".into(),
1283 model: String::new(),
1284 api_key: None,
1285 }],
1286 ..Config::default()
1287 };
1288 let mut items = Vec::new();
1289 check_config_semantics(&config, &mut items);
1290 let route_item = items.iter().find(|i| i.message.contains("empty model"));
1291 assert!(route_item.is_some());
1292 assert_eq!(route_item.unwrap().severity, Severity::Warn);
1293 }
1294
1295 #[test]
1296 fn config_validation_warns_empty_embedding_route_model() {
1297 let config = Config {
1298 embedding_routes: vec![zeroclaw_config::schema::EmbeddingRouteConfig {
1299 hint: "semantic".into(),
1300 model_provider: "openai".into(),
1301 model: String::new(),
1302 dimensions: Some(1536),
1303 api_key: None,
1304 }],
1305 ..Config::default()
1306 };
1307
1308 let mut items = Vec::new();
1309 check_config_semantics(&config, &mut items);
1310 let route_item = items.iter().find(|item| {
1311 item.message
1312 .contains("embedding route \"semantic\" has empty model")
1313 });
1314 assert!(route_item.is_some());
1315 assert_eq!(route_item.unwrap().severity, Severity::Warn);
1316 }
1317
1318 #[test]
1319 fn config_validation_warns_invalid_embedding_route_provider() {
1320 let config = Config {
1321 embedding_routes: vec![zeroclaw_config::schema::EmbeddingRouteConfig {
1322 hint: "semantic".into(),
1323 model_provider: "groq".into(),
1324 model: "text-embedding-3-small".into(),
1325 dimensions: None,
1326 api_key: None,
1327 }],
1328 ..Config::default()
1329 };
1330
1331 let mut items = Vec::new();
1332 check_config_semantics(&config, &mut items);
1333 let route_item = items.iter().find(|item| {
1334 item.message
1335 .contains("uses invalid model_provider \"groq\"")
1336 });
1337 assert!(route_item.is_some());
1338 assert_eq!(route_item.unwrap().severity, Severity::Warn);
1339 }
1340
1341 #[test]
1342 fn config_validation_warns_missing_embedding_hint_target() {
1343 let mut config = Config::default();
1344 config.memory.embedding_model = "hint:semantic".into();
1345
1346 let mut items = Vec::new();
1347 check_config_semantics(&config, &mut items);
1348 let route_item = items.iter().find(|item| {
1349 item.message
1350 .contains("no matching [[embedding_routes]] entry exists")
1351 });
1352 assert!(route_item.is_some());
1353 assert_eq!(route_item.unwrap().severity, Severity::Warn);
1354 }
1355
1356 #[test]
1357 fn environment_check_finds_git() {
1358 let mut items = Vec::new();
1359 check_environment(&mut items);
1360 let git_item = items.iter().find(|i| i.message.starts_with("git:"));
1361 assert!(git_item.is_some());
1363 assert_eq!(git_item.unwrap().severity, Severity::Ok);
1364 }
1365
1366 #[test]
1367 fn parse_df_available_mb_uses_last_data_line() {
1368 let stdout =
1369 "Filesystem 1M-blocks Used Available Use% Mounted on\n/dev/sda1 1000 500 500 50% /\n";
1370 assert_eq!(parse_df_available_mb(stdout), Some(500));
1371 }
1372
1373 #[test]
1374 fn truncate_for_display_preserves_utf8_boundaries() {
1375 let preview = truncate_for_display("🙂example-alpha-build", 3);
1376 assert_eq!(preview, "🙂ex…");
1377 }
1378
1379 #[test]
1380 fn workspace_probe_path_is_hidden_and_unique() {
1381 let tmp = TempDir::new().unwrap();
1382 let first = workspace_probe_path(tmp.path());
1383 let second = workspace_probe_path(tmp.path());
1384
1385 assert_ne!(first, second);
1386 assert!(
1387 first
1388 .file_name()
1389 .and_then(|name| name.to_str())
1390 .is_some_and(|name| name.starts_with(".zeroclaw_doctor_probe_"))
1391 );
1392 }
1393
1394 #[test]
1395 fn diagnose_flags_web_dist_dir_with_tilde() {
1396 let mut config = Config::default();
1400 config.gateway.web_dist_dir = Some("~/web-dist".to_string());
1401
1402 let expected_reason = crate::i18n::get_required_cli_string("cli-web-dist-dir-reason-tilde");
1403 let expected_message = crate::i18n::get_required_cli_string_with_args(
1404 "cli-doctor-web-dist-dir-expansion-warning",
1405 &[("path", "~/web-dist"), ("reason", expected_reason.as_str())],
1406 );
1407
1408 let results = diagnose(&config);
1409 let hit = results
1410 .iter()
1411 .find(|item| item.category == "config" && item.message == expected_message);
1412 assert!(
1413 hit.is_some(),
1414 "doctor should flag web_dist_dir = \"~/web-dist\" with the localized warning; \
1415 expected message: {expected_message:?}; got: {results:?}"
1416 );
1417 assert_eq!(hit.unwrap().severity, Severity::Warn);
1418 }
1419
1420 #[test]
1421 fn diagnose_flags_web_dist_dir_with_env_var() {
1422 let mut config = Config::default();
1423 config.gateway.web_dist_dir = Some("$HOME/web-dist".to_string());
1424
1425 let expected_reason =
1426 crate::i18n::get_required_cli_string("cli-web-dist-dir-reason-dollar");
1427 let expected_message = crate::i18n::get_required_cli_string_with_args(
1428 "cli-doctor-web-dist-dir-expansion-warning",
1429 &[
1430 ("path", "$HOME/web-dist"),
1431 ("reason", expected_reason.as_str()),
1432 ],
1433 );
1434
1435 let results = diagnose(&config);
1436 let hit = results
1437 .iter()
1438 .find(|item| item.category == "config" && item.message == expected_message);
1439 assert!(hit.is_some());
1440 assert_eq!(hit.unwrap().severity, Severity::Warn);
1441 }
1442
1443 #[test]
1444 fn diagnose_accepts_literal_web_dist_dir() {
1445 let mut config = Config::default();
1446 config.gateway.web_dist_dir = Some("/srv/zeroclaw/web-dist".to_string());
1447
1448 let results = diagnose(&config);
1449 assert!(
1450 !results
1451 .iter()
1452 .any(|item| item.message.contains("gateway.web_dist_dir")),
1453 "literal web_dist_dir paths should produce no doctor diagnostic"
1454 );
1455 }
1456
1457 #[test]
1458 fn web_dist_dir_expansion_reason_key_detects_tilde_and_env() {
1459 assert_eq!(
1460 web_dist_dir_expansion_reason_key("~/web-dist"),
1461 Some("cli-web-dist-dir-reason-tilde")
1462 );
1463 assert_eq!(
1464 web_dist_dir_expansion_reason_key("$HOME/web-dist"),
1465 Some("cli-web-dist-dir-reason-dollar")
1466 );
1467 assert_eq!(
1468 web_dist_dir_expansion_reason_key("${HOME}/web-dist"),
1469 Some("cli-web-dist-dir-reason-dollar")
1470 );
1471 assert!(web_dist_dir_expansion_reason_key("/srv/zeroclaw/web-dist").is_none());
1472 assert!(web_dist_dir_expansion_reason_key("./dist").is_none());
1473 }
1474
1475 #[test]
1476 fn config_validation_reports_delegate_agents_in_sorted_order() {
1477 let mut config = Config::default();
1478 config.agents.insert(
1479 "zeta".into(),
1480 zeroclaw_config::schema::AliasedAgentConfig {
1481 model_provider: "totally-fake.default".into(),
1482 ..Default::default()
1483 },
1484 );
1485 config.agents.insert(
1486 "alpha".into(),
1487 zeroclaw_config::schema::AliasedAgentConfig {
1488 model_provider: "totally-fake.default".into(),
1489 ..Default::default()
1490 },
1491 );
1492
1493 let mut items = Vec::new();
1494 check_config_semantics(&config, &mut items);
1495
1496 let agent_messages: Vec<_> = items
1497 .iter()
1498 .filter(|item| item.message.starts_with("agent \""))
1499 .map(|item| item.message.as_str())
1500 .collect();
1501
1502 assert_eq!(agent_messages.len(), 2);
1503 assert!(agent_messages[0].contains("agent \"alpha\""));
1504 assert!(agent_messages[1].contains("agent \"zeta\""));
1505 }
1506}