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