cli

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

commit ad5669f3a1cfefe8c5738bfc1bab8a56e6fb628a
parent 4faedf460990e56c744578d615c25ca666a2cc26
Author: triesap <tyson@radroots.org>
Date:   Fri, 10 Apr 2026 23:18:52 +0000

hyf: add typed cli stdio client

Diffstat:
Msrc/runtime/hyf.rs | 867++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------
1 file changed, 731 insertions(+), 136 deletions(-)

diff --git a/src/runtime/hyf.rs b/src/runtime/hyf.rs @@ -1,9 +1,14 @@ +#![cfg_attr(not(test), allow(dead_code))] + +use std::collections::BTreeMap; use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; use std::process::{Child, Command, ExitStatus, Output, Stdio}; use std::thread; use std::time::{Duration, Instant}; -use serde_json::{Value, json}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; use crate::runtime::config::{ CapabilityBindingTargetKind, HyfConfig, INFERENCE_HYF_STDIO_CAPABILITY, RuntimeConfig, @@ -12,7 +17,10 @@ use crate::runtime::config::{ 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_CAPABILITIES_REQUEST_ID: &str = "cli-runtime-hyf-capabilities"; +const HYF_SOURCE: &str = "hyf status control request · local first"; const HYF_PROTOCOL_VERSION: u64 = 1; +const HYF_CONSUMER: &str = "radroots-cli"; #[derive(Debug, Clone, PartialEq, Eq)] pub struct HyfStatusView { @@ -24,22 +32,463 @@ pub struct HyfStatusView { pub deterministic_available: Option<bool>, } -pub fn resolve_runtime_status(config: &RuntimeConfig) -> HyfStatusView { +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HyfClient { + executable: PathBuf, +} + +impl HyfClient { + pub fn new(executable: PathBuf) -> Self { + Self { executable } + } + + pub fn executable(&self) -> &Path { + self.executable.as_path() + } + + pub fn status(&self) -> Result<HyfSuccess<HyfStatusOutput>, HyfClientError> { + self.call( + HYF_STATUS_REQUEST_ID, + Some(HYF_STATUS_REQUEST_ID), + "sys.status", + None, + &HyfEmptyInput::default(), + ) + } + + pub fn capabilities(&self) -> Result<HyfSuccess<HyfCapabilitiesOutput>, HyfClientError> { + self.call( + HYF_CAPABILITIES_REQUEST_ID, + None, + "sys.capabilities", + None, + &HyfEmptyInput::default(), + ) + } + + pub fn query_rewrite( + &self, + request_id: &str, + trace_id: Option<&str>, + context: &HyfRequestContext, + request: &HyfQueryRewriteRequest, + ) -> Result<HyfSuccess<HyfQueryRewriteOutput>, HyfClientError> { + self.call( + request_id, + trace_id, + "query_rewrite", + Some(context), + request, + ) + } + + pub fn semantic_rank( + &self, + request_id: &str, + trace_id: Option<&str>, + context: &HyfRequestContext, + request: &HyfSemanticRankRequest, + ) -> Result<HyfSuccess<HyfSemanticRankOutput>, HyfClientError> { + self.call( + request_id, + trace_id, + "semantic_rank", + Some(context), + request, + ) + } + + pub fn explain_result( + &self, + request_id: &str, + trace_id: Option<&str>, + context: &HyfRequestContext, + request: &HyfExplainResultRequest, + ) -> Result<HyfSuccess<HyfExplainResultOutput>, HyfClientError> { + self.call( + request_id, + trace_id, + "explain_result", + Some(context), + request, + ) + } + + fn call<TRequest, TResponse>( + &self, + request_id: &str, + trace_id: Option<&str>, + capability: &str, + context: Option<&HyfRequestContext>, + input: &TRequest, + ) -> Result<HyfSuccess<TResponse>, HyfClientError> + where + TRequest: Serialize, + TResponse: for<'de> Deserialize<'de>, + { + let request = serde_json::to_string(&HyfRequestEnvelope { + version: HYF_PROTOCOL_VERSION, + request_id, + trace_id, + capability, + context, + input, + }) + .map_err(HyfClientError::SerializeRequest)?; + + let output = self.run_request(request.as_str())?; + 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)?; + + if !response.ok { + return Err(HyfClientError::RemoteError { + code: response.error.as_ref().and_then(|error| error.code.clone()), + message: response + .error + .as_ref() + .and_then(|error| error.message.clone()), + }); + } + + let Some(output) = response.output else { + return Err(HyfClientError::InvalidResponse( + "hyf response omitted output for a successful request".to_owned(), + )); + }; + + Ok(HyfSuccess { + version: response.version, + request_id: response.request_id, + trace_id: response.trace_id, + output, + meta: response.meta, + }) + } + + fn run_request(&self, request: &str) -> Result<Output, HyfClientError> { + let mut child = Command::new(&self.executable) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|error| match error.kind() { + std::io::ErrorKind::NotFound => HyfClientError::NotFound, + _ => HyfClientError::Start(error), + })?; + + if let Some(mut stdin) = child.stdin.take() { + writeln!(stdin, "{request}").map_err(HyfClientError::Write)?; + } + + let output = collect_output_with_timeout(child)?; + if !output.status.success() { + return Err(HyfClientError::NonZeroExit { + status: output.status.code(), + stderr: String::from_utf8_lossy(&output.stderr).trim().to_owned(), + }); + } + Ok(output) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HyfSuccess<T> { + pub version: u64, + pub request_id: String, + pub trace_id: Option<String>, + pub output: T, + pub meta: Option<Value>, +} + +#[derive(Debug, thiserror::Error)] +pub enum HyfClientError { + #[error("hyf executable was not found")] + NotFound, + #[error("failed to start hyf request: {0}")] + Start(std::io::Error), + #[error("failed to write hyf request stdin: {0}")] + Write(std::io::Error), + #[error("failed to wait on hyf request: {0}")] + Wait(std::io::Error), + #[error("failed to read hyf request output: {0}")] + Read(std::io::Error), + #[error("hyf request timed out after {0}ms")] + Timeout(u128), + #[error("hyf request exited unsuccessfully")] + NonZeroExit { status: Option<i32>, stderr: String }, + #[error("failed to serialize hyf request: {0}")] + SerializeRequest(serde_json::Error), + #[error("hyf response was not valid UTF-8: {0}")] + InvalidUtf8(std::string::FromUtf8Error), + #[error("hyf response was not valid JSON: {0}")] + InvalidJson(serde_json::Error), + #[error("{0}")] + InvalidResponse(String), + #[error("hyf request returned a remote error")] + RemoteError { + code: Option<String>, + message: Option<String>, + }, +} + +#[derive(Debug, Clone, Default, Serialize, PartialEq, Eq)] +pub struct HyfRequestContext { + #[serde(skip_serializing_if = "Option::is_none")] + pub consumer: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub execution_mode_preference: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub scope: Option<HyfRequestScope>, + #[serde(skip_serializing_if = "Option::is_none")] + pub return_provenance: Option<bool>, +} + +impl HyfRequestContext { + pub fn deterministic_cli() -> Self { + Self { + consumer: Some(HYF_CONSUMER.to_owned()), + execution_mode_preference: Some("deterministic".to_owned()), + scope: None, + return_provenance: None, + } + } + + pub fn with_return_provenance(mut self, return_provenance: bool) -> Self { + self.return_provenance = Some(return_provenance); + self + } + + pub fn with_listing_scope(mut self, listing_ids: Vec<String>) -> Self { + self.scope = if listing_ids.is_empty() { + None + } else { + Some(HyfRequestScope { listing_ids }) + }; + self + } +} + +#[derive(Debug, Clone, Default, Serialize, PartialEq, Eq)] +pub struct HyfRequestScope { + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub listing_ids: Vec<String>, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct HyfQueryRewriteRequest { + pub query: String, +} + +impl HyfQueryRewriteRequest { + pub fn new(query: impl Into<String>) -> Self { + Self { + query: query.into(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct HyfSemanticCandidate { + pub id: String, + pub title: String, + pub farm: String, + pub delivery: String, + pub distance_km: f64, + pub freshness_minutes: i64, +} + +#[derive(Debug, Clone, Serialize, PartialEq)] +pub struct HyfSemanticRankRequest { + pub query: String, + pub candidates: Vec<HyfSemanticCandidate>, +} + +impl HyfSemanticRankRequest { + pub fn new(query: impl Into<String>, candidates: Vec<HyfSemanticCandidate>) -> Self { + Self { + query: query.into(), + candidates, + } + } +} + +#[derive(Debug, Clone, Serialize, PartialEq)] +pub struct HyfExplainResultRequest { + pub query: String, + pub candidate: HyfSemanticCandidate, +} + +impl HyfExplainResultRequest { + pub fn new(query: impl Into<String>, candidate: HyfSemanticCandidate) -> Self { + Self { + query: query.into(), + candidate, + } + } +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct HyfBuildIdentity { + pub protocol_version: u64, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct HyfExecutionModes { + pub deterministic: bool, + #[serde(default)] + pub assisted: Option<bool>, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct HyfStatusOutput { + pub build_identity: HyfBuildIdentity, + pub enabled_execution_modes: HyfExecutionModes, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct HyfRequestContextContract { + pub accepted_features: Vec<String>, + pub effective_features: Vec<String>, + pub unsupported_field_behavior: String, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct HyfBusinessCapability { + pub id: String, + pub kind: String, + pub deterministic_execution: String, + pub implementation_status: String, + pub callable: bool, + pub implemented: bool, + pub assisted_execution: String, + pub assisted_backend_available: bool, + #[serde(default)] + pub disabled_reason: Option<String>, +} + +#[derive(Debug, Clone, Deserialize, PartialEq)] +pub struct HyfCapabilitiesOutput { + pub control_routes: Vec<String>, + pub business_capabilities: Vec<HyfBusinessCapability>, + pub assisted_backend_capabilities: Vec<Value>, + pub request_context_contract: HyfRequestContextContract, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct HyfExtractedFilters { + pub local_intent: bool, + pub fulfillment: String, + pub time_window: String, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct HyfQueryRewriteOutput { + pub original_text: String, + pub normalized_text: String, + pub rewritten_text: String, + pub query_terms: Vec<String>, + pub normalization_signals: Vec<String>, + pub ranking_hints: Vec<String>, + pub extracted_filters: HyfExtractedFilters, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct HyfScoredCandidate { + pub id: String, + pub heuristic_score: i64, + pub matched_terms: Vec<String>, + pub reasons: Vec<String>, + pub delivery_alignment: String, + pub distance_band: String, + pub freshness_band: String, + pub scope_match: bool, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct HyfSemanticRankOutput { + pub ranked_ids: Vec<String>, + pub reasons: BTreeMap<String, Vec<String>>, + pub scored_candidates: Vec<HyfScoredCandidate>, + pub ranking_hints: Vec<String>, + pub extracted_filters: HyfExtractedFilters, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct HyfSignalAssessment { + pub delivery_alignment: String, + pub distance_band: String, + pub freshness_band: String, + pub scope_match: bool, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct HyfExplainResultOutput { + pub result_id: String, + pub explanation_kind: String, + pub summary: String, + pub score: i64, + pub reasons: Vec<String>, + pub matched_terms: Vec<String>, + pub ranking_hints: Vec<String>, + pub extracted_filters: HyfExtractedFilters, + pub signal_assessment: HyfSignalAssessment, +} + +#[derive(Debug, Clone, Serialize, Default)] +struct HyfEmptyInput {} + +#[derive(Debug, Serialize)] +struct HyfRequestEnvelope<'a, T> { + version: u64, + request_id: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + trace_id: Option<&'a str>, + capability: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + context: Option<&'a HyfRequestContext>, + input: &'a T, +} + +#[derive(Debug, Deserialize)] +#[serde(bound(deserialize = "T: Deserialize<'de>"))] +struct HyfWireResponse<T> { + version: u64, + request_id: String, + #[serde(default)] + trace_id: Option<String>, + ok: bool, + #[serde(default)] + output: Option<T>, + #[serde(default)] + meta: Option<Value>, + #[serde(default)] + error: Option<HyfWireError>, +} + +#[derive(Debug, Clone, Deserialize)] +struct HyfWireError { + #[serde(default)] + code: Option<String>, + #[serde(default)] + message: Option<String>, +} + +pub fn resolve_runtime_client(config: &RuntimeConfig) -> Result<HyfClient, HyfStatusView> { if !config.hyf.enabled { - return resolve_status(&config.hyf); + return Err(disabled_status(config.hyf.executable.display().to_string())); } let Some(binding) = config.capability_binding(INFERENCE_HYF_STDIO_CAPABILITY) else { - return resolve_status(&config.hyf); + return resolve_client(&config.hyf); }; match binding.target_kind { - CapabilityBindingTargetKind::ExplicitEndpoint => { - let mut hyf = config.hyf.clone(); - hyf.executable = binding.target.clone().into(); - resolve_status(&hyf) - } - CapabilityBindingTargetKind::ManagedInstance => unavailable_status( + CapabilityBindingTargetKind::ExplicitEndpoint => resolve_client(&HyfConfig { + enabled: true, + executable: binding.target.clone().into(), + }), + CapabilityBindingTargetKind::ManagedInstance => Err(unavailable_status( config.hyf.executable.display().to_string(), format!( "configured hyf binding target `{}` uses unsupported target_kind `managed_instance`; use `explicit_endpoint` for `inference.hyf_stdio`", @@ -47,57 +496,69 @@ pub fn resolve_runtime_status(config: &RuntimeConfig) -> HyfStatusView { ), None, None, - ), + )), + } +} + +pub fn resolve_runtime_status(config: &RuntimeConfig) -> HyfStatusView { + match resolve_runtime_client(config) { + Ok(client) => resolve_status_for_client(&client), + Err(view) => view, } } pub fn resolve_status(config: &HyfConfig) -> HyfStatusView { + match resolve_client(config) { + Ok(client) => resolve_status_for_client(&client), + Err(view) => view, + } +} + +fn resolve_client(config: &HyfConfig) -> Result<HyfClient, 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, - }; + return Err(disabled_status(executable)); } if config.executable.as_os_str().is_empty() { - return unavailable_status( + return Err(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) => { + Ok(HyfClient::new(config.executable.clone())) +} + +fn resolve_status_for_client(client: &HyfClient) -> HyfStatusView { + let executable = client.executable().display().to_string(); + let response = match client.status() { + Ok(response) => response, + Err(HyfClientError::NotFound) => { return unavailable_status( executable, format!( "hyf executable was not found at {}", - config.executable.display() + client.executable().display() ), None, None, ); } - Err(HyfCommandError::Start(error)) => { + Err(HyfClientError::Start(error)) => { return unavailable_status( executable, format!( "failed to start hyf control request at {}: {error}", - config.executable.display() + client.executable().display() ), None, None, ); } - Err(HyfCommandError::Write(error)) => { + Err(HyfClientError::Write(error)) => { return unavailable_status( executable, format!("failed to write hyf control request stdin: {error}"), @@ -105,18 +566,15 @@ pub fn resolve_status(config: &HyfConfig) -> HyfStatusView { None, ); } - Err(HyfCommandError::Timeout) => { + Err(HyfClientError::Timeout(timeout_ms)) => { return unavailable_status( executable, - format!( - "hyf status control request timed out after {}ms", - HYF_STATUS_TIMEOUT.as_millis() - ), + format!("hyf status control request timed out after {timeout_ms}ms"), None, None, ); } - Err(HyfCommandError::Wait(error)) | Err(HyfCommandError::Read(error)) => { + Err(HyfClientError::Wait(error)) | Err(HyfClientError::Read(error)) => { return unavailable_status( executable, format!("failed to capture hyf status control output: {error}"), @@ -124,28 +582,15 @@ pub fn resolve_status(config: &HyfConfig) -> HyfStatusView { 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) => { + Err(HyfClientError::NonZeroExit { status, stderr }) => { + return unavailable_status( + executable, + format_nonzero_exit("hyf status control request", status, stderr.as_str()), + None, + None, + ); + } + Err(HyfClientError::InvalidUtf8(error)) => { return unavailable_status( executable, format!("hyf status output was not valid UTF-8: {error}"), @@ -153,11 +598,7 @@ pub fn resolve_status(config: &HyfConfig) -> HyfStatusView { None, ); } - }; - - let payload: Value = match serde_json::from_str(stdout.as_str()) { - Ok(payload) => payload, - Err(error) => { + Err(HyfClientError::InvalidJson(error)) => { return unavailable_status( executable, format!("hyf status output was not valid JSON: {error}"), @@ -165,34 +606,41 @@ pub fn resolve_status(config: &HyfConfig) -> HyfStatusView { None, ); } + Err(HyfClientError::RemoteError { code, .. }) => { + let reason = code + .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, None, None); + } + Err(HyfClientError::SerializeRequest(_) | HyfClientError::InvalidResponse(_)) => { + return unavailable_status( + executable, + "hyf status control request returned an invalid error response".to_owned(), + 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) { + let protocol_version = Some(response.output.build_identity.protocol_version); + let deterministic_available = Some(response.output.enabled_execution_modes.deterministic); + + if response.version != HYF_PROTOCOL_VERSION { return unavailable_status( executable, format!( "hyf status response version {:?} is incompatible with cli expected {}", - response_version, HYF_PROTOCOL_VERSION + Some(response.version), + HYF_PROTOCOL_VERSION ), protocol_version, deterministic_available, ); } - if request_id != Some(HYF_STATUS_REQUEST_ID) { + if response.request_id != HYF_STATUS_REQUEST_ID { return unavailable_status( executable, "hyf status response did not preserve the control request id".to_owned(), @@ -201,23 +649,6 @@ pub fn resolve_status(config: &HyfConfig) -> HyfStatusView { ); } - 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, @@ -242,35 +673,14 @@ pub fn resolve_status(config: &HyfConfig) -> HyfStatusView { HyfStatusView { executable, state: "ready".to_owned(), - source: "hyf status control request · local first".to_owned(), + source: HYF_SOURCE.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)?; - } - +fn collect_output_with_timeout(mut child: Child) -> Result<Output, HyfClientError> { let started_at = Instant::now(); loop { match child.try_wait() { @@ -279,30 +689,30 @@ fn run_status_command(config: &HyfConfig) -> Result<Output, HyfCommandError> { if started_at.elapsed() >= HYF_STATUS_TIMEOUT { let _ = child.kill(); let _ = child.wait(); - return Err(HyfCommandError::Timeout); + return Err(HyfClientError::Timeout(HYF_STATUS_TIMEOUT.as_millis())); } thread::sleep(HYF_STATUS_POLL_INTERVAL); } Err(error) => { let _ = child.kill(); let _ = child.wait(); - return Err(HyfCommandError::Wait(error)); + return Err(HyfClientError::Wait(error)); } } } } -fn collect_output(mut child: Child, status: ExitStatus) -> Result<Output, HyfCommandError> { +fn collect_output(mut child: Child, status: ExitStatus) -> Result<Output, HyfClientError> { 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)?; + .map_err(HyfClientError::Read)?; } if let Some(mut pipe) = child.stderr.take() { pipe.read_to_end(&mut stderr) - .map_err(HyfCommandError::Read)?; + .map_err(HyfClientError::Read)?; } Ok(Output { @@ -312,6 +722,17 @@ fn collect_output(mut child: Child, status: ExitStatus) -> Result<Output, HyfCom }) } +fn disabled_status(executable: String) -> HyfStatusView { + HyfStatusView { + executable, + state: "disabled".to_owned(), + source: HYF_SOURCE.to_owned(), + reason: Some("disabled by config".to_owned()), + protocol_version: None, + deterministic_available: None, + } +} + fn unavailable_status( executable: String, reason: String, @@ -321,28 +742,37 @@ fn unavailable_status( HyfStatusView { executable, state: "unavailable".to_owned(), - source: "hyf status control request · local first".to_owned(), + source: HYF_SOURCE.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, +fn format_nonzero_exit(request_label: &str, status: Option<i32>, stderr: &str) -> String { + match status { + Some(code) if stderr.is_empty() => { + format!("{request_label} exited with status code {code}") + } + Some(code) => { + format!("{request_label} exited with status code {code}: {stderr}") + } + None if stderr.is_empty() => format!("{request_label} terminated by signal"), + None => format!("{request_label} terminated by signal: {stderr}"), + } } #[cfg(test)] mod tests { - use super::{HYF_PROTOCOL_VERSION, resolve_status}; + use super::{ + HYF_PROTOCOL_VERSION, HyfClient, HyfExplainResultRequest, HyfQueryRewriteRequest, + HyfRequestContext, HyfSemanticCandidate, HyfSemanticRankRequest, resolve_status, + }; use crate::runtime::config::HyfConfig; + use serde_json::Value; use std::fs; use std::os::unix::fs::PermissionsExt; + use std::path::{Path, PathBuf}; use std::sync::{Mutex, OnceLock}; use tempfile::tempdir; @@ -366,10 +796,10 @@ mod tests { fn healthy_hyf_status_reports_ready() { let _guard = hyf_test_lock().lock().expect("hyf test lock"); let dir = tempdir().expect("tempdir"); - let executable = write_script( + let executable = write_response_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" + "{{\"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}}}}}}" ) .as_str(), ); @@ -387,9 +817,9 @@ mod tests { fn incompatible_hyf_status_reports_unavailable() { let _guard = hyf_test_lock().lock().expect("hyf test lock"); let dir = tempdir().expect("tempdir"); - let executable = write_script( + let executable = write_response_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", + "{\"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}}}", ); let view = resolve_status(&HyfConfig { @@ -404,7 +834,172 @@ mod tests { ); } - fn write_script(dir: &std::path::Path, script: &str) -> std::path::PathBuf { + #[test] + fn capabilities_request_uses_typed_client() { + let _guard = hyf_test_lock().lock().expect("hyf test lock"); + let dir = tempdir().expect("tempdir"); + let (executable, request_path) = write_capture_script( + dir.path(), + "{\"version\":1,\"request_id\":\"cli-runtime-hyf-capabilities\",\"ok\":true,\"output\":{\"control_routes\":[\"sys.status\",\"sys.capabilities\"],\"business_capabilities\":[{\"id\":\"query_rewrite\",\"kind\":\"business\",\"deterministic_execution\":\"enabled\",\"implementation_status\":\"implemented\",\"callable\":true,\"implemented\":true,\"assisted_execution\":\"unavailable\",\"assisted_backend_available\":false}],\"assisted_backend_capabilities\":[],\"request_context_contract\":{\"accepted_features\":[\"consumer\",\"execution_mode_preference\"],\"effective_features\":[\"execution_mode_preference\"],\"unsupported_field_behavior\":\"reject\"}}}", + ); + + let response = HyfClient::new(executable) + .capabilities() + .expect("capabilities"); + let request = read_request_json(request_path.as_path()); + + assert_eq!(request["capability"], "sys.capabilities"); + assert_eq!(request["input"], serde_json::json!({})); + assert!(request.get("context").is_none()); + assert_eq!( + response.output.control_routes, + vec!["sys.status", "sys.capabilities"] + ); + assert_eq!(response.output.business_capabilities[0].id, "query_rewrite"); + } + + #[test] + fn query_rewrite_request_round_trips_typed_output() { + let _guard = hyf_test_lock().lock().expect("hyf test lock"); + let dir = tempdir().expect("tempdir"); + let (executable, request_path) = write_capture_script( + dir.path(), + "{\"version\":1,\"request_id\":\"rewrite-test-1\",\"trace_id\":\"trace-rewrite-test-1\",\"ok\":true,\"output\":{\"original_text\":\"apples near me with weekend pickup\",\"normalized_text\":\"apples near me with weekend pickup\",\"rewritten_text\":\"apples\",\"query_terms\":[\"apples\"],\"normalization_signals\":[\"local_intent_detected\"],\"ranking_hints\":[\"prefer_local_results\"],\"extracted_filters\":{\"local_intent\":true,\"fulfillment\":\"pickup\",\"time_window\":\"weekend\"}},\"meta\":{\"execution_mode\":\"deterministic\",\"backend\":\"heuristic\"}}", + ); + let context = HyfRequestContext::deterministic_cli().with_return_provenance(true); + let client = HyfClient::new(executable); + let response = client + .query_rewrite( + "rewrite-test-1", + Some("trace-rewrite-test-1"), + &context, + &HyfQueryRewriteRequest::new("apples near me with weekend pickup"), + ) + .expect("query rewrite"); + let request = read_request_json(request_path.as_path()); + + assert_eq!(request["capability"], "query_rewrite"); + assert_eq!( + request["context"]["execution_mode_preference"], + "deterministic" + ); + assert_eq!(request["context"]["consumer"], "radroots-cli"); + assert_eq!(request["context"]["return_provenance"], true); + assert_eq!( + request["input"]["query"], + "apples near me with weekend pickup" + ); + assert_eq!(response.output.rewritten_text, "apples"); + assert_eq!(response.output.query_terms, vec!["apples"]); + assert_eq!( + response.meta, + Some(serde_json::json!({"execution_mode":"deterministic","backend":"heuristic"})) + ); + } + + #[test] + fn semantic_rank_request_round_trips_typed_output() { + let _guard = hyf_test_lock().lock().expect("hyf test lock"); + let dir = tempdir().expect("tempdir"); + let (executable, request_path) = write_capture_script( + dir.path(), + "{\"version\":1,\"request_id\":\"rank-test-1\",\"ok\":true,\"output\":{\"ranked_ids\":[\"listing_local_1\",\"listing_regional_1\"],\"reasons\":{\"listing_local_1\":[\"apples match\",\"pickup match\"],\"listing_regional_1\":[\"delivery mismatch\"]},\"scored_candidates\":[{\"id\":\"listing_local_1\",\"heuristic_score\":14,\"matched_terms\":[\"apples\"],\"reasons\":[\"apples match\",\"pickup match\"],\"delivery_alignment\":\"match\",\"distance_band\":\"closer\",\"freshness_band\":\"fresher\",\"scope_match\":true}],\"ranking_hints\":[\"prefer_local_results\"],\"extracted_filters\":{\"local_intent\":true,\"fulfillment\":\"pickup\",\"time_window\":\"weekend\"}},\"meta\":{\"execution_mode\":\"deterministic\",\"backend\":\"heuristic\"}}", + ); + let client = HyfClient::new(executable); + let response = client + .semantic_rank( + "rank-test-1", + None, + &HyfRequestContext::deterministic_cli() + .with_listing_scope(vec!["listing_local_1".to_owned()]), + &HyfSemanticRankRequest::new( + "apples near me with weekend pickup", + vec![sample_candidate("listing_local_1")], + ), + ) + .expect("semantic rank"); + let request = read_request_json(request_path.as_path()); + + assert_eq!(request["capability"], "semantic_rank"); + assert_eq!( + request["context"]["scope"]["listing_ids"], + serde_json::json!(["listing_local_1"]) + ); + assert_eq!(request["input"]["candidates"][0]["id"], "listing_local_1"); + assert_eq!(response.output.ranked_ids[0], "listing_local_1"); + assert_eq!(response.output.scored_candidates[0].heuristic_score, 14); + } + + #[test] + fn explain_result_request_round_trips_typed_output() { + let _guard = hyf_test_lock().lock().expect("hyf test lock"); + let dir = tempdir().expect("tempdir"); + let (executable, request_path) = write_capture_script( + dir.path(), + "{\"version\":1,\"request_id\":\"explain-test-1\",\"trace_id\":\"trace-explain-test-1\",\"ok\":true,\"output\":{\"result_id\":\"listing_local_1\",\"explanation_kind\":\"deterministic\",\"summary\":\"Result listing_local_1 was ranked using deterministic heuristic signals: apples match and pickup match.\",\"score\":14,\"reasons\":[\"apples match\",\"pickup match\"],\"matched_terms\":[\"apples\"],\"ranking_hints\":[\"prefer_local_results\"],\"extracted_filters\":{\"local_intent\":true,\"fulfillment\":\"pickup\",\"time_window\":\"weekend\"},\"signal_assessment\":{\"delivery_alignment\":\"match\",\"distance_band\":\"closer\",\"freshness_band\":\"fresher\",\"scope_match\":true}},\"meta\":{\"execution_mode\":\"deterministic\",\"backend\":\"heuristic\"}}", + ); + let client = HyfClient::new(executable); + let response = client + .explain_result( + "explain-test-1", + Some("trace-explain-test-1"), + &HyfRequestContext::deterministic_cli().with_return_provenance(true), + &HyfExplainResultRequest::new( + "apples near me with weekend pickup", + sample_candidate("listing_local_1"), + ), + ) + .expect("explain result"); + let request = read_request_json(request_path.as_path()); + + assert_eq!(request["capability"], "explain_result"); + assert_eq!(request["context"]["return_provenance"], true); + assert_eq!(request["input"]["candidate"]["id"], "listing_local_1"); + assert_eq!(response.output.result_id, "listing_local_1"); + assert_eq!( + response.output.signal_assessment.delivery_alignment, + "match" + ); + } + + fn sample_candidate(id: &str) -> HyfSemanticCandidate { + HyfSemanticCandidate { + id: id.to_owned(), + title: "Organic apples".to_owned(), + farm: "Local Orchard".to_owned(), + delivery: "pickup".to_owned(), + distance_km: 4.1, + freshness_minutes: 3, + } + } + + fn read_request_json(path: &Path) -> Value { + let raw = fs::read_to_string(path).expect("request raw"); + serde_json::from_str(raw.trim()).expect("request json") + } + + fn write_response_script(dir: &Path, response: &str) -> PathBuf { + write_script( + dir, + format!("#!/bin/sh\nread -r _request || exit 64\ncat <<'JSON'\n{response}\nJSON\n") + .as_str(), + ) + } + + fn write_capture_script(dir: &Path, response: &str) -> (PathBuf, PathBuf) { + let request_path = dir.join("request.json"); + let executable = write_script( + dir, + format!( + "#!/bin/sh\ncat > '{}'\ncat <<'JSON'\n{response}\nJSON\n", + request_path.display() + ) + .as_str(), + ); + (executable, request_path) + } + + fn write_script(dir: &Path, script: &str) -> PathBuf { let path = dir.join("fake-hyfd"); fs::write(&path, script).expect("write fake hyfd"); let mut permissions = fs::metadata(&path).expect("metadata").permissions();