commit 59644fc5938fc1a0f50279771fa7f04377c8c4cf
parent a9b997ca69e0a60f7b2e36a61af2367a1cbd9cef
Author: triesap <tyson@radroots.org>
Date: Thu, 9 Apr 2026 21:13:00 +0000
workflow: expose rhi provider posture
Diffstat:
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");