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:
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;