Skip to main content

runx_cli/
doctor.rs

1// rust-style-allow: large-file - doctor aggregates path, registry, and authority diagnostics until those surfaces split.
2use 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
104// rust-style-allow: long-function - this builds one structured diagnostic packet
105// from env, config, and credential state so the evidence and repair stay together.
106fn 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(&registry_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: &registry::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
370// rust-style-allow: long-function - one diagnostic assembles the trust-key matrix and repair hints.
371fn 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: &registry::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
613// rust-style-allow: long-function - one diagnostic keeps effect-state path, evidence, and repairs together.
614fn 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}