cli

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

commit 1be924c14828056a5333e90258ea5705f63fdb11
parent 6ae4e02db72e79b6047ef9c6e574b640a90302a6
Author: triesap <tyson@radroots.org>
Date:   Sat, 11 Apr 2026 18:31:18 +0000

hyf: harden cli business request path

Diffstat:
Msrc/runtime/find.rs | 2+-
Msrc/runtime/hyf.rs | 65+++++++++++++++++++++++++++++++++++++++++++----------------------
Mtests/find.rs | 61+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 105 insertions(+), 23 deletions(-)

diff --git a/src/runtime/find.rs b/src/runtime/find.rs @@ -163,7 +163,7 @@ fn attempt_query_rewrite( return None; } - let client = hyf::resolve_ready_runtime_client(config).ok()?; + let client = hyf::resolve_runtime_client(config).ok()?; let response = client .query_rewrite( FIND_HYF_QUERY_REWRITE_REQUEST_ID, diff --git a/src/runtime/hyf.rs b/src/runtime/hyf.rs @@ -14,8 +14,9 @@ use crate::runtime::config::{ CapabilityBindingTargetKind, HyfConfig, INFERENCE_HYF_STDIO_CAPABILITY, RuntimeConfig, }; -const HYF_STATUS_TIMEOUT: Duration = Duration::from_secs(1); -const HYF_STATUS_POLL_INTERVAL: Duration = Duration::from_millis(10); +const HYF_CONTROL_TIMEOUT: Duration = Duration::from_secs(2); +const HYF_BUSINESS_TIMEOUT: Duration = Duration::from_secs(4); +const HYF_TIMEOUT_POLL_INTERVAL: Duration = Duration::from_millis(10); const HYF_STATUS_REQUEST_ID: &str = "cli-doctor-hyf-status"; const HYF_CAPABILITIES_REQUEST_ID: &str = "cli-runtime-hyf-capabilities"; const HYF_SOURCE: &str = "hyf status control request ยท local first"; @@ -53,6 +54,7 @@ impl HyfClient { "sys.status", None, &HyfEmptyInput::default(), + HYF_CONTROL_TIMEOUT, ) } @@ -63,6 +65,7 @@ impl HyfClient { "sys.capabilities", None, &HyfEmptyInput::default(), + HYF_CONTROL_TIMEOUT, ) } @@ -79,6 +82,7 @@ impl HyfClient { "query_rewrite", Some(context), request, + HYF_BUSINESS_TIMEOUT, ) } @@ -95,6 +99,7 @@ impl HyfClient { "semantic_rank", Some(context), request, + HYF_BUSINESS_TIMEOUT, ) } @@ -111,6 +116,7 @@ impl HyfClient { "explain_result", Some(context), request, + HYF_BUSINESS_TIMEOUT, ) } @@ -121,6 +127,7 @@ impl HyfClient { capability: &str, context: Option<&HyfRequestContext>, input: &TRequest, + timeout: Duration, ) -> Result<HyfSuccess<TResponse>, HyfClientError> where TRequest: Serialize, @@ -129,7 +136,7 @@ impl HyfClient { let request = serialize_request(request_id, trace_id, capability, context, input) .map_err(HyfClientError::SerializeRequest)?; - let output = self.run_request(request.as_str())?; + let output = self.run_request(request.as_str(), timeout)?; let stdout = String::from_utf8(output.stdout).map_err(HyfClientError::InvalidUtf8)?; let response: HyfWireResponse<TResponse> = serde_json::from_str(stdout.as_str()).map_err(HyfClientError::InvalidJson)?; @@ -159,7 +166,7 @@ impl HyfClient { }) } - fn run_request(&self, request: &str) -> Result<Output, HyfClientError> { + fn run_request(&self, request: &str, timeout: Duration) -> Result<Output, HyfClientError> { let mut child = Command::new(&self.executable) .stdin(Stdio::piped()) .stdout(Stdio::piped()) @@ -174,7 +181,7 @@ impl HyfClient { writeln!(stdin, "{request}").map_err(HyfClientError::Write)?; } - let output = collect_output_with_timeout(child)?; + let output = collect_output_with_timeout(child, timeout)?; if !output.status.success() { return Err(HyfClientError::NonZeroExit { status: output.status.code(), @@ -510,16 +517,6 @@ pub fn resolve_runtime_client(config: &RuntimeConfig) -> Result<HyfClient, HyfSt } } -pub fn resolve_ready_runtime_client(config: &RuntimeConfig) -> Result<HyfClient, HyfStatusView> { - let client = resolve_runtime_client(config)?; - let status = resolve_status_for_client(&client); - if status.state == "ready" { - Ok(client) - } else { - Err(status) - } -} - pub fn resolve_runtime_status(config: &RuntimeConfig) -> HyfStatusView { match resolve_runtime_client(config) { Ok(client) => resolve_status_for_client(&client), @@ -700,18 +697,18 @@ fn resolve_status_for_client(client: &HyfClient) -> HyfStatusView { } } -fn collect_output_with_timeout(mut child: Child) -> Result<Output, HyfClientError> { +fn collect_output_with_timeout(mut child: Child, timeout: Duration) -> Result<Output, HyfClientError> { 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 { + if started_at.elapsed() >= timeout { let _ = child.kill(); let _ = child.wait(); - return Err(HyfClientError::Timeout(HYF_STATUS_TIMEOUT.as_millis())); + return Err(HyfClientError::Timeout(timeout.as_millis())); } - thread::sleep(HYF_STATUS_POLL_INTERVAL); + thread::sleep(HYF_TIMEOUT_POLL_INTERVAL); } Err(error) => { let _ = child.kill(); @@ -785,9 +782,9 @@ fn format_nonzero_exit(request_label: &str, status: Option<i32>, stderr: &str) - #[cfg(test)] mod tests { use super::{ - HYF_PROTOCOL_VERSION, HyfClient, HyfEmptyInput, HyfExplainResultRequest, - HyfQueryRewriteRequest, HyfRequestContext, HyfSemanticCandidate, HyfSemanticRankRequest, - resolve_status, + HYF_PROTOCOL_VERSION, HyfClient, HyfClientError, HyfEmptyInput, + HyfExplainResultRequest, HyfQueryRewriteRequest, HyfRequestContext, + HyfSemanticCandidate, HyfSemanticRankRequest, resolve_status, }; use crate::runtime::config::HyfConfig; use serde::Serialize; @@ -931,6 +928,30 @@ mod tests { } #[test] + fn business_requests_use_a_longer_timeout_than_control_requests() { + let _guard = hyf_test_lock().lock().expect("hyf test lock"); + let dir = tempdir().expect("tempdir"); + let executable = write_script( + dir.path(), + "#!/bin/sh\nread -r request || exit 64\ncase \"$request\" in\n *'\"capability\":\"sys.status\"'*)\n sleep 3\n cat <<'JSON'\n{\"version\":1,\"request_id\":\"cli-doctor-hyf-status\",\"trace_id\":\"cli-doctor-hyf-status\",\"ok\":true,\"output\":{\"build_identity\":{\"protocol_version\":1},\"enabled_execution_modes\":{\"deterministic\":true}}}\nJSON\n ;;\n *'\"capability\":\"query_rewrite\"'*)\n sleep 3\n cat <<'JSON'\n{\"version\":1,\"request_id\":\"rewrite-timeout-test\",\"trace_id\":\"rewrite-timeout-test\",\"ok\":true,\"output\":{\"original_text\":\"henhouse\",\"normalized_text\":\"henhouse\",\"rewritten_text\":\"eggs\",\"query_terms\":[\"eggs\"],\"normalization_signals\":[\"query_rewrite\"],\"ranking_hints\":[\"local_first\"],\"extracted_filters\":{\"local_intent\":false,\"fulfillment\":\"any\",\"time_window\":\"any\"}}}\nJSON\n ;;\n *)\n exit 65\n ;;\nesac\n", + ); + let client = HyfClient::new(executable); + + let status = client.status().expect_err("status should time out"); + assert!(matches!(status, HyfClientError::Timeout(2000))); + + let rewrite = client + .query_rewrite( + "rewrite-timeout-test", + Some("rewrite-timeout-test"), + &HyfRequestContext::deterministic_cli(), + &HyfQueryRewriteRequest::new("henhouse"), + ) + .expect("query rewrite should use longer timeout"); + assert_eq!(rewrite.output.rewritten_text, "eggs"); + } + + #[test] fn semantic_rank_request_round_trips_typed_output() { let _guard = hyf_test_lock().lock().expect("hyf test lock"); let dir = tempdir().expect("tempdir"); diff --git a/tests/find.rs b/tests/find.rs @@ -250,6 +250,48 @@ fn find_uses_hyf_query_rewrite_when_available() { } #[test] +fn find_uses_hyf_query_rewrite_without_status_preflight() { + let dir = tempdir().expect("tempdir"); + let init = cli_command_in(dir.path()) + .args(["local", "init"]) + .output() + .expect("run local init"); + assert!(init.status.success()); + + seed_trade_product( + dir.path(), + "00000000-0000-0000-0000-000000000106", + "fresh-eggs", + "protein", + "Fresh Eggs", + "Pasture-raised eggs", + 36, + 24, + Some("Marshall"), + ); + + let hyfd = write_fake_hyfd_with_failing_status( + dir.path(), + r#"{"version":1,"request_id":"cli-find-query-rewrite","trace_id":"cli-find-query-rewrite","ok":true,"output":{"original_text":"henhouse","normalized_text":"henhouse","rewritten_text":"eggs","query_terms":["eggs"],"normalization_signals":["query_rewrite"],"ranking_hints":["local_first"],"extracted_filters":{"local_intent":false,"fulfillment":"any","time_window":"any"}}}"#, + ); + + let output = cli_command_in(dir.path()) + .env("RADROOTS_HYF_ENABLED", "true") + .env("RADROOTS_HYF_EXECUTABLE", &hyfd) + .args(["--json", "find", "henhouse"]) + .output() + .expect("run hyf json find"); + + assert!(output.status.success()); + let json: Value = serde_json::from_slice(output.stdout.as_slice()).expect("json"); + assert_eq!(json["state"], "ready"); + assert_eq!(json["count"], 1); + assert_eq!(json["hyf"]["state"], "query_rewrite_applied"); + assert_eq!(json["hyf"]["rewritten_query"], "eggs"); + assert_eq!(json["results"][0]["title"], "Fresh Eggs"); +} + +#[test] fn find_falls_back_cleanly_when_hyf_is_unavailable() { let dir = tempdir().expect("tempdir"); let init = cli_command_in(dir.path()) @@ -391,3 +433,22 @@ fn write_fake_hyfd( } path } + +fn write_fake_hyfd_with_failing_status( + workdir: &Path, + rewrite_response: &str, +) -> std::path::PathBuf { + let path = workdir.join("fake-hyfd"); + let script = format!( + "#!/bin/sh\nread -r request || exit 64\ncase \"$request\" in\n *'\"capability\":\"sys.status\"'*)\n echo \"status should not be called\" >&2\n exit 23\n ;;\n *'\"capability\":\"query_rewrite\"'*)\n cat <<'JSON'\n{rewrite_response}\nJSON\n ;;\n *)\n cat <<'JSON'\n{{\"version\":1,\"request_id\":\"unexpected\",\"ok\":false,\"error\":{{\"code\":\"unsupported_capability\",\"message\":\"unexpected request\"}}}}\nJSON\n ;;\nesac\n" + ); + std::fs::write(&path, script).expect("write fake hyfd"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut permissions = std::fs::metadata(&path).expect("metadata").permissions(); + permissions.set_mode(0o755); + std::fs::set_permissions(&path, permissions).expect("chmod fake hyfd"); + } + path +}