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:
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]]"