cli

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

commit 6ae4e02db72e79b6047ef9c6e574b640a90302a6
parent ad5669f3a1cfefe8c5738bfc1bab8a56e6fb628a
Author: triesap <tyson@radroots.org>
Date:   Fri, 10 Apr 2026 23:47:50 +0000

find: adopt hyf rewrite and workflow truth

Diffstat:
Msrc/commands/doctor.rs | 11+++++++++--
Msrc/domain/runtime.rs | 21+++++++++++++++++++++
Msrc/render/mod.rs | 3+++
Msrc/runtime/find.rs | 98++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Msrc/runtime/hyf.rs | 126++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------------
Msrc/runtime/provider.rs | 35+++++++++++++++++++++++++++--------
Mtests/doctor.rs | 16+++++++++++-----
Mtests/find.rs | 126+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtests/runtime_show.rs | 4++--
9 files changed, 380 insertions(+), 60 deletions(-)

diff --git a/src/commands/doctor.rs b/src/commands/doctor.rs @@ -303,11 +303,18 @@ fn hyf_check(hyf: &crate::runtime::provider::HyfProviderView) -> EvaluatedCheck } fn workflow_check(workflow: &crate::runtime::provider::WorkflowProviderView) -> EvaluatedCheck { + let severity = match workflow.state.as_str() { + "ready" => DoctorSeverity::Ok, + "not_configured" => DoctorSeverity::Warn, + "unsupported" | "unavailable" | "incompatible" => DoctorSeverity::ExternalFail, + _ => DoctorSeverity::InternalFail, + }; + EvaluatedCheck { - severity: DoctorSeverity::Ok, + severity, view: DoctorCheckView { name: "workflow".to_owned(), - status: "ok".to_owned(), + status: severity.status().to_owned(), detail: workflow.detail(), }, action: None, diff --git a/src/domain/runtime.rs b/src/domain/runtime.rs @@ -642,6 +642,8 @@ pub struct FindView { pub freshness: SyncFreshnessView, pub results: Vec<FindResultView>, #[serde(skip_serializing_if = "Option::is_none")] + pub hyf: Option<FindHyfView>, + #[serde(skip_serializing_if = "Option::is_none")] pub reason: Option<String>, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub actions: Vec<String>, @@ -657,6 +659,15 @@ impl FindView { } #[derive(Debug, Clone, Serialize)] +pub struct FindHyfView { + pub state: String, + pub source: String, + pub rewritten_query: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub query_terms: Vec<String>, +} + +#[derive(Debug, Clone, Serialize)] pub struct JobListView { pub state: String, pub source: String, @@ -1227,6 +1238,16 @@ pub struct FindResultView { pub available: FindQuantityView, pub price: FindPriceView, pub provenance: FindResultProvenanceView, + #[serde(skip_serializing_if = "Option::is_none")] + pub hyf: Option<FindResultHyfView>, +} + +#[derive(Debug, Clone, Serialize)] +pub struct FindResultHyfView { + pub state: String, + pub rewritten_query: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub query_terms: Vec<String>, } #[derive(Debug, Clone, Serialize)] diff --git a/src/render/mod.rs b/src/render/mod.rs @@ -1023,6 +1023,9 @@ fn render_find(stdout: &mut dyn Write, view: &FindView) -> Result<(), RuntimeErr }; write_context(stdout, context.as_str())?; writeln!(stdout, "query: {}", view.query)?; + if let Some(hyf) = &view.hyf { + writeln!(stdout, "hyf: query rewritten to {}", hyf.rewritten_query)?; + } match view.state.as_str() { "unconfigured" => { diff --git a/src/runtime/find.rs b/src/runtime/find.rs @@ -4,14 +4,17 @@ use serde_json::Value; use crate::cli::FindArgs; use crate::domain::runtime::{ - FindPriceView, FindQuantityView, FindResultProvenanceView, FindResultView, FindView, - SyncFreshnessView, + FindHyfView, FindPriceView, FindQuantityView, FindResultHyfView, FindResultProvenanceView, + FindResultView, FindView, SyncFreshnessView, }; use crate::runtime::RuntimeError; use crate::runtime::config::RuntimeConfig; +use crate::runtime::hyf::{self, HyfQueryRewriteRequest, HyfRequestContext}; use crate::runtime::sync::freshness_from_executor; const FIND_SOURCE: &str = "local replica · local first"; +const FIND_HYF_SOURCE: &str = "hyf query_rewrite · local first"; +const FIND_HYF_QUERY_REWRITE_REQUEST_ID: &str = "cli-find-query-rewrite"; #[derive(Debug, Clone, Deserialize)] struct FindRow { @@ -31,6 +34,31 @@ struct FindRow { location_primary: Option<String>, } +#[derive(Debug, Clone)] +struct AppliedQueryRewrite { + rewritten_query: String, + query_terms: Vec<String>, +} + +impl AppliedQueryRewrite { + fn to_find_view(&self) -> FindHyfView { + FindHyfView { + state: "query_rewrite_applied".to_owned(), + source: FIND_HYF_SOURCE.to_owned(), + rewritten_query: self.rewritten_query.clone(), + query_terms: self.query_terms.clone(), + } + } + + fn to_result_view(&self) -> FindResultHyfView { + FindResultHyfView { + state: "query_rewrite_applied".to_owned(), + rewritten_query: self.rewritten_query.clone(), + query_terms: self.query_terms.clone(), + } + } +} + pub fn search(config: &RuntimeConfig, args: &FindArgs) -> Result<FindView, RuntimeError> { let query = args.query.join(" "); if !config.local.replica_db_path.exists() { @@ -48,6 +76,7 @@ pub fn search(config: &RuntimeConfig, args: &FindArgs) -> Result<FindView, Runti last_event_at: None, }, results: Vec::new(), + hyf: None, reason: Some("local replica database is not initialized".to_owned()), actions: vec!["radroots local init".to_owned()], }); @@ -55,7 +84,12 @@ pub fn search(config: &RuntimeConfig, args: &FindArgs) -> Result<FindView, Runti let executor = SqliteExecutor::open(&config.local.replica_db_path)?; let freshness = freshness_from_executor(&executor)?; - let rows = query_rows(&executor, &args.query)?; + let applied_query_rewrite = attempt_query_rewrite(config, query.as_str(), &args.query); + let effective_query_terms = applied_query_rewrite + .as_ref() + .map(|rewrite| rewrite.query_terms.clone()) + .unwrap_or_else(|| normalize_query_terms(args.query.clone())); + let rows = query_rows(&executor, effective_query_terms.as_slice())?; let relay_count = config.relay.urls.len(); let result_provenance = FindResultProvenanceView { origin: "local_replica.trade_product".to_owned(), @@ -84,6 +118,9 @@ pub fn search(config: &RuntimeConfig, args: &FindArgs) -> Result<FindView, Runti per_unit: row.price_qty_unit, }, provenance: result_provenance.clone(), + hyf: applied_query_rewrite + .as_ref() + .map(AppliedQueryRewrite::to_result_view), }) .collect::<Vec<_>>(); @@ -111,11 +148,55 @@ pub fn search(config: &RuntimeConfig, args: &FindArgs) -> Result<FindView, Runti replica_db: config.local.replica_db_path.display().to_string(), freshness, results, + hyf: applied_query_rewrite.map(|rewrite| rewrite.to_find_view()), reason, actions, }) } +fn attempt_query_rewrite( + config: &RuntimeConfig, + query: &str, + original_terms: &[String], +) -> Option<AppliedQueryRewrite> { + if query.trim().is_empty() { + return None; + } + + let client = hyf::resolve_ready_runtime_client(config).ok()?; + let response = client + .query_rewrite( + FIND_HYF_QUERY_REWRITE_REQUEST_ID, + Some(FIND_HYF_QUERY_REWRITE_REQUEST_ID), + &HyfRequestContext::deterministic_cli(), + &HyfQueryRewriteRequest::new(query), + ) + .ok()?; + + let rewritten_terms = normalize_query_terms(response.output.query_terms.clone()); + if rewritten_terms.is_empty() { + return None; + } + + if rewritten_terms == normalize_query_terms(original_terms.iter().cloned()) { + return None; + } + + let rewritten_query = { + let rewritten_text = response.output.rewritten_text.trim(); + if rewritten_text.is_empty() { + rewritten_terms.join(" ") + } else { + rewritten_text.to_owned() + } + }; + + Some(AppliedQueryRewrite { + rewritten_query, + query_terms: rewritten_terms, + }) +} + fn query_rows( executor: &SqliteExecutor, query_terms: &[String], @@ -160,3 +241,14 @@ fn non_empty(value: String) -> Option<String> { Some(trimmed.to_owned()) } } + +fn normalize_query_terms<I>(terms: I) -> Vec<String> +where + I: IntoIterator<Item = String>, +{ + terms + .into_iter() + .map(|term| term.trim().to_lowercase()) + .filter(|term| !term.is_empty()) + .collect() +} diff --git a/src/runtime/hyf.rs b/src/runtime/hyf.rs @@ -126,15 +126,8 @@ impl HyfClient { 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 request = serialize_request(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)?; @@ -192,6 +185,23 @@ impl HyfClient { } } +fn serialize_request<TRequest: Serialize>( + request_id: &str, + trace_id: Option<&str>, + capability: &str, + context: Option<&HyfRequestContext>, + input: &TRequest, +) -> Result<String, serde_json::Error> { + serde_json::to_string(&HyfRequestEnvelope { + version: HYF_PROTOCOL_VERSION, + request_id, + trace_id, + capability, + context, + input, + }) +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct HyfSuccess<T> { pub version: u64, @@ -500,6 +510,16 @@ 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), @@ -765,10 +785,12 @@ fn format_nonzero_exit(request_label: &str, status: Option<i32>, stderr: &str) - #[cfg(test)] mod tests { use super::{ - HYF_PROTOCOL_VERSION, HyfClient, HyfExplainResultRequest, HyfQueryRewriteRequest, - HyfRequestContext, HyfSemanticCandidate, HyfSemanticRankRequest, resolve_status, + HYF_PROTOCOL_VERSION, HyfClient, HyfEmptyInput, HyfExplainResultRequest, + HyfQueryRewriteRequest, HyfRequestContext, HyfSemanticCandidate, HyfSemanticRankRequest, + resolve_status, }; use crate::runtime::config::HyfConfig; + use serde::Serialize; use serde_json::Value; use std::fs; use std::os::unix::fs::PermissionsExt; @@ -838,15 +860,21 @@ mod tests { 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( + let executable = write_response_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 request = request_json( + "cli-runtime-hyf-capabilities", + None, + "sys.capabilities", + None, + &HyfEmptyInput::default(), + ); 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!({})); @@ -862,11 +890,18 @@ mod tests { 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( + let executable = write_response_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 request = request_json( + "rewrite-test-1", + Some("trace-rewrite-test-1"), + "query_rewrite", + Some(&context), + &HyfQueryRewriteRequest::new("apples near me with weekend pickup"), + ); let client = HyfClient::new(executable); let response = client .query_rewrite( @@ -876,8 +911,6 @@ mod tests { &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"], @@ -901,24 +934,34 @@ mod tests { 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( + let executable = write_response_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 context = HyfRequestContext::deterministic_cli() + .with_listing_scope(vec!["listing_local_1".to_owned()]); + let request = request_json( + "rank-test-1", + None, + "semantic_rank", + Some(&context), + &HyfSemanticRankRequest::new( + "apples near me with weekend pickup", + vec![sample_candidate("listing_local_1")], + ), + ); 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()]), + &context, &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!( @@ -934,23 +977,33 @@ mod tests { 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( + let executable = write_response_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 context = HyfRequestContext::deterministic_cli().with_return_provenance(true); + let request = request_json( + "explain-test-1", + Some("trace-explain-test-1"), + "explain_result", + Some(&context), + &HyfExplainResultRequest::new( + "apples near me with weekend pickup", + sample_candidate("listing_local_1"), + ), + ); 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), + &context, &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); @@ -973,9 +1026,16 @@ mod tests { } } - 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 request_json<T: Serialize>( + request_id: &str, + trace_id: Option<&str>, + capability: &str, + context: Option<&HyfRequestContext>, + input: &T, + ) -> Value { + let raw = super::serialize_request(request_id, trace_id, capability, context, input) + .expect("serialize request"); + serde_json::from_str(raw.as_str()).expect("request json") } fn write_response_script(dir: &Path, response: &str) -> PathBuf { @@ -985,20 +1045,6 @@ mod tests { .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"); diff --git a/src/runtime/provider.rs b/src/runtime/provider.rs @@ -77,15 +77,20 @@ pub struct WorkflowProviderView { impl WorkflowProviderView { pub fn detail(&self) -> String { - match (self.target_kind.as_deref(), self.target.as_deref()) { - (Some(target_kind), Some(target)) if self.state == "configured" => { + match (self.state.as_str(), self.target_kind.as_deref(), self.target.as_deref()) { + ("not_configured", _, _) => { + "optional workflow provider is not configured; rhi remains status-only in this wave" + .to_owned() + } + ("unsupported", Some(target_kind), Some(target)) => { format!( - "{} workflow provider configured via {} {}", - self.provider_runtime_id, target_kind, target + "configured workflow binding via {} {} is not executable in this wave; rhi remains status-only", + target_kind, target ) } - _ if self.state == "configured" => { - format!("{} workflow provider configured", self.provider_runtime_id) + ("unsupported", _, _) => { + "configured workflow binding is not executable in this wave; rhi remains status-only" + .to_owned() } _ => self.source.clone(), } @@ -133,12 +138,25 @@ pub fn resolve_actor_write_plane_target( pub fn resolve_workflow_provider(config: &RuntimeConfig) -> WorkflowProviderView { let binding = inspect_binding(config, WORKFLOW_TRADE_CAPABILITY); - let provenance = binding_provenance(&binding).as_str().to_owned(); + let (state, provenance) = match binding.state { + CapabilityBindingInspectionState::Configured => ( + "unsupported".to_owned(), + ProviderProvenance::ExplicitBinding.as_str().to_owned(), + ), + CapabilityBindingInspectionState::Disabled => ( + "disabled".to_owned(), + ProviderProvenance::Disabled.as_str().to_owned(), + ), + CapabilityBindingInspectionState::NotConfigured => ( + "not_configured".to_owned(), + ProviderProvenance::Unavailable.as_str().to_owned(), + ), + }; WorkflowProviderView { provider_runtime_id: binding.provider_runtime_id, binding_model: binding.binding_model, - state: binding.state.as_str().to_owned(), + state, provenance, source: binding.source, target_kind: binding.target_kind, @@ -642,6 +660,7 @@ mod tests { signer_session_ref: None, }; let view = resolve_workflow_provider(&sample_config(vec![binding], false)); + assert_eq!(view.state, "unsupported"); assert_eq!( view.provenance, ProviderProvenance::ExplicitBinding.as_str() diff --git a/tests/doctor.rs b/tests/doctor.rs @@ -59,13 +59,15 @@ fn doctor_reports_unconfigured_local_bootstrap_state() { assert_eq!(json["checks"][2]["status"], "warn"); assert_eq!(json["checks"][3]["name"], "signer"); assert_eq!(json["checks"][3]["status"], "warn"); + assert_eq!(json["checks"][5]["name"], "workflow"); + assert_eq!(json["checks"][5]["status"], "warn"); assert_eq!(json["source"], "local diagnostics"); assert_eq!(json["actions"][0], "radroots account new"); assert_eq!(json["actions"][1], "radroots relay ls"); } #[test] -fn doctor_reports_ready_local_bootstrap_state() { +fn doctor_reports_warn_for_ready_local_bootstrap_without_workflow_provider() { let dir = tempdir().expect("tempdir"); let init = doctor_command_in(dir.path()) .args(["--json", "account", "new"]) @@ -78,18 +80,20 @@ fn doctor_reports_ready_local_bootstrap_state() { .output() .expect("run doctor"); - assert!(output.status.success()); + assert_eq!(output.status.code(), Some(3)); let stdout = String::from_utf8(output.stdout).expect("utf8 stdout"); let json: Value = serde_json::from_str(stdout.as_str()).expect("json output"); - assert_eq!(json["ok"], true); - assert_eq!(json["state"], "ok"); + assert_eq!(json["ok"], false); + assert_eq!(json["state"], "warn"); assert_eq!(json["checks"][1]["name"], "account"); assert_eq!(json["checks"][1]["status"], "ok"); assert_eq!(json["checks"][2]["name"], "relays"); assert_eq!(json["checks"][2]["status"], "ok"); assert_eq!(json["checks"][3]["name"], "signer"); assert_eq!(json["checks"][3]["status"], "ok"); - assert_eq!(json["actions"], Value::Null); + assert_eq!(json["checks"][5]["name"], "workflow"); + assert_eq!(json["checks"][5]["status"], "warn"); + assert!(json["actions"].is_null()); } #[test] @@ -116,5 +120,7 @@ fn doctor_reports_external_failure_for_missing_myc() { assert_eq!(json["checks"][3]["status"], "fail"); assert_eq!(json["checks"][4]["name"], "myc"); assert_eq!(json["checks"][4]["status"], "fail"); + assert_eq!(json["checks"][6]["name"], "workflow"); + assert_eq!(json["checks"][6]["status"], "warn"); assert_eq!(json["source"], "local diagnostics + myc status command"); } diff --git a/tests/find.rs b/tests/find.rs @@ -35,6 +35,8 @@ fn cli_command_in(workdir: &Path) -> Command { "RADROOTS_ACCOUNT_SECRET_BACKEND", "RADROOTS_ACCOUNT_SECRET_FALLBACK", "RADROOTS_ACCOUNT_HOST_VAULT_AVAILABLE", + "RADROOTS_HYF_ENABLED", + "RADROOTS_HYF_EXECUTABLE", "RADROOTS_IDENTITY_PATH", "RADROOTS_SIGNER", "RADROOTS_RELAYS", @@ -179,6 +181,110 @@ fn find_reports_empty_results_without_failing() { ); } +#[test] +fn find_uses_hyf_query_rewrite_when_available() { + 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-000000000104", + "fresh-eggs", + "protein", + "Fresh Eggs", + "Pasture-raised eggs", + 36, + 24, + Some("Marshall"), + ); + + let hyfd = write_fake_hyfd( + dir.path(), + r#"{"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}}}"#, + 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 json_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!(json_output.status.success()); + let json: Value = serde_json::from_slice(json_output.stdout.as_slice()).expect("json"); + assert_eq!(json["state"], "ready"); + assert_eq!(json["count"], 1); + assert_eq!(json["query"], "henhouse"); + assert_eq!(json["hyf"]["state"], "query_rewrite_applied"); + assert_eq!(json["hyf"]["rewritten_query"], "eggs"); + assert_eq!(json["hyf"]["query_terms"], json!(["eggs"])); + assert_eq!(json["results"][0]["title"], "Fresh Eggs"); + assert_eq!(json["results"][0]["hyf"]["rewritten_query"], "eggs"); + + let human_output = cli_command_in(dir.path()) + .env("RADROOTS_HYF_ENABLED", "true") + .env("RADROOTS_HYF_EXECUTABLE", &hyfd) + .args(["find", "henhouse"]) + .output() + .expect("run hyf human find"); + assert!(human_output.status.success()); + let stdout = String::from_utf8(human_output.stdout).expect("utf8 stdout"); + assert!(stdout.contains("hyf: query rewritten to eggs")); + + let ndjson_output = cli_command_in(dir.path()) + .env("RADROOTS_HYF_ENABLED", "true") + .env("RADROOTS_HYF_EXECUTABLE", &hyfd) + .args(["--ndjson", "find", "henhouse"]) + .output() + .expect("run hyf ndjson find"); + assert!(ndjson_output.status.success()); + let stdout = String::from_utf8(ndjson_output.stdout).expect("utf8 stdout"); + let lines = stdout.lines().collect::<Vec<_>>(); + assert_eq!(lines.len(), 1); + assert!(lines[0].contains("\"title\":\"Fresh Eggs\"")); + assert!(lines[0].contains("\"rewritten_query\":\"eggs\"")); +} + +#[test] +fn find_falls_back_cleanly_when_hyf_is_unavailable() { + 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-000000000105", + "fresh-eggs", + "protein", + "Fresh Eggs", + "Pasture-raised eggs", + 36, + 24, + Some("Marshall"), + ); + + let output = cli_command_in(dir.path()) + .env("RADROOTS_HYF_ENABLED", "true") + .env("RADROOTS_HYF_EXECUTABLE", dir.path().join("missing-hyfd")) + .args(["--json", "find", "eggs"]) + .output() + .expect("run fallback 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!(json["hyf"].is_null()); + assert_eq!(json["results"][0]["title"], "Fresh Eggs"); +} + fn seed_trade_product( workdir: &Path, product_id: &str, @@ -265,3 +371,23 @@ fn seed_trade_product( .expect("insert trade product location"); } } + +fn write_fake_hyfd( + workdir: &Path, + status_response: &str, + 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 cat <<'JSON'\n{status_response}\nJSON\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 +} diff --git a/tests/runtime_show.rs b/tests/runtime_show.rs @@ -683,8 +683,8 @@ target = "bin/hyfd-user" assert_eq!(workflow["target_kind"], "managed_instance"); assert_eq!(workflow["target"], "workflow-default"); assert_eq!(json["workflow"]["provider_runtime_id"], "rhi"); - assert_eq!(json["workflow"]["state"], "configured"); - assert_eq!(json["workflow"]["provenance"], "managed_default"); + assert_eq!(json["workflow"]["state"], "unsupported"); + assert_eq!(json["workflow"]["provenance"], "explicit_binding"); assert_eq!( json["workflow"]["source"], "user config [[capability_binding]]"