1use std::collections::BTreeMap;
3use std::env;
4use std::path::{Path, PathBuf};
5use std::process::ExitCode;
6
7use runx_contracts::{
8 DoctorDiagnostic, DoctorDiagnosticSeverity, DoctorLocation, DoctorRepair,
9 DoctorRepairConfidence, DoctorRepairKind, DoctorRepairRisk, DoctorReport, DoctorReportSchema,
10 DoctorStatus, DoctorSummary, JsonObject, JsonValue,
11};
12use runx_pay::effect_state::{
13 RUNX_EFFECT_STATE_PATH_ENV, RUNX_HOSTED_EFFECT_STATE_BACKEND_JSON_ENV,
14 hosted_effect_state_backend_is_supported, resolve_effect_state_path,
15};
16use runx_runtime::{
17 PROVIDER_PERMISSION_GRANT_ID_ENV, PROVIDER_PERMISSION_GRANTED_SCOPES_ENV,
18 RUNX_RECEIPT_SIGN_ED25519_SEED_BASE64_ENV, RUNX_RECEIPT_SIGN_ISSUER_TYPE_ENV,
19 RUNX_RECEIPT_SIGN_KID_ENV, RuntimeError, default_doctor_options, load_runx_config_file,
20 resolve_runx_home_dir, run_doctor,
21};
22
23use crate::history::{
24 RUNX_RECEIPT_VERIFY_ED25519_PUBLIC_KEY_BASE64_ENV, RUNX_RECEIPT_VERIFY_KID_ENV,
25};
26use crate::registry::{self, RegistryAction, RegistryPlan};
27use crate::router::{DoctorMode, DoctorPlan};
28
29const OFFICIAL_SKILLS_DIR_ENV: &str = "RUNX_OFFICIAL_SKILLS_DIR";
30
31pub fn run_native_doctor(plan: DoctorPlan) -> ExitCode {
32 let env = crate::history::env_map();
33 let cwd = match env::current_dir() {
34 Ok(cwd) => cwd,
35 Err(error) => {
36 let _ignored = crate::cli_io::write_stderr_code(&format!(
37 "runx: failed to resolve cwd: {error}\n"
38 ));
39 return ExitCode::from(1);
40 }
41 };
42
43 match run_doctor_command(&plan, &env, &cwd) {
44 Ok(output) => crate::cli_io::write_stdout_code(&output.stdout, output.exit_code),
45 Err(error) => {
46 let _ignored = crate::cli_io::write_stderr_code(&format!("runx: {error}\n"));
47 ExitCode::from(1)
48 }
49 }
50}
51
52struct DoctorCliOutput {
53 stdout: String,
54 exit_code: u8,
55}
56
57fn run_doctor_command(
58 plan: &DoctorPlan,
59 env: &BTreeMap<String, String>,
60 cwd: &Path,
61) -> Result<DoctorCliOutput, DoctorCliError> {
62 if plan.mode == DoctorMode::Authority || plan.mode == DoctorMode::Registry {
63 let report = if plan.mode == DoctorMode::Authority {
64 run_authority_doctor(env, cwd)
65 } else {
66 run_registry_doctor(env, cwd)
67 };
68 let stdout = if plan.json {
69 json_line(&report)?
70 } else {
71 render_doctor_report(&report)
72 };
73 return Ok(DoctorCliOutput {
74 stdout,
75 exit_code: 0,
76 });
77 }
78
79 let root = resolve_doctor_root(plan, env, cwd);
80 let mut report = run_doctor(&root, &default_doctor_options())?;
81 if let Some(diagnostic) = managed_agent_config_diagnostic(env, cwd) {
82 report.diagnostics.push(diagnostic);
83 }
84 report.summary = summary(&report.diagnostics);
85 if report
86 .diagnostics
87 .iter()
88 .any(|diagnostic| diagnostic.severity == DoctorDiagnosticSeverity::Error)
89 {
90 report.status = DoctorStatus::Failure;
91 }
92 let exit_code = match report.status {
93 DoctorStatus::Success => 0,
94 DoctorStatus::Failure => 1,
95 };
96 let stdout = if plan.json {
97 json_line(&report)?
98 } else {
99 render_doctor_report(&report)
100 };
101 Ok(DoctorCliOutput { stdout, exit_code })
102}
103
104fn managed_agent_config_diagnostic(
107 env: &BTreeMap<String, String>,
108 cwd: &Path,
109) -> Option<DoctorDiagnostic> {
110 let config_dir = resolve_runx_home_dir(env, cwd);
111 let config_path = config_dir.join("config.json");
112 let config = load_runx_config_file(&config_path);
113 let mut evidence = JsonObject::new();
114 evidence.insert(
115 "config_path".to_owned(),
116 JsonValue::String(config_path.display().to_string()),
117 );
118
119 let (config_provider, config_model, config_key_ref, config_error) = match config {
120 Ok(config) => (
121 config
122 .agent
123 .as_ref()
124 .and_then(|agent| agent.provider.as_deref())
125 .map(str::to_owned),
126 config
127 .agent
128 .as_ref()
129 .and_then(|agent| agent.model.as_deref())
130 .map(str::to_owned),
131 config
132 .agent
133 .as_ref()
134 .and_then(|agent| agent.api_key_ref.as_deref())
135 .map(str::to_owned),
136 None,
137 ),
138 Err(error) => (None, None, None, Some(error.to_string())),
139 };
140
141 let provider = first_non_empty([
142 env.get("RUNX_AGENT_PROVIDER").map(String::as_str),
143 config_provider.as_deref(),
144 ]);
145 let model = first_non_empty([
146 env.get("RUNX_AGENT_MODEL").map(String::as_str),
147 config_model.as_deref(),
148 ]);
149 let provider_key_env = provider.and_then(provider_api_key_env);
150 let api_key_configured = env_contains_non_empty(env, "RUNX_AGENT_API_KEY")
151 || provider_key_env.is_some_and(|name| env_contains_non_empty(env, name))
152 || config_key_ref
153 .as_deref()
154 .is_some_and(|value| !value.trim().is_empty());
155
156 evidence.insert(
157 "provider_set".to_owned(),
158 JsonValue::Bool(provider.is_some()),
159 );
160 evidence.insert("model_set".to_owned(), JsonValue::Bool(model.is_some()));
161 evidence.insert(
162 "api_key_set".to_owned(),
163 JsonValue::Bool(api_key_configured),
164 );
165 if let Some(provider) = provider {
166 evidence.insert(
167 "provider".to_owned(),
168 JsonValue::String(provider.to_owned()),
169 );
170 }
171 if let Some(model) = model {
172 evidence.insert("model".to_owned(), JsonValue::String(model.to_owned()));
173 }
174 if let Some(name) = provider_key_env {
175 evidence.insert(
176 "provider_api_key_env".to_owned(),
177 JsonValue::String(name.to_owned()),
178 );
179 }
180 if let Some(error) = config_error.as_ref() {
181 evidence.insert("config_error".to_owned(), JsonValue::String(error.clone()));
182 }
183
184 let complete = provider.is_some() && model.is_some() && api_key_configured;
185 let partial = !complete
186 && (provider.is_some() || model.is_some() || api_key_configured || config_error.is_some());
187 if !complete && !partial {
188 return None;
189 }
190 let severity = if partial {
191 DoctorDiagnosticSeverity::Warning
192 } else {
193 DoctorDiagnosticSeverity::Info
194 };
195 let message = if let Some(error) = config_error {
196 format!("Managed-agent config could not be read: {error}.")
197 } else if complete {
198 "Managed-agent config is complete; agent-task runners can execute in-process.".to_owned()
199 } else {
200 "Managed-agent config is partial; set provider, model, and API key or unset the partial values. Otherwise agent-task runners may yield to the host or fail later.".to_owned()
201 };
202
203 Some(DoctorDiagnostic {
204 id: "runx.agent.config".to_owned(),
205 instance_id: "runx:doctor:runx.agent.config".to_owned(),
206 severity,
207 title: "Managed-agent config".to_owned(),
208 message,
209 target: object([
210 ("kind", string_value("config")),
211 ("ref", string_value("runx.agent.config")),
212 ]),
213 location: DoctorLocation {
214 path: "runx config".to_owned(),
215 json_pointer: Some("/agent".to_owned()),
216 },
217 evidence: Some(evidence),
218 repairs: if partial {
219 vec![DoctorRepair {
220 id: "runx.agent.config.configure".to_owned(),
221 kind: DoctorRepairKind::Manual,
222 confidence: DoctorRepairConfidence::High,
223 risk: DoctorRepairRisk::Low,
224 path: Some("runx config".to_owned()),
225 json_pointer: Some("/agent".to_owned()),
226 contents: Some(
227 "Set agent.provider, agent.model, and agent.api_key, or unset partial managed-agent config."
228 .to_owned(),
229 ),
230 patch: None,
231 command: Some(
232 "runx config set agent.provider anthropic && runx config set agent.model <model> && runx config set agent.api_key <key>".to_owned(),
233 ),
234 requires_human_review: false,
235 }]
236 } else {
237 Vec::new()
238 },
239 })
240}
241
242fn first_non_empty<'a>(values: impl IntoIterator<Item = Option<&'a str>>) -> Option<&'a str> {
243 values
244 .into_iter()
245 .flatten()
246 .map(str::trim)
247 .find(|value| !value.is_empty())
248}
249
250fn provider_api_key_env(provider: &str) -> Option<&'static str> {
251 match provider.trim().to_ascii_lowercase().as_str() {
252 "anthropic" => Some("ANTHROPIC_API_KEY"),
253 "openai" => Some("OPENAI_API_KEY"),
254 _ => None,
255 }
256}
257
258fn run_registry_doctor(env: &BTreeMap<String, String>, cwd: &Path) -> DoctorReport {
259 let target = registry::resolve_registry_target(®istry_probe_plan(), env, cwd);
260 let diagnostics = vec![
261 registry_target_diagnostic(&target),
262 path_diagnostic(
263 "runx.registry.official_cache",
264 "Official skill cache",
265 registry::official_skills_cache_root(env, cwd),
266 &[OFFICIAL_SKILLS_DIR_ENV],
267 ),
268 path_diagnostic(
269 "runx.registry.global_cache",
270 "Registry skill cache",
271 registry::registry_skills_cache_root(env, cwd),
272 &["RUNX_HOME"],
273 ),
274 registry_trust_key_diagnostic(env),
275 registry_remote_install_diagnostic(&target, env),
276 ];
277 DoctorReport {
278 schema: DoctorReportSchema::V1,
279 status: DoctorStatus::Success,
280 summary: summary(&diagnostics),
281 diagnostics,
282 }
283}
284
285fn registry_probe_plan() -> RegistryPlan {
286 RegistryPlan {
287 action: RegistryAction::Resolve,
288 subject: "runx/registry-probe".to_owned(),
289 registry: None,
290 registry_dir: None,
291 version: None,
292 expected_digest: None,
293 destination: None,
294 owner: None,
295 profile: None,
296 trust_tier: None,
297 limit: None,
298 upsert: false,
299 json: true,
300 }
301}
302
303fn registry_target_diagnostic(target: ®istry::RegistryTarget) -> DoctorDiagnostic {
304 let mut evidence = JsonObject::new();
305 evidence.insert("source".to_owned(), string_value(target.label()));
306 evidence.insert(
307 "description".to_owned(),
308 JsonValue::String(registry::registry_source_description(target)),
309 );
310 evidence.insert(
311 "source_fingerprint".to_owned(),
312 JsonValue::String(target.fingerprint_source()),
313 );
314 DoctorDiagnostic {
315 id: "runx.registry.target".to_owned(),
316 instance_id: "runx:doctor-registry:runx.registry.target".to_owned(),
317 severity: DoctorDiagnosticSeverity::Info,
318 title: "Registry target".to_owned(),
319 message: format!(
320 "Registry target selected: {}.",
321 registry::registry_source_description(target)
322 ),
323 target: object([
324 ("kind", string_value("registry")),
325 ("ref", string_value("runx.registry.target")),
326 ]),
327 location: DoctorLocation {
328 path: "environment".to_owned(),
329 json_pointer: None,
330 },
331 evidence: Some(evidence),
332 repairs: Vec::new(),
333 }
334}
335
336fn path_diagnostic(id: &str, title: &str, path: PathBuf, env_names: &[&str]) -> DoctorDiagnostic {
337 let mut evidence = JsonObject::new();
338 evidence.insert(
339 "path".to_owned(),
340 JsonValue::String(path.display().to_string()),
341 );
342 evidence.insert(
343 "env_vars".to_owned(),
344 JsonValue::Array(
345 env_names
346 .iter()
347 .map(|name| JsonValue::String((*name).to_owned()))
348 .collect(),
349 ),
350 );
351 DoctorDiagnostic {
352 id: id.to_owned(),
353 instance_id: format!("runx:doctor-registry:{id}"),
354 severity: DoctorDiagnosticSeverity::Info,
355 title: title.to_owned(),
356 message: format!("{title} resolves to {}.", path.display()),
357 target: object([
358 ("kind", string_value("registry")),
359 ("ref", string_value(id)),
360 ]),
361 location: DoctorLocation {
362 path: "environment".to_owned(),
363 json_pointer: None,
364 },
365 evidence: Some(evidence),
366 repairs: Vec::new(),
367 }
368}
369
370fn registry_trust_key_diagnostic(env: &BTreeMap<String, String>) -> DoctorDiagnostic {
372 let configured_key_id = env
373 .get(runx_runtime::registry::RUNX_REGISTRY_MANIFEST_TRUST_KEY_ID_ENV)
374 .filter(|value| !value.trim().is_empty())
375 .cloned();
376 let configured_owner = env
377 .get(runx_runtime::registry::RUNX_REGISTRY_MANIFEST_TRUST_OWNER_ENV)
378 .filter(|value| !value.trim().is_empty())
379 .cloned();
380 let configured_source =
381 runx_runtime::registry::registry_manifest_source_authority_from_env(env)
382 .map(|source| runx_runtime::registry::registry_manifest_source_key(&source));
383 let key_material_configured = env_contains_non_empty(
384 env,
385 runx_runtime::registry::RUNX_REGISTRY_MANIFEST_TRUST_KEY_ENV,
386 );
387 let mut keys =
388 runx_runtime::registry::default_trusted_registry_manifest_keys().unwrap_or_default();
389 let configured = key_material_configured
390 && configured_key_id.is_some()
391 && configured_owner.is_some()
392 && configured_source.is_some();
393 if configured
394 && let Ok(configured_keys) =
395 runx_runtime::registry::trusted_registry_manifest_keys_from_env(env)
396 {
397 keys = configured_keys;
398 }
399 let partial = !configured
400 && (key_material_configured
401 || configured_key_id.is_some()
402 || configured_owner.is_some()
403 || configured_source.is_some());
404 let mut evidence = JsonObject::new();
405 evidence.insert(
406 "key_ids".to_owned(),
407 JsonValue::Array(
408 keys.iter()
409 .map(|key| JsonValue::String(key.key_id.clone()))
410 .collect(),
411 ),
412 );
413 evidence.insert(
414 "trust_policy".to_owned(),
415 JsonValue::Array(keys.iter().map(registry_trust_policy_evidence).collect()),
416 );
417 evidence.insert(
418 "operator_key_configured".to_owned(),
419 JsonValue::Bool(configured),
420 );
421 evidence.insert(
422 "partial_operator_key_config".to_owned(),
423 JsonValue::Bool(partial),
424 );
425 evidence.insert(
426 "env_vars".to_owned(),
427 JsonValue::Array(
428 [
429 runx_runtime::registry::RUNX_REGISTRY_MANIFEST_TRUST_KEY_ID_ENV,
430 runx_runtime::registry::RUNX_REGISTRY_MANIFEST_TRUST_KEY_ENV,
431 runx_runtime::registry::RUNX_REGISTRY_MANIFEST_TRUST_OWNER_ENV,
432 "RUNX_REGISTRY_URL",
433 "RUNX_REGISTRY_DIR",
434 ]
435 .into_iter()
436 .map(|name| JsonValue::String(name.to_owned()))
437 .collect(),
438 ),
439 );
440 DoctorDiagnostic {
441 id: "runx.registry.trust_keys".to_owned(),
442 instance_id: "runx:doctor-registry:runx.registry.trust_keys".to_owned(),
443 severity: if partial {
444 DoctorDiagnosticSeverity::Warning
445 } else {
446 DoctorDiagnosticSeverity::Info
447 },
448 title: "Registry trust keys".to_owned(),
449 message: if configured {
450 format!(
451 "Registry manifest trust key configured; key id: {}.",
452 configured_key_id.unwrap_or_default()
453 )
454 } else if partial {
455 format!(
456 "Registry manifest trust key is partially configured; set {}, {}, {}, and a registry source.",
457 runx_runtime::registry::RUNX_REGISTRY_MANIFEST_TRUST_KEY_ID_ENV,
458 runx_runtime::registry::RUNX_REGISTRY_MANIFEST_TRUST_KEY_ENV,
459 runx_runtime::registry::RUNX_REGISTRY_MANIFEST_TRUST_OWNER_ENV,
460 )
461 } else {
462 format!(
463 "Using built-in registry trust keys. Set {}, {}, {}, and a registry source to add an operator key.",
464 runx_runtime::registry::RUNX_REGISTRY_MANIFEST_TRUST_KEY_ID_ENV,
465 runx_runtime::registry::RUNX_REGISTRY_MANIFEST_TRUST_KEY_ENV,
466 runx_runtime::registry::RUNX_REGISTRY_MANIFEST_TRUST_OWNER_ENV,
467 )
468 },
469 target: object([
470 ("kind", string_value("registry")),
471 ("ref", string_value("runx.registry.trust_keys")),
472 ]),
473 location: DoctorLocation {
474 path: "environment".to_owned(),
475 json_pointer: None,
476 },
477 evidence: Some(evidence),
478 repairs: if partial {
479 vec![manual_env_repair(
480 "runx.registry.trust_keys.configure_env",
481 &[
482 runx_runtime::registry::RUNX_REGISTRY_MANIFEST_TRUST_KEY_ID_ENV,
483 runx_runtime::registry::RUNX_REGISTRY_MANIFEST_TRUST_KEY_ENV,
484 runx_runtime::registry::RUNX_REGISTRY_MANIFEST_TRUST_OWNER_ENV,
485 "RUNX_REGISTRY_URL",
486 "RUNX_REGISTRY_DIR",
487 ],
488 "Set the registry manifest trust key id, public key, allowed owner namespace, and registry source together.",
489 DoctorRepairRisk::Sensitive,
490 )]
491 } else {
492 Vec::new()
493 },
494 }
495}
496
497fn registry_trust_policy_evidence(
498 key: &runx_runtime::registry::TrustedRegistryManifestKey,
499) -> JsonValue {
500 let (scope, allowed_namespace, allowed_source, can_grant_first_party) = match &key.scope {
501 runx_runtime::registry::RegistryManifestTrustScope::OfficialRunx => (
502 "official_runx",
503 "runx/*".to_owned(),
504 "official_runx".to_owned(),
505 true,
506 ),
507 runx_runtime::registry::RegistryManifestTrustScope::ThirdParty {
508 allowed_owner,
509 allowed_source,
510 } => (
511 "third_party",
512 format!("{allowed_owner}/*"),
513 allowed_source.clone(),
514 false,
515 ),
516 };
517 JsonValue::Object(object([
518 ("key_id", JsonValue::String(key.key_id.clone())),
519 ("scope", string_value(scope)),
520 ("allowed_namespace", JsonValue::String(allowed_namespace)),
521 ("allowed_source", JsonValue::String(allowed_source)),
522 (
523 "can_grant_first_party",
524 JsonValue::Bool(can_grant_first_party),
525 ),
526 ]))
527}
528
529fn registry_remote_install_diagnostic(
530 target: ®istry::RegistryTarget,
531 env: &BTreeMap<String, String>,
532) -> DoctorDiagnostic {
533 let remote = matches!(target, registry::RegistryTarget::Remote { .. });
534 let configured = env_contains_non_empty(env, "RUNX_INSTALLATION_ID");
535 let severity = DoctorDiagnosticSeverity::Info;
536 let message = if remote && configured {
537 "Remote registry install identity configured.".to_owned()
538 } else if remote {
539 "Remote registry installs will use the local runx install identity; set RUNX_INSTALLATION_ID only when an explicit shared identity is needed.".to_owned()
540 } else {
541 "Remote registry install identity is not required for the selected local registry target."
542 .to_owned()
543 };
544 let mut evidence = JsonObject::new();
545 evidence.insert("remote_target".to_owned(), JsonValue::Bool(remote));
546 evidence.insert("configured".to_owned(), JsonValue::Bool(configured));
547 evidence.insert(
548 "env_vars".to_owned(),
549 JsonValue::Array(vec![JsonValue::String("RUNX_INSTALLATION_ID".to_owned())]),
550 );
551 DoctorDiagnostic {
552 id: "runx.registry.installation_id".to_owned(),
553 instance_id: "runx:doctor-registry:runx.registry.installation_id".to_owned(),
554 severity,
555 title: "Registry install identity".to_owned(),
556 message,
557 target: object([
558 ("kind", string_value("registry")),
559 ("ref", string_value("runx.registry.installation_id")),
560 ]),
561 location: DoctorLocation {
562 path: "environment".to_owned(),
563 json_pointer: None,
564 },
565 evidence: Some(evidence),
566 repairs: Vec::new(),
567 }
568}
569
570fn run_authority_doctor(env: &BTreeMap<String, String>, cwd: &Path) -> DoctorReport {
571 let diagnostics = vec![
572 readiness_diagnostic(
573 "runx.authority.signer",
574 "Receipt signer",
575 &[
576 RUNX_RECEIPT_SIGN_KID_ENV,
577 RUNX_RECEIPT_SIGN_ED25519_SEED_BASE64_ENV,
578 RUNX_RECEIPT_SIGN_ISSUER_TYPE_ENV,
579 ],
580 env,
581 Some(RUNX_RECEIPT_SIGN_KID_ENV),
582 ),
583 readiness_diagnostic(
584 "runx.authority.verify_key",
585 "Receipt verification key",
586 &[
587 RUNX_RECEIPT_VERIFY_KID_ENV,
588 RUNX_RECEIPT_VERIFY_ED25519_PUBLIC_KEY_BASE64_ENV,
589 ],
590 env,
591 Some(RUNX_RECEIPT_VERIFY_KID_ENV),
592 ),
593 effect_state_diagnostic(env, cwd),
594 readiness_diagnostic(
595 "runx.authority.provider_grant",
596 "Provider permission grant",
597 &[
598 PROVIDER_PERMISSION_GRANT_ID_ENV,
599 PROVIDER_PERMISSION_GRANTED_SCOPES_ENV,
600 ],
601 env,
602 None,
603 ),
604 ];
605 DoctorReport {
606 schema: DoctorReportSchema::V1,
607 status: DoctorStatus::Success,
608 summary: summary(&diagnostics),
609 diagnostics,
610 }
611}
612
613fn effect_state_diagnostic(env: &BTreeMap<String, String>, cwd: &Path) -> DoctorDiagnostic {
615 if env_contains_non_empty(env, RUNX_HOSTED_EFFECT_STATE_BACKEND_JSON_ENV) {
616 let hosted_status = hosted_effect_state_backend_is_supported(env);
617 let mut evidence = authority_evidence(
618 &[
619 RUNX_EFFECT_STATE_PATH_ENV,
620 RUNX_HOSTED_EFFECT_STATE_BACKEND_JSON_ENV,
621 ],
622 true,
623 None,
624 );
625 if matches!(hosted_status, Ok(true)) {
626 evidence.insert(
627 "backend".to_owned(),
628 JsonValue::String("hosted_transactional".to_owned()),
629 );
630 evidence.insert(
631 "transport".to_owned(),
632 JsonValue::String("configured".to_owned()),
633 );
634 return DoctorDiagnostic {
635 id: "runx.authority.effect_state".to_owned(),
636 instance_id: "runx:doctor-authority:runx.authority.effect_state".to_owned(),
637 severity: DoctorDiagnosticSeverity::Info,
638 title: "Hosted effect state transport".to_owned(),
639 message: format!(
640 "{RUNX_HOSTED_EFFECT_STATE_BACKEND_JSON_ENV} is configured with a hosted transactional transport."
641 ),
642 target: object([
643 ("kind", string_value("authority")),
644 ("ref", string_value("runx.authority.effect_state")),
645 ]),
646 location: DoctorLocation {
647 path: "environment".to_owned(),
648 json_pointer: None,
649 },
650 evidence: Some(evidence),
651 repairs: Vec::new(),
652 };
653 }
654 evidence.insert(
655 "consequence".to_owned(),
656 JsonValue::String(
657 "Native runx refuses incomplete hosted transactional effect-state descriptors before local file fallback.".to_owned(),
658 ),
659 );
660 if let Err(error) = hosted_status {
661 evidence.insert("error".to_owned(), JsonValue::String(error.to_string()));
662 }
663 return DoctorDiagnostic {
664 id: "runx.authority.effect_state".to_owned(),
665 instance_id: "runx:doctor-authority:runx.authority.effect_state".to_owned(),
666 severity: DoctorDiagnosticSeverity::Error,
667 title: "Effect state path".to_owned(),
668 message: format!(
669 "{RUNX_HOSTED_EFFECT_STATE_BACKEND_JSON_ENV} is configured without a complete hosted effect-state transport. Unset it for local file-backed execution, or pass endpoint_url, bearer_token, and allowed_families from the hosted runtime service."
670 ),
671 target: object([
672 ("kind", string_value("authority")),
673 ("ref", string_value("runx.authority.effect_state")),
674 ]),
675 location: DoctorLocation {
676 path: "environment".to_owned(),
677 json_pointer: None,
678 },
679 evidence: Some(evidence),
680 repairs: vec![manual_env_repair(
681 "runx.authority.effect_state.configure_hosted_transport",
682 &[RUNX_HOSTED_EFFECT_STATE_BACKEND_JSON_ENV],
683 "Pass a complete native-hosted effect-state transport descriptor, or unset RUNX_HOSTED_EFFECT_STATE_BACKEND_JSON for local file-backed execution.",
684 DoctorRepairRisk::High,
685 )],
686 };
687 }
688 let configured = env_contains_non_empty(env, RUNX_EFFECT_STATE_PATH_ENV);
689 let resolved_path = resolve_effect_state_path(env, cwd);
690 let mut evidence = authority_evidence(&[RUNX_EFFECT_STATE_PATH_ENV], configured, None);
691 let message = match resolved_path.as_ref() {
692 Some(path) if configured => {
693 let path = path.display();
694 evidence.insert(
695 "resolved_path".to_owned(),
696 JsonValue::String(path.to_string()),
697 );
698 format!("Effect state path configured; resolved path: {path}.")
699 }
700 Some(path) => {
701 let path = path.display();
702 evidence.insert(
703 "resolved_path".to_owned(),
704 JsonValue::String(path.to_string()),
705 );
706 evidence.insert(
707 "consequence".to_owned(),
708 JsonValue::String(effect_state_unset_consequence().to_owned()),
709 );
710 format!(
711 "Effect state path not configured; set {RUNX_EFFECT_STATE_PATH_ENV}. \
712 {consequence} Current fallback resolves to: {path}.",
713 consequence = effect_state_unset_consequence()
714 )
715 }
716 None => {
717 evidence.insert(
718 "consequence".to_owned(),
719 JsonValue::String(effect_state_unset_consequence().to_owned()),
720 );
721 format!(
722 "Effect state path not configured; set {RUNX_EFFECT_STATE_PATH_ENV}. {}",
723 effect_state_unset_consequence()
724 )
725 }
726 };
727 DoctorDiagnostic {
728 id: "runx.authority.effect_state".to_owned(),
729 instance_id: "runx:doctor-authority:runx.authority.effect_state".to_owned(),
730 severity: if configured {
731 DoctorDiagnosticSeverity::Info
732 } else {
733 DoctorDiagnosticSeverity::Warning
734 },
735 title: "Effect state path".to_owned(),
736 message,
737 target: object([
738 ("kind", string_value("authority")),
739 ("ref", string_value("runx.authority.effect_state")),
740 ]),
741 location: DoctorLocation {
742 path: "environment".to_owned(),
743 json_pointer: None,
744 },
745 evidence: Some(evidence),
746 repairs: if configured {
747 Vec::new()
748 } else {
749 vec![manual_env_repair(
750 "runx.authority.effect_state.configure_env",
751 &[RUNX_EFFECT_STATE_PATH_ENV],
752 "Set RUNX_EFFECT_STATE_PATH to a durable writable state file for cross-run effect accounting.",
753 DoctorRepairRisk::Low,
754 )]
755 },
756 }
757}
758
759fn effect_state_unset_consequence() -> &'static str {
760 "Cross-run spend caps, payment idempotency, and effect replay recovery are not durable without a configured state path."
761}
762
763fn readiness_diagnostic(
764 id: &str,
765 title: &str,
766 env_names: &[&str],
767 env: &BTreeMap<String, String>,
768 key_id_env: Option<&str>,
769) -> DoctorDiagnostic {
770 let missing = env_names
771 .iter()
772 .filter(|name| !env_contains_non_empty(env, name))
773 .copied()
774 .collect::<Vec<_>>();
775 let configured = missing.is_empty();
776 let message = if configured {
777 match key_id_env
778 .and_then(|name| env.get(name))
779 .map(String::as_str)
780 {
781 Some(key_id) => format!("{title} configured; key id: {key_id}."),
782 None => format!("{title} configured."),
783 }
784 } else {
785 format!("{title} not configured; set {}.", missing.join(", "))
786 };
787 DoctorDiagnostic {
788 id: id.to_owned(),
789 instance_id: format!("runx:doctor-authority:{id}"),
790 severity: if configured {
791 DoctorDiagnosticSeverity::Info
792 } else {
793 DoctorDiagnosticSeverity::Warning
794 },
795 title: title.to_owned(),
796 message,
797 target: object([
798 ("kind", string_value("authority")),
799 ("ref", string_value(id)),
800 ]),
801 location: DoctorLocation {
802 path: "environment".to_owned(),
803 json_pointer: None,
804 },
805 evidence: Some(authority_evidence(env_names, configured, key_id_env)),
806 repairs: if configured {
807 Vec::new()
808 } else {
809 vec![manual_env_repair(
810 &format!("{id}.configure_env"),
811 &missing,
812 &format!("Set {} in the operator environment.", missing.join(", ")),
813 DoctorRepairRisk::Sensitive,
814 )]
815 },
816 }
817}
818
819fn manual_env_repair(
820 id: &str,
821 env_names: &[&str],
822 contents: &str,
823 risk: DoctorRepairRisk,
824) -> DoctorRepair {
825 DoctorRepair {
826 id: id.to_owned(),
827 kind: DoctorRepairKind::Manual,
828 confidence: DoctorRepairConfidence::High,
829 risk,
830 path: Some("environment".to_owned()),
831 json_pointer: None,
832 contents: Some(format!(
833 "{contents} Required env vars: {}.",
834 env_names.join(", ")
835 )),
836 patch: None,
837 command: None,
838 requires_human_review: true,
839 }
840}
841
842fn authority_evidence(
843 env_names: &[&str],
844 configured: bool,
845 key_id_env: Option<&str>,
846) -> JsonObject {
847 let mut evidence = JsonObject::new();
848 evidence.insert(
849 "env_vars".to_owned(),
850 JsonValue::Array(
851 env_names
852 .iter()
853 .map(|name| JsonValue::String((*name).to_owned()))
854 .collect(),
855 ),
856 );
857 evidence.insert("configured".to_owned(), JsonValue::Bool(configured));
858 if let Some(name) = key_id_env {
859 evidence.insert("key_id_env".to_owned(), JsonValue::String(name.to_owned()));
860 }
861 evidence
862}
863
864fn env_contains_non_empty(env: &BTreeMap<String, String>, name: &str) -> bool {
865 env.get(name)
866 .map(String::as_str)
867 .is_some_and(|value| !value.trim().is_empty())
868}
869
870fn object(entries: impl IntoIterator<Item = (&'static str, JsonValue)>) -> JsonObject {
871 entries
872 .into_iter()
873 .map(|(key, value)| (key.to_owned(), value))
874 .collect()
875}
876
877fn string_value(value: &str) -> JsonValue {
878 JsonValue::String(value.to_owned())
879}
880
881fn summary(diagnostics: &[DoctorDiagnostic]) -> DoctorSummary {
882 let mut errors = 0;
883 let mut warnings = 0;
884 let mut infos = 0;
885 for diagnostic in diagnostics {
886 match diagnostic.severity {
887 DoctorDiagnosticSeverity::Error => errors += 1,
888 DoctorDiagnosticSeverity::Warning => warnings += 1,
889 DoctorDiagnosticSeverity::Info => infos += 1,
890 }
891 }
892 DoctorSummary {
893 errors,
894 warnings,
895 infos,
896 }
897}
898
899fn resolve_doctor_root(plan: &DoctorPlan, env: &BTreeMap<String, String>, cwd: &Path) -> PathBuf {
900 match plan.path.as_deref() {
901 Some(path) => {
902 runx_runtime::resolve_path_from_user_input(&path.to_string_lossy(), env, cwd, true)
903 }
904 None => runx_runtime::resolve_runx_workspace_base(env, cwd),
905 }
906}
907
908fn json_line<T: serde::Serialize>(value: &T) -> Result<String, DoctorCliError> {
909 serde_json::to_string_pretty(value)
910 .map(|json| format!("{json}\n"))
911 .map_err(DoctorCliError::Serialize)
912}
913
914fn render_doctor_report(report: &DoctorReport) -> String {
915 let mut lines = vec![
916 String::new(),
917 format!(
918 " {} doctor {} error(s), {} warning(s)",
919 status_icon(&report.status),
920 report.summary.errors,
921 report.summary.warnings
922 ),
923 ];
924 for diagnostic in &report.diagnostics {
925 lines.push(format!(
926 " {} {} {}",
927 diagnostic_icon(&diagnostic.severity),
928 diagnostic.id,
929 diagnostic.location.path
930 ));
931 lines.push(format!(" {}", diagnostic.message));
932 if let Some(repair) = diagnostic.repairs.first() {
933 if let Some(command) = repair.command.as_ref() {
934 lines.push(format!(" next: {command}"));
935 } else if let Some(contents) = repair.contents.as_ref() {
936 lines.push(format!(" next: {contents}"));
937 }
938 }
939 }
940 lines.push(String::new());
941 lines.join("\n")
942}
943
944fn status_icon(status: &DoctorStatus) -> &'static str {
945 match status {
946 DoctorStatus::Success => "โ",
947 DoctorStatus::Failure => "โ",
948 }
949}
950
951fn diagnostic_icon(severity: &DoctorDiagnosticSeverity) -> &'static str {
952 match severity {
953 DoctorDiagnosticSeverity::Error => "โ",
954 DoctorDiagnosticSeverity::Warning | DoctorDiagnosticSeverity::Info => "ยท",
955 }
956}
957
958#[derive(Debug)]
959enum DoctorCliError {
960 Runtime(RuntimeError),
961 Serialize(serde_json::Error),
962}
963
964impl std::fmt::Display for DoctorCliError {
965 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
966 match self {
967 Self::Runtime(error) => write!(formatter, "{error}"),
968 Self::Serialize(error) => {
969 write!(formatter, "failed to serialize doctor report: {error}")
970 }
971 }
972 }
973}
974
975impl From<RuntimeError> for DoctorCliError {
976 fn from(value: RuntimeError) -> Self {
977 Self::Runtime(value)
978 }
979}
980
981#[cfg(test)]
982mod tests {
983 use super::*;
984
985 #[test]
986 fn doctor_render_surfaces_first_repair_next_action() {
987 let report = DoctorReport {
988 schema: DoctorReportSchema::V1,
989 status: DoctorStatus::Success,
990 summary: DoctorSummary {
991 errors: 0,
992 warnings: 1,
993 infos: 0,
994 },
995 diagnostics: vec![DoctorDiagnostic {
996 id: "runx.registry.installation_id".to_owned(),
997 instance_id: "runx:doctor-registry:runx.registry.installation_id".to_owned(),
998 severity: DoctorDiagnosticSeverity::Info,
999 title: "Registry install identity".to_owned(),
1000 message: "Remote registry installs use an automatic local install identity."
1001 .to_owned(),
1002 target: object([
1003 ("kind", string_value("registry")),
1004 ("ref", string_value("runx.registry.installation_id")),
1005 ]),
1006 location: DoctorLocation {
1007 path: "environment".to_owned(),
1008 json_pointer: None,
1009 },
1010 evidence: None,
1011 repairs: vec![DoctorRepair {
1012 id: "runx.registry.installation_id.configure_env".to_owned(),
1013 kind: DoctorRepairKind::Manual,
1014 confidence: DoctorRepairConfidence::High,
1015 risk: DoctorRepairRisk::Low,
1016 path: Some("environment".to_owned()),
1017 json_pointer: None,
1018 contents: Some(
1019 "Set RUNX_INSTALLATION_ID only when you need a shared install identity."
1020 .to_owned(),
1021 ),
1022 patch: None,
1023 command: None,
1024 requires_human_review: true,
1025 }],
1026 }],
1027 };
1028
1029 let rendered = render_doctor_report(&report);
1030
1031 assert!(rendered.contains(
1032 "next: Set RUNX_INSTALLATION_ID only when you need a shared install identity."
1033 ));
1034 }
1035}