1use std::fs;
2use std::io::ErrorKind;
3use std::path::{Path, PathBuf};
4
5use serde::{Deserialize, Serialize};
6use serde_json::Value;
7
8use super::setup::{
9 OPENCLAW_DEFAULT_BASE_URL, load_relay_runtime_config, openclaw_agent_name_path,
10 read_selected_openclaw_agent, resolve_connector_base_url, resolve_openclaw_hook_token,
11};
12use crate::constants::{AGENTS_DIR, AIT_FILE_NAME, SECRET_KEY_FILE_NAME};
13use crate::db::SqliteStore;
14use crate::error::{CoreError, Result};
15use crate::http::blocking_client;
16use crate::peers::load_peers_config;
17
18const RELAY_TRANSFORM_MODULE_RELATIVE_PATH: &str = "hooks/transforms/relay-to-peer.mjs";
19const OPENCLAW_PENDING_DEVICES_RELATIVE_PATH: &str = "devices/pending.json";
20const STATUS_PATH: &str = "/v1/status";
21
22#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
23#[serde(rename_all = "lowercase")]
24pub enum DoctorCheckStatus {
25 Pass,
26 Fail,
27}
28
29#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
30#[serde(rename_all = "lowercase")]
31pub enum DoctorStatus {
32 Healthy,
33 Unhealthy,
34}
35
36#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
37#[serde(rename_all = "camelCase")]
38pub struct OpenclawDoctorCheck {
39 pub id: String,
40 pub label: String,
41 pub status: DoctorCheckStatus,
42 pub message: String,
43 #[serde(skip_serializing_if = "Option::is_none")]
44 pub remediation_hint: Option<String>,
45 #[serde(skip_serializing_if = "Option::is_none")]
46 pub details: Option<Value>,
47}
48
49#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
50#[serde(rename_all = "camelCase")]
51pub struct OpenclawDoctorResult {
52 pub status: DoctorStatus,
53 pub checks: Vec<OpenclawDoctorCheck>,
54}
55
56#[derive(Debug, Clone, Default)]
57pub struct OpenclawDoctorOptions {
58 pub home_dir: Option<PathBuf>,
59 pub openclaw_dir: Option<PathBuf>,
60 pub selected_agent: Option<String>,
61 pub peer_alias: Option<String>,
62 pub connector_base_url: Option<String>,
63 pub include_connector_runtime_check: bool,
64}
65
66fn push_check(
67 checks: &mut Vec<OpenclawDoctorCheck>,
68 id: &str,
69 label: &str,
70 status: DoctorCheckStatus,
71 message: impl Into<String>,
72 remediation_hint: Option<&str>,
73 details: Option<Value>,
74) {
75 checks.push(OpenclawDoctorCheck {
76 id: id.to_string(),
77 label: label.to_string(),
78 status,
79 message: message.into(),
80 remediation_hint: remediation_hint.map(ToOwned::to_owned),
81 details,
82 });
83}
84
85fn resolve_openclaw_dir(home_dir: Option<&Path>, override_dir: Option<&Path>) -> Result<PathBuf> {
86 if let Some(path) = override_dir {
87 return Ok(path.to_path_buf());
88 }
89
90 if let Ok(path) = std::env::var("OPENCLAW_STATE_DIR") {
91 let trimmed = path.trim();
92 if !trimmed.is_empty() {
93 return Ok(PathBuf::from(trimmed));
94 }
95 }
96
97 if let Ok(path) = std::env::var("OPENCLAW_CONFIG_PATH") {
98 let trimmed = path.trim();
99 if !trimmed.is_empty() {
100 let path = PathBuf::from(trimmed);
101 return Ok(path.parent().map(Path::to_path_buf).unwrap_or(path));
102 }
103 }
104
105 let home = if let Some(home_dir) = home_dir {
106 home_dir.to_path_buf()
107 } else {
108 dirs::home_dir().ok_or(CoreError::HomeDirectoryUnavailable)?
109 };
110 Ok(home.join(".openclaw"))
111}
112
113fn read_non_empty_file(path: &Path) -> Result<bool> {
114 let content = fs::read_to_string(path).map_err(|source| CoreError::Io {
115 path: path.to_path_buf(),
116 source,
117 })?;
118 Ok(!content.trim().is_empty())
119}
120
121fn get_status_url(base_url: &str) -> Result<String> {
122 let normalized = if base_url.ends_with('/') {
123 base_url.to_string()
124 } else {
125 format!("{base_url}/")
126 };
127 let joined = url::Url::parse(&normalized)
128 .map_err(|_| CoreError::InvalidUrl {
129 context: "connectorBaseUrl",
130 value: base_url.to_string(),
131 })?
132 .join(STATUS_PATH.trim_start_matches('/'))
133 .map_err(|_| CoreError::InvalidUrl {
134 context: "connectorBaseUrl",
135 value: base_url.to_string(),
136 })?;
137 Ok(joined.to_string())
138}
139
140fn parse_pending_approvals_count(path: &Path) -> Result<usize> {
141 let raw = match fs::read_to_string(path) {
142 Ok(raw) => raw,
143 Err(error) if error.kind() == ErrorKind::NotFound => return Ok(0),
144 Err(source) => {
145 return Err(CoreError::Io {
146 path: path.to_path_buf(),
147 source,
148 });
149 }
150 };
151 let payload: Value = serde_json::from_str(&raw).map_err(|source| CoreError::JsonParse {
152 path: path.to_path_buf(),
153 source,
154 })?;
155
156 if let Some(array) = payload.as_array() {
157 return Ok(array.len());
158 }
159 if let Some(array) = payload.get("requests").and_then(|value| value.as_array()) {
160 return Ok(array.len());
161 }
162 Ok(0)
163}
164
165#[allow(clippy::too_many_lines)]
166fn run_connector_checks(
167 checks: &mut Vec<OpenclawDoctorCheck>,
168 config_dir: &Path,
169 selected_agent: Option<&str>,
170 connector_base_url: Option<&str>,
171) -> Result<()> {
172 let resolved_base_url =
173 resolve_connector_base_url(config_dir, selected_agent, connector_base_url)?;
174 let Some(base_url) = resolved_base_url else {
175 push_check(
176 checks,
177 "state.connectorRuntime",
178 "Connector runtime",
179 DoctorCheckStatus::Fail,
180 "connector runtime assignment is missing for selected agent",
181 Some("Run `openclaw setup <agentName>` or pass `--connector-base-url`."),
182 None,
183 );
184 push_check(
185 checks,
186 "state.connectorInboundInbox",
187 "Connector inbound inbox",
188 DoctorCheckStatus::Fail,
189 "cannot validate connector inbox without connector assignment",
190 Some("Run `openclaw setup <agentName>` or pass `--connector-base-url`."),
191 None,
192 );
193 push_check(
194 checks,
195 "state.openclawHookHealth",
196 "OpenClaw hook health",
197 DoctorCheckStatus::Fail,
198 "cannot validate OpenClaw hook health without connector runtime",
199 Some("Run `openclaw setup <agentName>` and restart connector runtime."),
200 None,
201 );
202 return Ok(());
203 };
204
205 let status_url = get_status_url(&base_url)?;
206 let response = blocking_client()?
207 .get(&status_url)
208 .header("accept", "application/json")
209 .send();
210
211 let response = match response {
212 Ok(response) => response,
213 Err(error) => {
214 push_check(
215 checks,
216 "state.connectorRuntime",
217 "Connector runtime",
218 DoctorCheckStatus::Fail,
219 format!("connector status request failed: {error}"),
220 Some("Ensure connector runtime is running and reachable."),
221 Some(serde_json::json!({ "connectorBaseUrl": base_url, "statusUrl": status_url })),
222 );
223 push_check(
224 checks,
225 "state.connectorInboundInbox",
226 "Connector inbound inbox",
227 DoctorCheckStatus::Fail,
228 "cannot read connector inbound inbox status",
229 Some("Start connector runtime and retry."),
230 Some(serde_json::json!({ "connectorBaseUrl": base_url, "statusUrl": status_url })),
231 );
232 push_check(
233 checks,
234 "state.openclawHookHealth",
235 "OpenClaw hook health",
236 DoctorCheckStatus::Fail,
237 "cannot read connector OpenClaw hook status",
238 Some("Restart connector runtime and OpenClaw."),
239 Some(serde_json::json!({ "connectorBaseUrl": base_url, "statusUrl": status_url })),
240 );
241 return Ok(());
242 }
243 };
244
245 if !response.status().is_success() {
246 let status = response.status().as_u16();
247 push_check(
248 checks,
249 "state.connectorRuntime",
250 "Connector runtime",
251 DoctorCheckStatus::Fail,
252 format!("connector status returned HTTP {status}"),
253 Some("Ensure connector runtime is running and reachable."),
254 Some(serde_json::json!({ "connectorBaseUrl": base_url, "statusUrl": status_url })),
255 );
256 push_check(
257 checks,
258 "state.connectorInboundInbox",
259 "Connector inbound inbox",
260 DoctorCheckStatus::Fail,
261 "cannot read connector inbound inbox status",
262 Some("Start connector runtime and retry."),
263 Some(serde_json::json!({ "connectorBaseUrl": base_url, "statusUrl": status_url })),
264 );
265 push_check(
266 checks,
267 "state.openclawHookHealth",
268 "OpenClaw hook health",
269 DoctorCheckStatus::Fail,
270 "cannot read connector OpenClaw hook status",
271 Some("Restart connector runtime and OpenClaw."),
272 Some(serde_json::json!({ "connectorBaseUrl": base_url, "statusUrl": status_url })),
273 );
274 return Ok(());
275 }
276
277 let payload: Value = response
278 .json()
279 .map_err(|error| CoreError::Http(error.to_string()))?;
280 let websocket_connected = payload
281 .get("websocket")
282 .and_then(|value| value.get("connected"))
283 .and_then(Value::as_bool)
284 .unwrap_or(false);
285 let inbound_pending = payload
286 .get("inbound")
287 .and_then(|value| value.get("pending"))
288 .and_then(Value::as_i64)
289 .unwrap_or(0);
290 let inbound_dead_letter = payload
291 .get("inbound")
292 .and_then(|value| value.get("deadLetter"))
293 .and_then(Value::as_i64)
294 .unwrap_or(0);
295 let hook_last_attempt_status = payload
296 .get("inbound")
297 .and_then(|value| value.get("openclawHook"))
298 .and_then(|value| value.get("lastAttemptStatus"))
299 .and_then(Value::as_str);
300
301 if websocket_connected {
302 push_check(
303 checks,
304 "state.connectorRuntime",
305 "Connector runtime",
306 DoctorCheckStatus::Pass,
307 "connector websocket is connected",
308 None,
309 Some(serde_json::json!({ "connectorBaseUrl": base_url, "statusUrl": status_url })),
310 );
311 push_check(
312 checks,
313 "state.connectorInboundInbox",
314 "Connector inbound inbox",
315 DoctorCheckStatus::Pass,
316 format!(
317 "pending={} deadLetter={}",
318 inbound_pending, inbound_dead_letter
319 ),
320 None,
321 Some(
322 serde_json::json!({ "pendingCount": inbound_pending, "deadLetterCount": inbound_dead_letter }),
323 ),
324 );
325 let hook_failed = hook_last_attempt_status == Some("failed");
326 push_check(
327 checks,
328 "state.openclawHookHealth",
329 "OpenClaw hook health",
330 if hook_failed && inbound_pending > 0 {
331 DoctorCheckStatus::Fail
332 } else {
333 DoctorCheckStatus::Pass
334 },
335 if hook_failed && inbound_pending > 0 {
336 "connector reports failed OpenClaw hook replay with pending inbox backlog"
337 } else {
338 "OpenClaw hook replay is healthy"
339 },
340 if hook_failed && inbound_pending > 0 {
341 Some("Restart OpenClaw and connector runtime, then replay pending inbox messages.")
342 } else {
343 None
344 },
345 None,
346 );
347 } else {
348 push_check(
349 checks,
350 "state.connectorRuntime",
351 "Connector runtime",
352 DoctorCheckStatus::Fail,
353 "connector websocket is disconnected",
354 Some("Run `connector start <agentName>` or `connector service install <agentName>`."),
355 Some(serde_json::json!({ "connectorBaseUrl": base_url, "statusUrl": status_url })),
356 );
357 push_check(
358 checks,
359 "state.connectorInboundInbox",
360 "Connector inbound inbox",
361 DoctorCheckStatus::Fail,
362 "connector websocket is disconnected; inbox status may be stale",
363 Some("Start connector runtime and retry."),
364 Some(
365 serde_json::json!({ "pendingCount": inbound_pending, "deadLetterCount": inbound_dead_letter }),
366 ),
367 );
368 push_check(
369 checks,
370 "state.openclawHookHealth",
371 "OpenClaw hook health",
372 DoctorCheckStatus::Fail,
373 "connector websocket is disconnected; hook replay is unavailable",
374 Some("Restart connector runtime and OpenClaw."),
375 None,
376 );
377 }
378
379 Ok(())
380}
381
382#[allow(clippy::too_many_lines)]
384pub fn run_openclaw_doctor(
385 config_dir: &Path,
386 store: &SqliteStore,
387 options: OpenclawDoctorOptions,
388) -> Result<OpenclawDoctorResult> {
389 let openclaw_dir =
390 resolve_openclaw_dir(options.home_dir.as_deref(), options.openclaw_dir.as_deref())?;
391 let mut checks = Vec::<OpenclawDoctorCheck>::new();
392
393 let selected_agent = options
394 .selected_agent
395 .as_deref()
396 .map(str::trim)
397 .filter(|value| !value.is_empty())
398 .map(ToOwned::to_owned)
399 .or(read_selected_openclaw_agent(config_dir)?);
400
401 if let Some(agent_name) = selected_agent.clone() {
402 push_check(
403 &mut checks,
404 "state.selectedAgent",
405 "Selected agent",
406 DoctorCheckStatus::Pass,
407 format!("selected agent is `{agent_name}`"),
408 None,
409 Some(serde_json::json!({
410 "path": openclaw_agent_name_path(config_dir),
411 "agentName": agent_name
412 })),
413 );
414 } else {
415 push_check(
416 &mut checks,
417 "state.selectedAgent",
418 "Selected agent",
419 DoctorCheckStatus::Fail,
420 "selected agent marker is missing",
421 Some("Run `openclaw setup <agentName>` to persist selected agent."),
422 Some(serde_json::json!({ "path": openclaw_agent_name_path(config_dir) })),
423 );
424 }
425
426 if let Some(agent_name) = selected_agent.as_deref() {
427 let agent_dir = config_dir.join(AGENTS_DIR).join(agent_name);
428 let ait_path = agent_dir.join(AIT_FILE_NAME);
429 let secret_path = agent_dir.join(SECRET_KEY_FILE_NAME);
430 let credentials_ok = read_non_empty_file(&ait_path).unwrap_or(false)
431 && read_non_empty_file(&secret_path).unwrap_or(false);
432 if credentials_ok {
433 push_check(
434 &mut checks,
435 "state.credentials",
436 "Agent credentials",
437 DoctorCheckStatus::Pass,
438 "local agent credentials are present",
439 None,
440 Some(serde_json::json!({
441 "agentDir": agent_dir,
442 "ait": ait_path,
443 "secretKey": secret_path
444 })),
445 );
446 } else {
447 push_check(
448 &mut checks,
449 "state.credentials",
450 "Agent credentials",
451 DoctorCheckStatus::Fail,
452 "local agent credentials are missing or unreadable",
453 Some("Run `agent create <agentName>` and retry setup."),
454 Some(serde_json::json!({
455 "agentDir": agent_dir,
456 "ait": ait_path,
457 "secretKey": secret_path
458 })),
459 );
460 }
461 } else {
462 push_check(
463 &mut checks,
464 "state.credentials",
465 "Agent credentials",
466 DoctorCheckStatus::Fail,
467 "cannot validate credentials without selected agent",
468 Some("Run `openclaw setup <agentName>` first."),
469 None,
470 );
471 }
472
473 match load_peers_config(store) {
474 Ok(peers) => {
475 if peers.peers.is_empty() {
476 push_check(
477 &mut checks,
478 "state.peers",
479 "Paired peers",
480 DoctorCheckStatus::Fail,
481 "no paired peers found",
482 Some("Run `pair start`/`pair confirm` before relay checks."),
483 None,
484 );
485 } else if let Some(peer_alias) = options.peer_alias.as_deref().map(str::trim) {
486 if peers.peers.contains_key(peer_alias) {
487 push_check(
488 &mut checks,
489 "state.peers",
490 "Paired peers",
491 DoctorCheckStatus::Pass,
492 format!("peer alias `{peer_alias}` is configured"),
493 None,
494 Some(serde_json::json!({ "peerCount": peers.peers.len() })),
495 );
496 } else {
497 push_check(
498 &mut checks,
499 "state.peers",
500 "Paired peers",
501 DoctorCheckStatus::Fail,
502 format!("peer alias `{peer_alias}` is not configured"),
503 Some("Choose an existing peer alias from peers.json."),
504 Some(
505 serde_json::json!({ "peerAliases": peers.peers.keys().collect::<Vec<_>>() }),
506 ),
507 );
508 }
509 } else {
510 push_check(
511 &mut checks,
512 "state.peers",
513 "Paired peers",
514 DoctorCheckStatus::Pass,
515 format!("{} paired peer(s) configured", peers.peers.len()),
516 None,
517 Some(serde_json::json!({ "peerCount": peers.peers.len() })),
518 );
519 }
520 }
521 Err(error) => {
522 push_check(
523 &mut checks,
524 "state.peers",
525 "Paired peers",
526 DoctorCheckStatus::Fail,
527 format!("unable to load peers: {error}"),
528 Some("Repair local state and retry pairing."),
529 None,
530 );
531 }
532 }
533
534 let transform_path = openclaw_dir.join(RELAY_TRANSFORM_MODULE_RELATIVE_PATH);
535 if transform_path.exists() {
536 push_check(
537 &mut checks,
538 "state.transformMapping",
539 "Relay transform mapping",
540 DoctorCheckStatus::Pass,
541 "relay transform module is present",
542 None,
543 Some(serde_json::json!({ "transformPath": transform_path })),
544 );
545 } else {
546 push_check(
547 &mut checks,
548 "state.transformMapping",
549 "Relay transform mapping",
550 DoctorCheckStatus::Fail,
551 "relay transform module is missing",
552 Some("Install OpenClaw relay skill or run setup to restore mapping."),
553 Some(serde_json::json!({ "transformPath": transform_path })),
554 );
555 }
556
557 let runtime_config = load_relay_runtime_config(config_dir)?;
558 let hook_token = resolve_openclaw_hook_token(config_dir, None)?;
559 if hook_token.is_some() {
560 push_check(
561 &mut checks,
562 "state.hookToken",
563 "OpenClaw hook token",
564 DoctorCheckStatus::Pass,
565 "hook token is configured",
566 None,
567 runtime_config.map(|config| serde_json::to_value(config).unwrap_or(Value::Null)),
568 );
569 } else {
570 push_check(
571 &mut checks,
572 "state.hookToken",
573 "OpenClaw hook token",
574 DoctorCheckStatus::Fail,
575 "hook token is missing",
576 Some("Run `openclaw setup <agentName>` to persist runtime hook token."),
577 None,
578 );
579 }
580
581 let pending_path = openclaw_dir.join(OPENCLAW_PENDING_DEVICES_RELATIVE_PATH);
582 let pending_count = parse_pending_approvals_count(&pending_path)?;
583 if pending_count == 0 {
584 push_check(
585 &mut checks,
586 "state.gatewayPairing",
587 "OpenClaw gateway pairing",
588 DoctorCheckStatus::Pass,
589 "no pending OpenClaw device approvals",
590 None,
591 Some(serde_json::json!({ "pendingPath": pending_path, "pendingCount": 0 })),
592 );
593 } else {
594 push_check(
595 &mut checks,
596 "state.gatewayPairing",
597 "OpenClaw gateway pairing",
598 DoctorCheckStatus::Fail,
599 format!("{pending_count} pending OpenClaw device approval(s)"),
600 Some("Approve pending devices in OpenClaw before relay diagnostics."),
601 Some(serde_json::json!({ "pendingPath": pending_path, "pendingCount": pending_count })),
602 );
603 }
604
605 if options.include_connector_runtime_check {
606 run_connector_checks(
607 &mut checks,
608 config_dir,
609 selected_agent.as_deref(),
610 options.connector_base_url.as_deref(),
611 )?;
612 } else {
613 push_check(
614 &mut checks,
615 "state.connectorRuntime",
616 "Connector runtime",
617 DoctorCheckStatus::Pass,
618 "connector runtime check skipped by caller",
619 None,
620 Some(serde_json::json!({ "defaultConnectorBaseUrl": OPENCLAW_DEFAULT_BASE_URL })),
621 );
622 }
623
624 let status = if checks
625 .iter()
626 .any(|check| check.status == DoctorCheckStatus::Fail)
627 {
628 DoctorStatus::Unhealthy
629 } else {
630 DoctorStatus::Healthy
631 };
632
633 Ok(OpenclawDoctorResult { status, checks })
634}
635
636#[cfg(test)]
637mod tests {
638 use tempfile::TempDir;
639 use wiremock::matchers::{method, path};
640 use wiremock::{Mock, MockServer, ResponseTemplate};
641
642 use super::super::setup::{
643 OpenclawRelayRuntimeConfig, save_connector_assignment, save_relay_runtime_config,
644 write_selected_openclaw_agent,
645 };
646 use crate::db::SqliteStore;
647 use crate::peers::{PersistPeerInput, persist_peer};
648
649 use super::{DoctorStatus, OpenclawDoctorOptions, run_openclaw_doctor};
650
651 #[tokio::test]
652 async fn doctor_reports_healthy_when_runtime_is_ready() {
653 let temp = TempDir::new().expect("temp dir");
654 let config_dir = temp.path().join("state");
655 std::fs::create_dir_all(config_dir.join("agents/alpha")).expect("agent dir");
656 std::fs::write(config_dir.join("agents/alpha/ait.jwt"), "token").expect("ait");
657 std::fs::write(config_dir.join("agents/alpha/secret.key"), "secret").expect("secret");
658 write_selected_openclaw_agent(&config_dir, "alpha").expect("selected");
659 save_relay_runtime_config(
660 &config_dir,
661 OpenclawRelayRuntimeConfig {
662 openclaw_base_url: "http://127.0.0.1:18789".to_string(),
663 openclaw_hook_token: Some("token".to_string()),
664 relay_transform_peers_path: None,
665 updated_at: None,
666 },
667 )
668 .expect("runtime config");
669
670 let openclaw_dir = temp.path().join("openclaw");
671 std::fs::create_dir_all(openclaw_dir.join("hooks/transforms")).expect("transform dir");
672 std::fs::write(
673 openclaw_dir.join("hooks/transforms/relay-to-peer.mjs"),
674 "export default {}",
675 )
676 .expect("transform");
677 std::fs::create_dir_all(openclaw_dir.join("devices")).expect("devices dir");
678 std::fs::write(openclaw_dir.join("devices/pending.json"), "[]").expect("pending");
679
680 let store = SqliteStore::open_path(temp.path().join("db.sqlite3")).expect("db");
681 let _ = persist_peer(
682 &store,
683 PersistPeerInput {
684 alias: Some("peer-alpha".to_string()),
685 did: "did:cdi:registry.clawdentity.com:agent:01HF7YAT00W6W7CM7N3W5FDXT4"
686 .to_string(),
687 proxy_url: "https://proxy.example/hooks/agent".to_string(),
688 agent_name: Some("alpha".to_string()),
689 human_name: Some("alice".to_string()),
690 },
691 )
692 .expect("peer");
693
694 let server = MockServer::start().await;
695 Mock::given(method("GET"))
696 .and(path("/v1/status"))
697 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
698 "websocket": { "connected": true },
699 "inbound": { "pending": 0, "deadLetter": 0 }
700 })))
701 .mount(&server)
702 .await;
703
704 save_connector_assignment(&config_dir, "alpha", &server.uri()).expect("assignment");
705 let doctor_config_dir = config_dir.clone();
706 let doctor_store = store.clone();
707 let result = tokio::task::spawn_blocking(move || {
708 run_openclaw_doctor(
709 &doctor_config_dir,
710 &doctor_store,
711 OpenclawDoctorOptions {
712 openclaw_dir: Some(openclaw_dir),
713 include_connector_runtime_check: true,
714 ..OpenclawDoctorOptions::default()
715 },
716 )
717 })
718 .await
719 .expect("join")
720 .expect("doctor");
721 assert_eq!(result.status, DoctorStatus::Healthy);
722 }
723
724 #[test]
725 fn doctor_fails_when_selected_agent_marker_is_missing() {
726 let temp = TempDir::new().expect("temp dir");
727 let config_dir = temp.path().join("state");
728 std::fs::create_dir_all(&config_dir).expect("state dir");
729 let store = SqliteStore::open_path(temp.path().join("db.sqlite3")).expect("db");
730 let result = run_openclaw_doctor(
731 &config_dir,
732 &store,
733 OpenclawDoctorOptions {
734 include_connector_runtime_check: false,
735 ..OpenclawDoctorOptions::default()
736 },
737 )
738 .expect("doctor");
739 assert_eq!(result.status, DoctorStatus::Unhealthy);
740 assert!(
741 result
742 .checks
743 .iter()
744 .any(|check| check.id == "state.selectedAgent"
745 && check.status == super::DoctorCheckStatus::Fail)
746 );
747 }
748}