cli

Command-line interface for Radroots
git clone https://radroots.dev/git/cli.git
Log | Files | Refs | README | LICENSE

commit 59644fc5938fc1a0f50279771fa7f04377c8c4cf
parent a9b997ca69e0a60f7b2e36a61af2367a1cbd9cef
Author: triesap <tyson@radroots.org>
Date:   Thu,  9 Apr 2026 21:13:00 +0000

workflow: expose rhi provider posture

Diffstat:
Msrc/commands/doctor.rs | 16++++++++++++++++
Msrc/commands/runtime.rs | 14+++++++++++++-
Msrc/domain/runtime.rs | 15+++++++++++++++
Msrc/render/mod.rs | 44++++++++++++++++++++++++++++++++++++--------
Msrc/runtime/config.rs | 2+-
Msrc/runtime/mod.rs | 1+
Asrc/runtime/workflow.rs | 178+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtests/runtime_show.rs | 19+++++++++++++++++++
8 files changed, 279 insertions(+), 10 deletions(-)

diff --git a/src/commands/doctor.rs b/src/commands/doctor.rs @@ -6,6 +6,7 @@ use crate::runtime::config::{RuntimeConfig, SignerBackend}; use crate::runtime::hyf::resolve_runtime_status as resolve_hyf_status; use crate::runtime::logging::LoggingState; use crate::runtime::signer::resolve_signer_status; +use crate::runtime::workflow::resolve_workflow_provider; #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] enum DoctorSeverity { @@ -59,6 +60,7 @@ pub fn report( } checks.push(hyf_check(&resolve_hyf_status(config))); + checks.push(workflow_check(&resolve_workflow_provider(config))); checks.push(logging_check(config, logging)); checks.push(binding_check(config)); @@ -300,6 +302,20 @@ fn hyf_check(hyf: &crate::runtime::hyf::HyfStatusView) -> EvaluatedCheck { } } +fn workflow_check( + workflow: &crate::runtime::workflow::WorkflowProviderStatusView, +) -> EvaluatedCheck { + EvaluatedCheck { + severity: DoctorSeverity::Ok, + view: DoctorCheckView { + name: "workflow".to_owned(), + status: "ok".to_owned(), + detail: workflow.detail(), + }, + action: None, + } +} + fn logging_check(config: &RuntimeConfig, logging: &LoggingState) -> EvaluatedCheck { let detail = match (config.logging.stdout, logging.current_file.as_ref()) { (true, Some(path)) => format!("stdout + file {}", path.display()), diff --git a/src/commands/runtime.rs b/src/commands/runtime.rs @@ -2,17 +2,19 @@ use crate::domain::runtime::{ AccountRuntimeView, AccountSecretRuntimeView, CapabilityBindingRuntimeView, ConfigFilesRuntimeView, ConfigShowView, HyfRuntimeView, LegacyPathRuntimeView, LocalRuntimeView, LoggingRuntimeView, MigrationRuntimeView, MycRuntimeView, OutputRuntimeView, - PathsRuntimeView, RelayRuntimeView, RpcRuntimeView, SignerRuntimeView, + PathsRuntimeView, RelayRuntimeView, RpcRuntimeView, SignerRuntimeView, WorkflowRuntimeView, }; use crate::runtime::RuntimeError; use crate::runtime::config::RuntimeConfig; use crate::runtime::logging::LoggingState; +use crate::runtime::workflow::resolve_workflow_provider; pub fn show( config: &RuntimeConfig, logging: &LoggingState, ) -> Result<ConfigShowView, RuntimeError> { let secret_backend = crate::runtime::accounts::secret_backend_status(config); + let workflow = resolve_workflow_provider(config); Ok(ConfigShowView { source: "local runtime state".to_owned(), output: OutputRuntimeView { @@ -104,6 +106,16 @@ pub fn show( myc: MycRuntimeView { executable: config.myc.executable.display().to_string(), }, + workflow: WorkflowRuntimeView { + provider_runtime_id: workflow.provider_runtime_id, + binding_model: workflow.binding_model, + state: workflow.state, + source: workflow.source, + target_kind: workflow.target_kind, + target: workflow.target, + hyf_helper_state: workflow.hyf_helper_state, + hyf_helper_detail: workflow.hyf_helper_detail, + }, hyf: HyfRuntimeView { enabled: config.hyf.enabled, executable: config.hyf.executable.display().to_string(), diff --git a/src/domain/runtime.rs b/src/domain/runtime.rs @@ -118,6 +118,7 @@ pub struct ConfigShowView { pub relay: RelayRuntimeView, pub local: LocalRuntimeView, pub myc: MycRuntimeView, + pub workflow: WorkflowRuntimeView, pub hyf: HyfRuntimeView, pub rpc: RpcRuntimeView, pub capability_bindings: Vec<CapabilityBindingRuntimeView>, @@ -246,6 +247,20 @@ pub struct MycRuntimeView { } #[derive(Debug, Clone, Serialize)] +pub struct WorkflowRuntimeView { + pub provider_runtime_id: String, + pub binding_model: String, + pub state: String, + pub source: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub target_kind: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub target: Option<String>, + pub hyf_helper_state: String, + pub hyf_helper_detail: String, +} + +#[derive(Debug, Clone, Serialize)] pub struct HyfRuntimeView { pub enabled: bool, pub executable: String, diff --git a/src/render/mod.rs b/src/render/mod.rs @@ -617,6 +617,26 @@ fn render_config_show( "myc", &[("executable", view.myc.executable.as_str())], )?; + let workflow_target = format_runtime_target( + view.workflow.target_kind.as_deref(), + view.workflow.target.as_deref(), + ); + render_pairs( + stdout, + "workflow", + &[ + ("provider", view.workflow.provider_runtime_id.as_str()), + ("binding model", view.workflow.binding_model.as_str()), + ("state", view.workflow.state.as_str()), + ("source", view.workflow.source.as_str()), + ("target", workflow_target.as_str()), + ("hyf helper", view.workflow.hyf_helper_state.as_str()), + ( + "hyf helper detail", + view.workflow.hyf_helper_detail.as_str(), + ), + ], + )?; render_pairs( stdout, "hyf", @@ -661,14 +681,11 @@ fn render_config_show( fn format_capability_binding_target( binding: &crate::domain::runtime::CapabilityBindingRuntimeView, ) -> String { - let Some(target) = binding.target.as_ref() else { - return String::new(); - }; - - let mut rendered = match binding.target_kind.as_deref() { - Some(kind) => format!("{kind} {target}"), - None => target.clone(), - }; + let mut rendered = + format_runtime_target(binding.target_kind.as_deref(), binding.target.as_deref()); + if rendered.is_empty() { + return rendered; + } if let Some(account_ref) = &binding.managed_account_ref { rendered.push_str(format!(" · account {account_ref}").as_str()); } @@ -678,6 +695,17 @@ fn format_capability_binding_target( rendered } +fn format_runtime_target(target_kind: Option<&str>, target: Option<&str>) -> String { + let Some(target) = target else { + return String::new(); + }; + + match target_kind { + Some(kind) => format!("{kind} {target}"), + None => target.to_owned(), + } +} + fn render_doctor(stdout: &mut dyn Write, view: &DoctorView) -> Result<(), RuntimeError> { write_context(stdout, "system · checks")?; let table = Table { diff --git a/src/runtime/config.rs b/src/runtime/config.rs @@ -381,7 +381,7 @@ struct CapabilityBindingSpec { pub(crate) const SIGNER_REMOTE_NIP46_CAPABILITY: &str = "signer.remote_nip46"; const WRITE_PLANE_TRADE_JSONRPC_CAPABILITY: &str = "write_plane.trade_jsonrpc"; -const WORKFLOW_TRADE_CAPABILITY: &str = "workflow.trade"; +pub(crate) const WORKFLOW_TRADE_CAPABILITY: &str = "workflow.trade"; pub(crate) const INFERENCE_HYF_STDIO_CAPABILITY: &str = "inference.hyf_stdio"; const CAPABILITY_BINDING_SPECS: &[CapabilityBindingSpec] = &[ diff --git a/src/runtime/mod.rs b/src/runtime/mod.rs @@ -13,6 +13,7 @@ pub mod order; pub mod paths; pub mod signer; pub mod sync; +pub mod workflow; use std::process::ExitCode; diff --git a/src/runtime/workflow.rs b/src/runtime/workflow.rs @@ -0,0 +1,178 @@ +use crate::runtime::config::{RuntimeConfig, WORKFLOW_TRADE_CAPABILITY}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WorkflowProviderStatusView { + pub provider_runtime_id: String, + pub binding_model: String, + pub state: String, + pub source: String, + pub target_kind: Option<String>, + pub target: Option<String>, + pub hyf_helper_state: String, + pub hyf_helper_detail: String, +} + +impl WorkflowProviderStatusView { + pub fn detail(&self) -> String { + match (self.target_kind.as_deref(), self.target.as_deref()) { + (Some(target_kind), Some(target)) if self.state == "configured" => { + format!( + "{} workflow provider configured via {} {}", + self.provider_runtime_id, target_kind, target + ) + } + _ if self.state == "configured" => { + format!("{} workflow provider configured", self.provider_runtime_id) + } + _ => self.source.clone(), + } + } +} + +pub fn resolve_workflow_provider(config: &RuntimeConfig) -> WorkflowProviderStatusView { + let binding = config + .inspect_capability_bindings() + .into_iter() + .find(|binding| binding.capability_id == WORKFLOW_TRADE_CAPABILITY) + .expect("workflow.trade binding inspection must exist"); + + WorkflowProviderStatusView { + provider_runtime_id: binding.provider_runtime_id, + binding_model: binding.binding_model, + state: binding.state.as_str().to_owned(), + source: binding.source, + target_kind: binding.target_kind, + target: binding.target, + hyf_helper_state: "not_implied".to_owned(), + hyf_helper_detail: + "cli bindings do not imply an rhi -> hyf helper path; any worker helper remains explicit and optional" + .to_owned(), + } +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use radroots_runtime_paths::RadrootsMigrationReport; + use radroots_secret_vault::RadrootsSecretBackend; + + use super::resolve_workflow_provider; + use crate::runtime::config::{ + AccountConfig, AccountSecretContractConfig, CapabilityBindingConfig, + CapabilityBindingSource, CapabilityBindingTargetKind, HyfConfig, IdentityConfig, + LocalConfig, LoggingConfig, MigrationConfig, MycConfig, OutputConfig, OutputFormat, + PathsConfig, RelayConfig, RelayConfigSource, RelayPublishPolicy, RpcConfig, RuntimeConfig, + SignerBackend, SignerConfig, Verbosity, + }; + + fn sample_config(workflow_binding: Option<CapabilityBindingConfig>) -> RuntimeConfig { + RuntimeConfig { + output: OutputConfig { + format: OutputFormat::Human, + verbosity: Verbosity::Normal, + color: true, + dry_run: false, + }, + paths: PathsConfig { + profile: "interactive_user".into(), + profile_source: "default".into(), + allowed_profiles: vec!["interactive_user".into()], + root_source: "host_defaults".into(), + repo_local_root: None, + repo_local_root_source: None, + subordinate_path_override_source: "runtime_config".into(), + app_namespace: "apps/cli".into(), + shared_accounts_namespace: "shared/accounts".into(), + shared_identities_namespace: "shared/identities".into(), + app_config_path: PathBuf::from("/tmp/config.toml"), + workspace_config_path: PathBuf::from("/tmp/workspace-config.toml"), + app_data_root: PathBuf::from("/tmp/data"), + app_logs_root: PathBuf::from("/tmp/logs"), + shared_accounts_data_root: PathBuf::from("/tmp/shared/accounts"), + shared_accounts_secrets_root: PathBuf::from("/tmp/shared/accounts-secrets"), + default_identity_path: PathBuf::from("/tmp/default-identity.json"), + }, + migration: MigrationConfig { + report: RadrootsMigrationReport::empty(), + }, + logging: LoggingConfig { + filter: "info".into(), + directory: None, + stdout: true, + }, + account: AccountConfig { + selector: None, + store_path: PathBuf::from("/tmp/store.json"), + secrets_dir: PathBuf::from("/tmp/secrets"), + secret_backend: RadrootsSecretBackend::EncryptedFile, + secret_fallback: None, + }, + account_secret_contract: AccountSecretContractConfig { + default_backend: "host_vault".into(), + default_fallback: Some("encrypted_file".into()), + allowed_backends: vec!["host_vault".into(), "encrypted_file".into()], + host_vault_policy: Some("desktop".into()), + uses_protected_store: true, + }, + identity: IdentityConfig { + path: PathBuf::from("/tmp/default-identity.json"), + }, + signer: SignerConfig { + backend: SignerBackend::Local, + }, + relay: RelayConfig { + urls: Vec::new(), + publish_policy: RelayPublishPolicy::Any, + source: RelayConfigSource::Defaults, + }, + local: LocalConfig { + root: PathBuf::from("/tmp/local"), + replica_db_path: PathBuf::from("/tmp/local/replica.sqlite"), + backups_dir: PathBuf::from("/tmp/local/backups"), + exports_dir: PathBuf::from("/tmp/local/exports"), + }, + myc: MycConfig { + executable: PathBuf::from("myc"), + }, + hyf: HyfConfig { + enabled: false, + executable: PathBuf::from("hyfd"), + }, + rpc: RpcConfig { + url: "http://127.0.0.1:7070".into(), + bridge_bearer_token: None, + }, + capability_bindings: workflow_binding.into_iter().collect(), + } + } + + #[test] + fn workflow_provider_reports_not_configured_without_binding() { + let view = resolve_workflow_provider(&sample_config(None)); + assert_eq!(view.provider_runtime_id, "rhi"); + assert_eq!(view.binding_model, "out_of_process_worker"); + assert_eq!(view.state, "not_configured"); + assert_eq!(view.source, "no explicit capability binding"); + assert_eq!(view.hyf_helper_state, "not_implied"); + } + + #[test] + fn workflow_provider_reports_explicit_binding_details() { + let binding = CapabilityBindingConfig { + capability_id: "workflow.trade".into(), + provider_runtime_id: "rhi".into(), + binding_model: "out_of_process_worker".into(), + source: CapabilityBindingSource::WorkspaceConfig, + target_kind: CapabilityBindingTargetKind::ExplicitEndpoint, + target: "/tmp/rhi-binary".into(), + managed_account_ref: None, + signer_session_ref: None, + }; + let view = resolve_workflow_provider(&sample_config(Some(binding))); + assert_eq!(view.state, "configured"); + assert_eq!(view.target_kind.as_deref(), Some("explicit_endpoint")); + assert_eq!(view.target.as_deref(), Some("/tmp/rhi-binary")); + assert!(view.detail().contains("explicit_endpoint /tmp/rhi-binary")); + } +} diff --git a/tests/runtime_show.rs b/tests/runtime_show.rs @@ -279,6 +279,11 @@ fn config_show_json_reports_default_bootstrap_state() { .to_string() ); assert_eq!(json["myc"]["executable"], "myc"); + assert_eq!(json["workflow"]["provider_runtime_id"], "rhi"); + assert_eq!(json["workflow"]["binding_model"], "out_of_process_worker"); + assert_eq!(json["workflow"]["state"], "not_configured"); + assert_eq!(json["workflow"]["source"], "no explicit capability binding"); + assert_eq!(json["workflow"]["hyf_helper_state"], "not_implied"); assert_eq!(json["rpc"]["url"], "http://127.0.0.1:7070"); assert_eq!(json["rpc"]["bridge_auth_configured"], false); assert_eq!( @@ -644,6 +649,20 @@ target = "bin/hyfd-user" assert_eq!(workflow["source"], "user config [[capability_binding]]"); assert_eq!(workflow["target_kind"], "managed_instance"); assert_eq!(workflow["target"], "workflow-default"); + assert_eq!(json["workflow"]["provider_runtime_id"], "rhi"); + assert_eq!(json["workflow"]["state"], "configured"); + assert_eq!( + json["workflow"]["source"], + "user config [[capability_binding]]" + ); + assert_eq!(json["workflow"]["target_kind"], "managed_instance"); + assert_eq!(json["workflow"]["target"], "workflow-default"); + assert_eq!(json["workflow"]["hyf_helper_state"], "not_implied"); + assert!( + json["workflow"]["hyf_helper_detail"] + .as_str() + .is_some_and(|detail| detail.contains("do not imply")) + ); let inference = binding_by_capability(&json, "inference.hyf_stdio"); assert_eq!(inference["state"], "configured");