cli

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

commit ba9243bf329e966c4d55e66e289e2dc7345d7839
parent 284c74df8dd1e6a4dd5b0b8a17a8f65c0d4ece9b
Author: triesap <tyson@radroots.org>
Date:   Wed,  8 Apr 2026 23:04:36 +0000

runtime: add cli hyf operator contract

Diffstat:
Msrc/cli.rs | 17+++++++++++++++++
Msrc/commands/doctor.rs | 51+++++++++++++++++++++++++++++++++++++++++++++++----
Msrc/commands/runtime.rs | 8++++++--
Msrc/domain/runtime.rs | 7+++++++
Msrc/render/mod.rs | 37+++++++++++++++++++++++++++++++------
Msrc/runtime/config.rs | 187+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Asrc/runtime/hyf.rs | 377+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/runtime/mod.rs | 1+
8 files changed, 670 insertions(+), 15 deletions(-)

diff --git a/src/cli.rs b/src/cli.rs @@ -41,6 +41,12 @@ pub struct CliArgs { pub relay: Vec<String>, #[arg(long, global = true)] pub myc_executable: Option<PathBuf>, + #[arg(long = "hyf-enabled", global = true, action = ArgAction::SetTrue)] + pub hyf_enabled: bool, + #[arg(long = "no-hyf-enabled", global = true, action = ArgAction::SetTrue)] + pub no_hyf_enabled: bool, + #[arg(long = "hyf-executable", global = true)] + pub hyf_executable: Option<PathBuf>, #[command(subcommand)] pub command: Command, } @@ -491,6 +497,9 @@ mod tests { "wss://relay.two", "--myc-executable", "bin/myc", + "--hyf-enabled", + "--hyf-executable", + "bin/hyfd", "config", "show", ]); @@ -531,6 +540,14 @@ mod tests { .and_then(|path| path.to_str()), Some("bin/myc") ); + assert!(parsed.hyf_enabled); + assert_eq!( + parsed + .hyf_executable + .as_deref() + .and_then(|path| path.to_str()), + Some("bin/hyfd") + ); } #[test] diff --git a/src/commands/doctor.rs b/src/commands/doctor.rs @@ -3,6 +3,7 @@ use crate::domain::runtime::{ }; use crate::runtime::RuntimeError; use crate::runtime::config::{RuntimeConfig, SignerBackend}; +use crate::runtime::hyf::resolve_status as resolve_hyf_status; use crate::runtime::logging::LoggingState; use crate::runtime::signer::resolve_signer_status; @@ -57,6 +58,7 @@ pub fn report( } } + checks.push(hyf_check(&resolve_hyf_status(&config.hyf))); checks.push(logging_check(config, logging)); let severity = checks @@ -69,10 +71,7 @@ pub fn report( ok: severity == DoctorSeverity::Ok, state: severity.status().to_owned(), checks: checks.into_iter().map(|check| check.view).collect(), - source: match config.signer.backend { - SignerBackend::Local => "local diagnostics".to_owned(), - SignerBackend::Myc => "local diagnostics + myc status command".to_owned(), - }, + source: doctor_source(config), actions, }; @@ -267,6 +266,39 @@ fn myc_check(myc: &crate::domain::runtime::MycStatusView) -> EvaluatedCheck { } } +fn hyf_check(hyf: &crate::runtime::hyf::HyfStatusView) -> EvaluatedCheck { + let (severity, detail) = match hyf.state.as_str() { + "disabled" => ( + DoctorSeverity::Ok, + hyf.reason + .clone() + .unwrap_or_else(|| "disabled by config".to_owned()), + ), + "ready" => ( + DoctorSeverity::Ok, + hyf.reason + .clone() + .unwrap_or_else(|| "healthy · protocol 1 · deterministic available".to_owned()), + ), + _ => ( + DoctorSeverity::ExternalFail, + hyf.reason + .clone() + .unwrap_or_else(|| "hyf is unavailable".to_owned()), + ), + }; + + EvaluatedCheck { + severity, + view: DoctorCheckView { + name: "hyf".to_owned(), + status: severity.status().to_owned(), + 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()), @@ -295,3 +327,14 @@ fn collect_actions(checks: &[EvaluatedCheck]) -> Vec<String> { } actions } + +fn doctor_source(config: &RuntimeConfig) -> String { + let mut sources = vec!["local diagnostics"]; + if matches!(config.signer.backend, SignerBackend::Myc) { + sources.push("myc status command"); + } + if config.hyf.enabled { + sources.push("hyf status control request"); + } + sources.join(" + ") +} diff --git a/src/commands/runtime.rs b/src/commands/runtime.rs @@ -1,7 +1,7 @@ use crate::domain::runtime::{ AccountRuntimeView, AccountSecretRuntimeView, ConfigFilesRuntimeView, ConfigShowView, - LocalRuntimeView, LoggingRuntimeView, MycRuntimeView, OutputRuntimeView, PathsRuntimeView, - RelayRuntimeView, RpcRuntimeView, SignerRuntimeView, + HyfRuntimeView, LocalRuntimeView, LoggingRuntimeView, MycRuntimeView, OutputRuntimeView, + PathsRuntimeView, RelayRuntimeView, RpcRuntimeView, SignerRuntimeView, }; use crate::runtime::RuntimeError; use crate::runtime::config::RuntimeConfig; @@ -93,6 +93,10 @@ pub fn show( myc: MycRuntimeView { executable: config.myc.executable.display().to_string(), }, + hyf: HyfRuntimeView { + enabled: config.hyf.enabled, + executable: config.hyf.executable.display().to_string(), + }, rpc: RpcRuntimeView { url: config.rpc.url.clone(), bridge_auth_configured: config.rpc.bridge_bearer_token.is_some(), diff --git a/src/domain/runtime.rs b/src/domain/runtime.rs @@ -117,6 +117,7 @@ pub struct ConfigShowView { pub relay: RelayRuntimeView, pub local: LocalRuntimeView, pub myc: MycRuntimeView, + pub hyf: HyfRuntimeView, pub rpc: RpcRuntimeView, } @@ -216,6 +217,12 @@ pub struct MycRuntimeView { } #[derive(Debug, Clone, Serialize)] +pub struct HyfRuntimeView { + pub enabled: bool, + pub executable: String, +} + +#[derive(Debug, Clone, Serialize)] pub struct RpcRuntimeView { pub url: String, pub bridge_auth_configured: bool, diff --git a/src/render/mod.rs b/src/render/mod.rs @@ -617,6 +617,14 @@ fn render_config_show( )?; render_pairs( stdout, + "hyf", + &[ + ("enabled", yes_no(view.hyf.enabled)), + ("executable", view.hyf.executable.as_str()), + ], + )?; + render_pairs( + stdout, "rpc", &[ ("url", view.rpc.url.as_str()), @@ -778,7 +786,9 @@ fn render_job_get(stdout: &mut dyn Write, view: &JobGetView) -> Result<(), Runti ("signer mode", job.signer.clone()), ( "signer session", - job.signer_session_id.clone().unwrap_or_else(|| "-".to_owned()), + job.signer_session_id + .clone() + .unwrap_or_else(|| "-".to_owned()), ), ( "requested", @@ -828,7 +838,9 @@ fn render_job_watch(stdout: &mut dyn Write, view: &JobWatchView) -> Result<(), R } } else { let table = Table { - headers: &["frame", "time", "state", "signer", "session", "terminal", "summary"], + headers: &[ + "frame", "time", "state", "signer", "session", "terminal", "summary", + ], rows: view .frames .iter() @@ -1087,7 +1099,9 @@ fn render_order_watch(stdout: &mut dyn Write, view: &OrderWatchView) -> Result<( render_owned_pairs(stdout, "watch", rows.as_slice())?; if !view.frames.is_empty() { let table = Table { - headers: &["frame", "time", "state", "signer", "session", "terminal", "summary"], + headers: &[ + "frame", "time", "state", "signer", "session", "terminal", "summary", + ], rows: view .frames .iter() @@ -2131,9 +2145,10 @@ mod tests { RelayEntryView, RelayListView, }; use crate::runtime::config::{ - AccountConfig, AccountSecretContractConfig, IdentityConfig, LocalConfig, LoggingConfig, - MycConfig, OutputConfig, OutputFormat, PathsConfig, RelayConfig, RelayConfigSource, - RelayPublishPolicy, RpcConfig, RuntimeConfig, SignerBackend, SignerConfig, Verbosity, + AccountConfig, AccountSecretContractConfig, HyfConfig, IdentityConfig, LocalConfig, + LoggingConfig, MycConfig, OutputConfig, OutputFormat, PathsConfig, RelayConfig, + RelayConfigSource, RelayPublishPolicy, RpcConfig, RuntimeConfig, SignerBackend, + SignerConfig, Verbosity, }; use crate::runtime::logging::LoggingState; use radroots_secret_vault::RadrootsSecretBackend; @@ -2209,6 +2224,10 @@ mod tests { myc: MycConfig { executable: "myc".into(), }, + hyf: HyfConfig { + enabled: false, + executable: "hyfd".into(), + }, rpc: RpcConfig { url: "http://127.0.0.1:7070".to_owned(), bridge_bearer_token: None, @@ -2236,6 +2255,8 @@ mod tests { ); assert_eq!(view.relay.count, 2); assert_eq!(view.relay.publish_policy, "any"); + assert!(!view.hyf.enabled); + assert_eq!(view.hyf.executable, "hyfd"); assert_eq!( view.account.secret_backend.contract_default_backend, "host_vault" @@ -2343,6 +2364,10 @@ mod tests { myc: MycConfig { executable: "myc".into(), }, + hyf: HyfConfig { + enabled: false, + executable: "hyfd".into(), + }, rpc: RpcConfig { url: "http://127.0.0.1:7070".to_owned(), bridge_bearer_token: None, diff --git a/src/runtime/config.rs b/src/runtime/config.rs @@ -22,6 +22,7 @@ const DEFAULT_LOCAL_DB_FILE: &str = "replica.sqlite"; const DEFAULT_LOCAL_BACKUPS_DIR: &str = "backups"; const DEFAULT_LOCAL_EXPORTS_DIR: &str = "exports"; const DEFAULT_SHARED_ACCOUNTS_STORE_FILE: &str = "store.json"; +const DEFAULT_HYF_EXECUTABLE: &str = "hyfd"; const DEFAULT_RPC_URL: &str = "http://127.0.0.1:7070"; const CLI_PROFILE: &str = "interactive_user"; const CLI_APP_NAMESPACE_VALUE: &str = "cli"; @@ -49,6 +50,8 @@ const ENV_IDENTITY_PATH: &str = "RADROOTS_IDENTITY_PATH"; const ENV_SIGNER: &str = "RADROOTS_SIGNER"; const ENV_RELAYS: &str = "RADROOTS_RELAYS"; const ENV_MYC_EXECUTABLE: &str = "RADROOTS_MYC_EXECUTABLE"; +const ENV_HYF_ENABLED: &str = "RADROOTS_HYF_ENABLED"; +const ENV_HYF_EXECUTABLE: &str = "RADROOTS_HYF_EXECUTABLE"; const ENV_RPC_URL: &str = "RADROOTS_RPC_URL"; const ENV_RPC_BEARER_TOKEN: &str = "RADROOTS_RPC_BEARER_TOKEN"; const SUPPORTED_ENV_FILE_KEYS: &[&str] = &[ @@ -66,6 +69,8 @@ const SUPPORTED_ENV_FILE_KEYS: &[&str] = &[ ENV_SIGNER, ENV_RELAYS, ENV_MYC_EXECUTABLE, + ENV_HYF_ENABLED, + ENV_HYF_EXECUTABLE, ENV_RPC_URL, ENV_RPC_BEARER_TOKEN, ]; @@ -219,6 +224,12 @@ pub struct MycConfig { } #[derive(Debug, Clone, PartialEq, Eq)] +pub struct HyfConfig { + pub enabled: bool, + pub executable: PathBuf, +} + +#[derive(Debug, Clone, PartialEq, Eq)] pub struct RpcConfig { pub url: String, pub bridge_bearer_token: Option<String>, @@ -236,6 +247,7 @@ pub struct RuntimeConfig { pub relay: RelayConfig, pub local: LocalConfig, pub myc: MycConfig, + pub hyf: HyfConfig, pub rpc: RpcConfig, } @@ -261,6 +273,7 @@ struct EnvFileValues(BTreeMap<String, String>); #[derive(Debug, Default, Deserialize)] struct CliConfigFile { relay: Option<RelayFileConfig>, + hyf: Option<HyfFileConfig>, rpc: Option<RpcFileConfig>, } @@ -275,6 +288,12 @@ struct RpcFileConfig { url: Option<String>, } +#[derive(Debug, Default, Deserialize)] +struct HyfFileConfig { + enabled: Option<bool>, + executable: Option<PathBuf>, +} + pub trait Environment { fn var(&self, key: &str) -> Option<String>; fn current_dir(&self) -> Result<PathBuf, RuntimeError>; @@ -421,6 +440,22 @@ impl RuntimeConfig { .or_else(|| env_value(env, env_file, &[ENV_MYC_EXECUTABLE]).map(PathBuf::from)) .unwrap_or_else(|| PathBuf::from("myc")), }, + hyf: HyfConfig { + enabled: resolve_hyf_enabled( + args, + env, + env_file, + app_config.as_ref(), + workspace_config.as_ref(), + )?, + executable: resolve_hyf_executable( + args, + env, + env_file, + app_config.as_ref(), + workspace_config.as_ref(), + ), + }, rpc: resolve_rpc_config( env, env_file, @@ -583,6 +618,68 @@ fn resolve_relay_config( }) } +fn resolve_hyf_enabled( + args: &CliArgs, + env: &dyn Environment, + env_file: &EnvFileValues, + user_config: Option<&CliConfigFile>, + workspace_config: Option<&CliConfigFile>, +) -> Result<bool, RuntimeError> { + match (args.hyf_enabled, args.no_hyf_enabled) { + (true, true) => { + return Err(RuntimeError::Config( + "flags --hyf-enabled and --no-hyf-enabled cannot be used together".to_owned(), + )); + } + (true, false) => return Ok(true), + (false, true) => return Ok(false), + (false, false) => {} + } + + if let Some((key, value)) = env_value_entry(env, env_file, &[ENV_HYF_ENABLED]) { + return parse_bool_env(key.as_str(), value.as_str()); + } + + if let Some(enabled) = user_config + .and_then(|config| config.hyf.as_ref()) + .and_then(|hyf| hyf.enabled) + { + return Ok(enabled); + } + + if let Some(enabled) = workspace_config + .and_then(|config| config.hyf.as_ref()) + .and_then(|hyf| hyf.enabled) + { + return Ok(enabled); + } + + Ok(false) +} + +fn resolve_hyf_executable( + args: &CliArgs, + env: &dyn Environment, + env_file: &EnvFileValues, + user_config: Option<&CliConfigFile>, + workspace_config: Option<&CliConfigFile>, +) -> PathBuf { + args.hyf_executable + .clone() + .or_else(|| env_value(env, env_file, &[ENV_HYF_EXECUTABLE]).map(PathBuf::from)) + .or_else(|| { + user_config + .and_then(|config| config.hyf.as_ref()) + .and_then(|hyf| hyf.executable.clone()) + }) + .or_else(|| { + workspace_config + .and_then(|config| config.hyf.as_ref()) + .and_then(|hyf| hyf.executable.clone()) + }) + .unwrap_or_else(|| PathBuf::from(DEFAULT_HYF_EXECUTABLE)) +} + fn resolve_relay_publish_policy( user_config: Option<&CliConfigFile>, workspace_config: Option<&CliConfigFile>, @@ -916,9 +1013,9 @@ fn parse_bool_env(key: &str, value: &str) -> Result<bool, RuntimeError> { #[cfg(test)] mod tests { use super::{ - AccountConfig, AccountSecretContractConfig, EnvFileValues, Environment, OutputConfig, - OutputFormat, PathsConfig, RelayConfigSource, RelayPublishPolicy, RuntimeConfig, - SignerBackend, Verbosity, parse_env_file_values, + AccountConfig, AccountSecretContractConfig, EnvFileValues, Environment, HyfConfig, + OutputConfig, OutputFormat, PathsConfig, RelayConfigSource, RelayPublishPolicy, + RuntimeConfig, SignerBackend, Verbosity, parse_env_file_values, }; use crate::cli::CliArgs; use clap::Parser; @@ -986,6 +1083,9 @@ mod tests { "wss://relay.two", "--myc-executable", "bin/myc-cli", + "--hyf-enabled", + "--hyf-executable", + "bin/hyfd-cli", "config", "show", ]); @@ -1000,6 +1100,8 @@ mod tests { ("RADROOTS_SIGNER".to_owned(), "myc".to_owned()), ("RADROOTS_RELAYS".to_owned(), "wss://relay.env".to_owned()), ("RADROOTS_MYC_EXECUTABLE".to_owned(), "env-myc".to_owned()), + ("RADROOTS_HYF_ENABLED".to_owned(), "false".to_owned()), + ("RADROOTS_HYF_EXECUTABLE".to_owned(), "env-hyfd".to_owned()), ])); let resolved = RuntimeConfig::resolve_with_env_file(&args, &env, &EnvFileValues::default()) @@ -1081,6 +1183,13 @@ mod tests { assert_eq!(resolved.relay.source, RelayConfigSource::Flags); assert_eq!(resolved.relay.publish_policy, RelayPublishPolicy::Any); assert_eq!(resolved.myc.executable, PathBuf::from("bin/myc-cli")); + assert_eq!( + resolved.hyf, + HyfConfig { + enabled: true, + executable: PathBuf::from("bin/hyfd-cli"), + } + ); } #[test] @@ -1105,6 +1214,8 @@ mod tests { "wss://relay.one,wss://relay.two".to_owned(), ), ("RADROOTS_MYC_EXECUTABLE".to_owned(), "bin/myc".to_owned()), + ("RADROOTS_HYF_ENABLED".to_owned(), "true".to_owned()), + ("RADROOTS_HYF_EXECUTABLE".to_owned(), "bin/hyfd".to_owned()), ])); let resolved = RuntimeConfig::resolve_with_env_file(&args, &env, &EnvFileValues::default()) @@ -1141,6 +1252,13 @@ mod tests { ); assert_eq!(resolved.relay.source, RelayConfigSource::Environment); assert_eq!(resolved.myc.executable, PathBuf::from("bin/myc")); + assert_eq!( + resolved.hyf, + HyfConfig { + enabled: true, + executable: PathBuf::from("bin/hyfd"), + } + ); } #[test] @@ -1156,6 +1274,18 @@ mod tests { let error = RuntimeConfig::resolve_with_env_file(&args, &env, &EnvFileValues::default()) .expect_err("conflicting flags"); assert!(error.to_string().contains("cannot be used together")); + + let hyf_args = CliArgs::parse_from([ + "radroots", + "--hyf-enabled", + "--no-hyf-enabled", + "config", + "show", + ]); + let error = + RuntimeConfig::resolve_with_env_file(&hyf_args, &env, &EnvFileValues::default()) + .expect_err("conflicting hyf flags"); + assert!(error.to_string().contains("--hyf-enabled")); } #[test] @@ -1214,6 +1344,8 @@ RADROOTS_IDENTITY_PATH=state/identity.json RADROOTS_SIGNER=myc RADROOTS_RELAYS=wss://relay.env-file RADROOTS_MYC_EXECUTABLE=bin/myc +RADROOTS_HYF_ENABLED=true +RADROOTS_HYF_EXECUTABLE=bin/hyfd "#, Path::new(".env.test"), ) @@ -1234,6 +1366,13 @@ RADROOTS_MYC_EXECUTABLE=bin/myc assert_eq!(resolved.relay.urls, vec!["wss://relay.env-file".to_owned()]); assert_eq!(resolved.relay.source, RelayConfigSource::Environment); assert_eq!(resolved.myc.executable, PathBuf::from("bin/myc")); + assert_eq!( + resolved.hyf, + HyfConfig { + enabled: true, + executable: PathBuf::from("bin/hyfd"), + } + ); } #[test] @@ -1304,6 +1443,48 @@ RADROOTS_CLI_LOGGING_STDOUT=false } #[test] + fn user_hyf_config_overrides_workspace_hyf_config() { + let temp = tempdir().expect("tempdir"); + let workspace_root = temp.path().join("workspace"); + let user_home = temp.path().join("home"); + fs::create_dir_all(workspace_root.join(".radroots")).expect("workspace config dir"); + fs::create_dir_all(user_home.join(".radroots/config/apps/cli")).expect("app config dir"); + fs::write( + workspace_root.join(".radroots/config.toml"), + "[hyf]\nenabled = false\nexecutable = \"workspace-hyfd\"\n", + ) + .expect("write workspace config"); + fs::write( + user_home.join(".radroots/config/apps/cli/config.toml"), + "[hyf]\nenabled = true\nexecutable = \"user-hyfd\"\n", + ) + .expect("write user config"); + + let env = MapEnvironment { + values: BTreeMap::new(), + current_dir: workspace_root, + path_resolver: RadrootsPathResolver::new( + RadrootsPlatform::Linux, + RadrootsHostEnvironment { + home_dir: Some(user_home), + ..RadrootsHostEnvironment::default() + }, + ), + }; + let args = CliArgs::parse_from(["radroots", "config", "show"]); + + let resolved = RuntimeConfig::resolve_with_env_file(&args, &env, &EnvFileValues::default()) + .expect("resolve config"); + assert_eq!( + resolved.hyf, + HyfConfig { + enabled: true, + executable: PathBuf::from("user-hyfd"), + } + ); + } + + #[test] fn invalid_relay_url_fails() { let args = CliArgs::parse_from([ "radroots", diff --git a/src/runtime/hyf.rs b/src/runtime/hyf.rs @@ -0,0 +1,377 @@ +use std::io::{Read, Write}; +use std::process::{Child, Command, ExitStatus, Output, Stdio}; +use std::thread; +use std::time::{Duration, Instant}; + +use serde_json::{Value, json}; + +use crate::runtime::config::HyfConfig; + +const HYF_STATUS_TIMEOUT: Duration = Duration::from_secs(1); +const HYF_STATUS_POLL_INTERVAL: Duration = Duration::from_millis(10); +const HYF_STATUS_REQUEST_ID: &str = "cli-doctor-hyf-status"; +const HYF_PROTOCOL_VERSION: u64 = 1; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HyfStatusView { + pub executable: String, + pub state: String, + pub source: String, + pub reason: Option<String>, + pub protocol_version: Option<u64>, + pub deterministic_available: Option<bool>, +} + +pub fn resolve_status(config: &HyfConfig) -> HyfStatusView { + let executable = config.executable.display().to_string(); + if !config.enabled { + return HyfStatusView { + executable, + state: "disabled".to_owned(), + source: "hyf status control request · local first".to_owned(), + reason: Some("disabled by config".to_owned()), + protocol_version: None, + deterministic_available: None, + }; + } + + if config.executable.as_os_str().is_empty() { + return unavailable_status( + executable, + "hyf executable path is not configured".to_owned(), + None, + None, + ); + } + + let output = match run_status_command(config) { + Ok(output) => output, + Err(HyfCommandError::NotFound) => { + return unavailable_status( + executable, + format!( + "hyf executable was not found at {}", + config.executable.display() + ), + None, + None, + ); + } + Err(HyfCommandError::Start(error)) => { + return unavailable_status( + executable, + format!( + "failed to start hyf control request at {}: {error}", + config.executable.display() + ), + None, + None, + ); + } + Err(HyfCommandError::Write(error)) => { + return unavailable_status( + executable, + format!("failed to write hyf control request stdin: {error}"), + None, + None, + ); + } + Err(HyfCommandError::Timeout) => { + return unavailable_status( + executable, + format!( + "hyf status control request timed out after {}ms", + HYF_STATUS_TIMEOUT.as_millis() + ), + None, + None, + ); + } + Err(HyfCommandError::Wait(error)) | Err(HyfCommandError::Read(error)) => { + return unavailable_status( + executable, + format!("failed to capture hyf status control output: {error}"), + None, + None, + ); + } + }; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned(); + let reason = match output.status.code() { + Some(code) if stderr.is_empty() => { + format!("hyf status control request exited with status code {code}") + } + Some(code) => { + format!("hyf status control request exited with status code {code}: {stderr}") + } + None if stderr.is_empty() => { + "hyf status control request terminated by signal".to_owned() + } + None => format!("hyf status control request terminated by signal: {stderr}"), + }; + return unavailable_status(executable, reason, None, None); + } + + let stdout = match String::from_utf8(output.stdout) { + Ok(stdout) => stdout, + Err(error) => { + return unavailable_status( + executable, + format!("hyf status output was not valid UTF-8: {error}"), + None, + None, + ); + } + }; + + let payload: Value = match serde_json::from_str(stdout.as_str()) { + Ok(payload) => payload, + Err(error) => { + return unavailable_status( + executable, + format!("hyf status output was not valid JSON: {error}"), + None, + None, + ); + } + }; + + let response_version = payload.get("version").and_then(Value::as_u64); + let request_id = payload.get("request_id").and_then(Value::as_str); + let protocol_version = payload + .get("output") + .and_then(|output| output.get("build_identity")) + .and_then(|identity| identity.get("protocol_version")) + .and_then(Value::as_u64); + let deterministic_available = payload + .get("output") + .and_then(|output| output.get("enabled_execution_modes")) + .and_then(|modes| modes.get("deterministic")) + .and_then(Value::as_bool); + + if response_version != Some(HYF_PROTOCOL_VERSION) { + return unavailable_status( + executable, + format!( + "hyf status response version {:?} is incompatible with cli expected {}", + response_version, HYF_PROTOCOL_VERSION + ), + protocol_version, + deterministic_available, + ); + } + + if request_id != Some(HYF_STATUS_REQUEST_ID) { + return unavailable_status( + executable, + "hyf status response did not preserve the control request id".to_owned(), + protocol_version, + deterministic_available, + ); + } + + if payload.get("ok").and_then(Value::as_bool) != Some(true) { + let reason = payload + .get("error") + .and_then(|error| error.get("code")) + .and_then(Value::as_str) + .map(|code| format!("hyf status control request returned error code {code}")) + .unwrap_or_else(|| { + "hyf status control request returned an invalid error response".to_owned() + }); + return unavailable_status( + executable, + reason, + protocol_version, + deterministic_available, + ); + } + + if protocol_version != Some(HYF_PROTOCOL_VERSION) { + return unavailable_status( + executable, + format!( + "hyf protocol version {:?} is incompatible with cli expected {}", + protocol_version, HYF_PROTOCOL_VERSION + ), + protocol_version, + deterministic_available, + ); + } + + if deterministic_available != Some(true) { + return unavailable_status( + executable, + "hyf deterministic execution is unavailable".to_owned(), + protocol_version, + deterministic_available, + ); + } + + HyfStatusView { + executable, + state: "ready".to_owned(), + source: "hyf status control request · local first".to_owned(), + reason: Some("healthy · protocol 1 · deterministic available".to_owned()), + protocol_version, + deterministic_available, + } +} + +fn run_status_command(config: &HyfConfig) -> Result<Output, HyfCommandError> { + let mut child = Command::new(&config.executable) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|error| match error.kind() { + std::io::ErrorKind::NotFound => HyfCommandError::NotFound, + _ => HyfCommandError::Start(error), + })?; + + if let Some(mut stdin) = child.stdin.take() { + let request = json!({ + "version": HYF_PROTOCOL_VERSION, + "request_id": HYF_STATUS_REQUEST_ID, + "trace_id": HYF_STATUS_REQUEST_ID, + "capability": "sys.status", + "input": {} + }); + writeln!(stdin, "{request}").map_err(HyfCommandError::Write)?; + } + + let started_at = Instant::now(); + loop { + match child.try_wait() { + Ok(Some(status)) => return collect_output(child, status), + Ok(None) => { + if started_at.elapsed() >= HYF_STATUS_TIMEOUT { + let _ = child.kill(); + let _ = child.wait(); + return Err(HyfCommandError::Timeout); + } + thread::sleep(HYF_STATUS_POLL_INTERVAL); + } + Err(error) => { + let _ = child.kill(); + let _ = child.wait(); + return Err(HyfCommandError::Wait(error)); + } + } + } +} + +fn collect_output(mut child: Child, status: ExitStatus) -> Result<Output, HyfCommandError> { + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + + if let Some(mut pipe) = child.stdout.take() { + pipe.read_to_end(&mut stdout) + .map_err(HyfCommandError::Read)?; + } + if let Some(mut pipe) = child.stderr.take() { + pipe.read_to_end(&mut stderr) + .map_err(HyfCommandError::Read)?; + } + + Ok(Output { + status, + stdout, + stderr, + }) +} + +fn unavailable_status( + executable: String, + reason: String, + protocol_version: Option<u64>, + deterministic_available: Option<bool>, +) -> HyfStatusView { + HyfStatusView { + executable, + state: "unavailable".to_owned(), + source: "hyf status control request · local first".to_owned(), + reason: Some(reason), + protocol_version, + deterministic_available, + } +} + +enum HyfCommandError { + NotFound, + Start(std::io::Error), + Write(std::io::Error), + Wait(std::io::Error), + Read(std::io::Error), + Timeout, +} + +#[cfg(test)] +mod tests { + use super::{HYF_PROTOCOL_VERSION, resolve_status}; + use crate::runtime::config::HyfConfig; + use std::fs; + use std::os::unix::fs::PermissionsExt; + use tempfile::tempdir; + + #[test] + fn disabled_hyf_reports_disabled_state_without_spawning() { + let view = resolve_status(&HyfConfig { + enabled: false, + executable: "hyfd".into(), + }); + assert_eq!(view.state, "disabled"); + assert_eq!(view.reason.as_deref(), Some("disabled by config")); + } + + #[test] + fn healthy_hyf_status_reports_ready() { + let dir = tempdir().expect("tempdir"); + let executable = write_script( + dir.path(), + format!( + "#!/bin/sh\nread -r _request || exit 64\ncat <<'JSON'\n{{\"version\":{HYF_PROTOCOL_VERSION},\"request_id\":\"cli-doctor-hyf-status\",\"trace_id\":\"cli-doctor-hyf-status\",\"ok\":true,\"output\":{{\"build_identity\":{{\"protocol_version\":{HYF_PROTOCOL_VERSION}}},\"enabled_execution_modes\":{{\"deterministic\":true}}}}}}\nJSON\n" + ) + .as_str(), + ); + + let view = resolve_status(&HyfConfig { + enabled: true, + executable, + }); + assert_eq!(view.state, "ready"); + assert_eq!(view.protocol_version, Some(HYF_PROTOCOL_VERSION)); + assert_eq!(view.deterministic_available, Some(true)); + } + + #[test] + fn incompatible_hyf_status_reports_unavailable() { + let dir = tempdir().expect("tempdir"); + let executable = write_script( + dir.path(), + "#!/bin/sh\nread -r _request || exit 64\ncat <<'JSON'\n{\"version\":1,\"request_id\":\"cli-doctor-hyf-status\",\"trace_id\":\"cli-doctor-hyf-status\",\"ok\":true,\"output\":{\"build_identity\":{\"protocol_version\":2},\"enabled_execution_modes\":{\"deterministic\":true}}}\nJSON\n", + ); + + let view = resolve_status(&HyfConfig { + enabled: true, + executable, + }); + assert_eq!(view.state, "unavailable"); + assert!( + view.reason + .as_deref() + .is_some_and(|reason| reason.contains("incompatible")) + ); + } + + fn write_script(dir: &std::path::Path, script: &str) -> std::path::PathBuf { + let path = dir.join("fake-hyfd"); + fs::write(&path, script).expect("write fake hyfd"); + let mut permissions = fs::metadata(&path).expect("metadata").permissions(); + permissions.set_mode(0o755); + fs::set_permissions(&path, permissions).expect("chmod fake hyfd"); + path + } +} diff --git a/src/runtime/mod.rs b/src/runtime/mod.rs @@ -2,6 +2,7 @@ pub mod accounts; pub mod config; pub mod daemon; pub mod find; +pub mod hyf; pub mod job; pub mod listing; pub mod local;