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!(
324 "pending={} deadLetter={}",
325 inbound_pending, inbound_dead_letter
326 ),
327 None,
328 Some(
329 serde_json::json!({ "pendingCount": inbound_pending, "deadLetterCount": inbound_dead_letter }),
330 ),
331 );
332 let hook_failed = hook_last_attempt_status == Some("failed");
333 push_check(
334 checks,
335 "state.openclawHookHealth",
336 "OpenClaw hook health",
337 if hook_failed && inbound_pending > 0 {
338 DoctorCheckStatus::Fail
339 } else {
340 DoctorCheckStatus::Pass
341 },
342 if hook_failed && inbound_pending > 0 {
343 "connector reports failed OpenClaw hook replay with pending inbox backlog"
344 } else {
345 "OpenClaw hook replay is healthy"
346 },
347 if hook_failed && inbound_pending > 0 {
348 Some("Restart OpenClaw and connector runtime, then replay pending inbox messages.")
349 } else {
350 None
351 },
352 None,
353 );
354 } else {
355 push_check(
356 checks,
357 "state.connectorRuntime",
358 "Connector runtime",
359 DoctorCheckStatus::Fail,
360 "connector websocket is disconnected",
361 Some("Run `connector start <agentName>` or `connector service install <agentName>`."),
362 Some(serde_json::json!({ "connectorBaseUrl": base_url, "statusUrl": status_url })),
363 );
364 push_check(
365 checks,
366 "state.connectorInboundInbox",
367 "Connector inbound inbox",
368 DoctorCheckStatus::Fail,
369 "connector websocket is disconnected; inbox status may be stale",
370 Some("Start connector runtime and retry."),
371 Some(
372 serde_json::json!({ "pendingCount": inbound_pending, "deadLetterCount": inbound_dead_letter }),
373 ),
374 );
375 push_check(
376 checks,
377 "state.openclawHookHealth",
378 "OpenClaw hook health",
379 DoctorCheckStatus::Fail,
380 "connector websocket is disconnected; hook replay is unavailable",
381 Some("Restart connector runtime and OpenClaw."),
382 None,
383 );
384 }
385
386 Ok(())
387}
388
389#[allow(clippy::too_many_lines)]
391pub fn run_openclaw_doctor(
392 config_dir: &Path,
393 store: &SqliteStore,
394 options: OpenclawDoctorOptions,
395) -> Result<OpenclawDoctorResult> {
396 let openclaw_dir =
397 resolve_openclaw_dir(options.home_dir.as_deref(), options.openclaw_dir.as_deref())?;
398 let mut checks = Vec::<OpenclawDoctorCheck>::new();
399
400 let selected_agent = options
401 .selected_agent
402 .as_deref()
403 .map(str::trim)
404 .filter(|value| !value.is_empty())
405 .map(ToOwned::to_owned)
406 .or(read_selected_openclaw_agent(config_dir)?);
407
408 if let Some(agent_name) = selected_agent.clone() {
409 push_check(
410 &mut checks,
411 "state.selectedAgent",
412 "Selected agent",
413 DoctorCheckStatus::Pass,
414 format!("selected agent is `{agent_name}`"),
415 None,
416 Some(serde_json::json!({
417 "path": openclaw_agent_name_path(config_dir),
418 "agentName": agent_name
419 })),
420 );
421 } else {
422 push_check(
423 &mut checks,
424 "state.selectedAgent",
425 "Selected agent",
426 DoctorCheckStatus::Fail,
427 "selected agent marker is missing",
428 Some(
429 "Run `clawdentity provider setup --for openclaw --agent-name <agentName>` to persist selected agent.",
430 ),
431 Some(serde_json::json!({ "path": openclaw_agent_name_path(config_dir) })),
432 );
433 }
434
435 if let Some(agent_name) = selected_agent.as_deref() {
436 let agent_dir = config_dir.join(AGENTS_DIR).join(agent_name);
437 let ait_path = agent_dir.join(AIT_FILE_NAME);
438 let secret_path = agent_dir.join(SECRET_KEY_FILE_NAME);
439 let credentials_ok = read_non_empty_file(&ait_path).unwrap_or(false)
440 && read_non_empty_file(&secret_path).unwrap_or(false);
441 if credentials_ok {
442 push_check(
443 &mut checks,
444 "state.credentials",
445 "Agent credentials",
446 DoctorCheckStatus::Pass,
447 "local agent credentials are present",
448 None,
449 Some(serde_json::json!({
450 "agentDir": agent_dir,
451 "ait": ait_path,
452 "secretKey": secret_path
453 })),
454 );
455 } else {
456 push_check(
457 &mut checks,
458 "state.credentials",
459 "Agent credentials",
460 DoctorCheckStatus::Fail,
461 "local agent credentials are missing or unreadable",
462 Some("Run `agent create <agentName>` and retry setup."),
463 Some(serde_json::json!({
464 "agentDir": agent_dir,
465 "ait": ait_path,
466 "secretKey": secret_path
467 })),
468 );
469 }
470 } else {
471 push_check(
472 &mut checks,
473 "state.credentials",
474 "Agent credentials",
475 DoctorCheckStatus::Fail,
476 "cannot validate credentials without selected agent",
477 Some("Run `clawdentity provider setup --for openclaw --agent-name <agentName>` first."),
478 None,
479 );
480 }
481
482 match load_peers_config(store) {
483 Ok(peers) => {
484 if peers.peers.is_empty() {
485 push_check(
486 &mut checks,
487 "state.peers",
488 "Paired peers",
489 DoctorCheckStatus::Fail,
490 "no paired peers found",
491 Some(
492 "Complete proxy pairing via `/pair/start` + `/pair/confirm` and persist local peer state before relay checks.",
493 ),
494 None,
495 );
496 } else if let Some(peer_alias) = options.peer_alias.as_deref().map(str::trim) {
497 if peers.peers.contains_key(peer_alias) {
498 push_check(
499 &mut checks,
500 "state.peers",
501 "Paired peers",
502 DoctorCheckStatus::Pass,
503 format!("peer alias `{peer_alias}` is configured"),
504 None,
505 Some(serde_json::json!({ "peerCount": peers.peers.len() })),
506 );
507 } else {
508 push_check(
509 &mut checks,
510 "state.peers",
511 "Paired peers",
512 DoctorCheckStatus::Fail,
513 format!("peer alias `{peer_alias}` is not configured"),
514 Some(
515 "Choose an existing peer alias from local peer state (or generated peer snapshot).",
516 ),
517 Some(
518 serde_json::json!({ "peerAliases": peers.peers.keys().collect::<Vec<_>>() }),
519 ),
520 );
521 }
522 } else {
523 push_check(
524 &mut checks,
525 "state.peers",
526 "Paired peers",
527 DoctorCheckStatus::Pass,
528 format!("{} paired peer(s) configured", peers.peers.len()),
529 None,
530 Some(serde_json::json!({ "peerCount": peers.peers.len() })),
531 );
532 }
533 }
534 Err(error) => {
535 push_check(
536 &mut checks,
537 "state.peers",
538 "Paired peers",
539 DoctorCheckStatus::Fail,
540 format!("unable to load peers: {error}"),
541 Some("Repair local state and retry pairing."),
542 None,
543 );
544 }
545 }
546
547 let transform_path = openclaw_dir.join(RELAY_TRANSFORM_MODULE_RELATIVE_PATH);
548 if transform_path.exists() {
549 push_check(
550 &mut checks,
551 "state.transformMapping",
552 "Relay transform mapping",
553 DoctorCheckStatus::Pass,
554 "relay transform module is present",
555 None,
556 Some(serde_json::json!({ "transformPath": transform_path })),
557 );
558 } else {
559 push_check(
560 &mut checks,
561 "state.transformMapping",
562 "Relay transform mapping",
563 DoctorCheckStatus::Fail,
564 "relay transform module is missing",
565 Some("Install OpenClaw relay skill or run setup to restore mapping."),
566 Some(serde_json::json!({ "transformPath": transform_path })),
567 );
568 }
569
570 let runtime_config = load_relay_runtime_config(config_dir)?;
571 let hook_token = resolve_openclaw_hook_token(config_dir, None)?;
572 if hook_token.is_some() {
573 push_check(
574 &mut checks,
575 "state.hookToken",
576 "OpenClaw hook token",
577 DoctorCheckStatus::Pass,
578 "hook token is configured",
579 None,
580 runtime_config.map(|config| serde_json::to_value(config).unwrap_or(Value::Null)),
581 );
582 } else {
583 push_check(
584 &mut checks,
585 "state.hookToken",
586 "OpenClaw hook token",
587 DoctorCheckStatus::Fail,
588 "hook token is missing",
589 Some(
590 "Run `clawdentity provider setup --for openclaw --agent-name <agentName>` to persist runtime hook token.",
591 ),
592 None,
593 );
594 }
595
596 let pending_path = openclaw_dir.join(OPENCLAW_PENDING_DEVICES_RELATIVE_PATH);
597 let pending_count = parse_pending_approvals_count(&pending_path)?;
598 if pending_count == 0 {
599 push_check(
600 &mut checks,
601 "state.gatewayPairing",
602 "OpenClaw gateway pairing",
603 DoctorCheckStatus::Pass,
604 "no pending OpenClaw device approvals",
605 None,
606 Some(serde_json::json!({ "pendingPath": pending_path, "pendingCount": 0 })),
607 );
608 } else {
609 push_check(
610 &mut checks,
611 "state.gatewayPairing",
612 "OpenClaw gateway pairing",
613 DoctorCheckStatus::Fail,
614 format!("{pending_count} pending OpenClaw device approval(s)"),
615 Some("Approve pending devices in OpenClaw before relay diagnostics."),
616 Some(serde_json::json!({ "pendingPath": pending_path, "pendingCount": pending_count })),
617 );
618 }
619
620 if options.include_connector_runtime_check {
621 run_connector_checks(
622 &mut checks,
623 config_dir,
624 selected_agent.as_deref(),
625 options.connector_base_url.as_deref(),
626 )?;
627 } else {
628 push_check(
629 &mut checks,
630 "state.connectorRuntime",
631 "Connector runtime",
632 DoctorCheckStatus::Pass,
633 "connector runtime check skipped by caller",
634 None,
635 Some(serde_json::json!({ "defaultConnectorBaseUrl": OPENCLAW_DEFAULT_BASE_URL })),
636 );
637 }
638
639 let status = if checks
640 .iter()
641 .any(|check| check.status == DoctorCheckStatus::Fail)
642 {
643 DoctorStatus::Unhealthy
644 } else {
645 DoctorStatus::Healthy
646 };
647
648 Ok(OpenclawDoctorResult { status, checks })
649}
650
651#[cfg(test)]
652mod tests {
653 use tempfile::TempDir;
654 use wiremock::matchers::{method, path};
655 use wiremock::{Mock, MockServer, ResponseTemplate};
656
657 use super::super::setup::{
658 OpenclawRelayRuntimeConfig, save_connector_assignment, save_relay_runtime_config,
659 write_selected_openclaw_agent,
660 };
661 use crate::db::SqliteStore;
662 use crate::peers::{PersistPeerInput, persist_peer};
663
664 use super::{DoctorStatus, OpenclawDoctorOptions, run_openclaw_doctor};
665
666 #[tokio::test]
667 async fn doctor_reports_healthy_when_runtime_is_ready() {
668 let temp = TempDir::new().expect("temp dir");
669 let config_dir = temp.path().join("state");
670 std::fs::create_dir_all(config_dir.join("agents/alpha")).expect("agent dir");
671 std::fs::write(config_dir.join("agents/alpha/ait.jwt"), "token").expect("ait");
672 std::fs::write(config_dir.join("agents/alpha/secret.key"), "secret").expect("secret");
673 write_selected_openclaw_agent(&config_dir, "alpha").expect("selected");
674 save_relay_runtime_config(
675 &config_dir,
676 OpenclawRelayRuntimeConfig {
677 openclaw_base_url: "http://127.0.0.1:18789".to_string(),
678 openclaw_hook_token: Some("token".to_string()),
679 relay_transform_peers_path: None,
680 updated_at: None,
681 },
682 )
683 .expect("runtime config");
684
685 let openclaw_dir = temp.path().join("openclaw");
686 std::fs::create_dir_all(openclaw_dir.join("hooks/transforms")).expect("transform dir");
687 std::fs::write(
688 openclaw_dir.join("hooks/transforms/relay-to-peer.mjs"),
689 "export default {}",
690 )
691 .expect("transform");
692 std::fs::create_dir_all(openclaw_dir.join("devices")).expect("devices dir");
693 std::fs::write(openclaw_dir.join("devices/pending.json"), "[]").expect("pending");
694
695 let store = SqliteStore::open_path(temp.path().join("db.sqlite3")).expect("db");
696 let _ = persist_peer(
697 &store,
698 PersistPeerInput {
699 alias: Some("peer-alpha".to_string()),
700 did: "did:cdi:registry.clawdentity.com:agent:01HF7YAT00W6W7CM7N3W5FDXT4"
701 .to_string(),
702 proxy_url: "https://proxy.example/hooks/agent".to_string(),
703 agent_name: Some("alpha".to_string()),
704 human_name: Some("alice".to_string()),
705 },
706 )
707 .expect("peer");
708
709 let server = MockServer::start().await;
710 Mock::given(method("GET"))
711 .and(path("/v1/status"))
712 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
713 "websocket": { "connected": true },
714 "inbound": { "pending": 0, "deadLetter": 0 }
715 })))
716 .mount(&server)
717 .await;
718
719 save_connector_assignment(&config_dir, "alpha", &server.uri()).expect("assignment");
720 let doctor_config_dir = config_dir.clone();
721 let doctor_store = store.clone();
722 let result = tokio::task::spawn_blocking(move || {
723 run_openclaw_doctor(
724 &doctor_config_dir,
725 &doctor_store,
726 OpenclawDoctorOptions {
727 openclaw_dir: Some(openclaw_dir),
728 include_connector_runtime_check: true,
729 ..OpenclawDoctorOptions::default()
730 },
731 )
732 })
733 .await
734 .expect("join")
735 .expect("doctor");
736 assert_eq!(result.status, DoctorStatus::Healthy);
737 }
738
739 #[test]
740 fn doctor_fails_when_selected_agent_marker_is_missing() {
741 let temp = TempDir::new().expect("temp dir");
742 let config_dir = temp.path().join("state");
743 std::fs::create_dir_all(&config_dir).expect("state dir");
744 let store = SqliteStore::open_path(temp.path().join("db.sqlite3")).expect("db");
745 let result = run_openclaw_doctor(
746 &config_dir,
747 &store,
748 OpenclawDoctorOptions {
749 include_connector_runtime_check: false,
750 ..OpenclawDoctorOptions::default()
751 },
752 )
753 .expect("doctor");
754 assert_eq!(result.status, DoctorStatus::Unhealthy);
755 assert!(
756 result
757 .checks
758 .iter()
759 .any(|check| check.id == "state.selectedAgent"
760 && check.status == super::DoctorCheckStatus::Fail)
761 );
762 }
763}