commit 2f3c47373b2927a5f3ccf7e2effa26fe78869f45
parent 28818d7077dbf920fe410e6030b458d474832b30
Author: triesap <tyson@radroots.org>
Date: Fri, 8 May 2026 15:30:35 +0000
order: defer event watch truthfully
- return structured not_implemented for order event watch
- keep deferred watch out of local draft lookup and relay runtime paths
- share next action extraction between success and failure envelopes
- cover the deferred watch envelope in adapter tests
Diffstat:
3 files changed, 127 insertions(+), 96 deletions(-)
diff --git a/src/operation_adapter.rs b/src/operation_adapter.rs
@@ -10,7 +10,7 @@ use crate::domain::runtime::CommandDisposition;
use crate::operation_registry::{OPERATION_REGISTRY, OperationSpec, get_operation};
use crate::output_contract::{
CliExitCode, EnvelopeActor, EnvelopeContext, NextAction, OUTPUT_SCHEMA_VERSION, OutputEnvelope,
- OutputError, OutputWarning,
+ OutputError, OutputWarning, next_actions_from_result_value,
};
use crate::runtime::RuntimeError;
use crate::runtime::accounts::AccountRuntimeFailure;
@@ -260,66 +260,7 @@ impl<P: OperationResultPayload> OperationResult<P> {
}
fn next_actions_from_result(result: &Value) -> Vec<NextAction> {
- result
- .get("actions")
- .and_then(Value::as_array)
- .into_iter()
- .flatten()
- .filter_map(Value::as_str)
- .filter_map(next_action_from_action_string)
- .fold(Vec::<NextAction>::new(), |mut actions, action| {
- if !actions
- .iter()
- .any(|existing| existing.command == action.command)
- {
- actions.push(action);
- }
- actions
- })
-}
-
-fn next_action_from_action_string(action: &str) -> Option<NextAction> {
- let command = action.trim().strip_prefix("run ").unwrap_or(action).trim();
- if !command.starts_with("radroots ") {
- return None;
- }
- Some(NextAction {
- label: next_action_label(command),
- command: command.to_owned(),
- })
-}
-
-fn next_action_label(command: &str) -> String {
- let parts = command.split_whitespace().collect::<Vec<_>>();
- let mut index = usize::from(parts.first().is_some_and(|part| *part == "radroots"));
- let mut labels = Vec::new();
- while index < parts.len() {
- let part = parts[index];
- if part.starts_with("--") {
- index += 1;
- if matches!(
- part,
- "--format"
- | "--account-id"
- | "--relay"
- | "--publish-mode"
- | "--idempotency-key"
- | "--correlation-id"
- | "--approval-token"
- ) && index < parts.len()
- {
- index += 1;
- }
- continue;
- }
- labels.push(part);
- index += 1;
- }
- if labels.is_empty() {
- "radroots".to_owned()
- } else {
- labels.join(" ")
- }
+ next_actions_from_result_value(result)
}
pub trait OperationService<P: OperationRequestPayload> {
@@ -531,6 +472,17 @@ impl OperationAdapterError {
}
}
+ pub fn not_implemented_with_detail(operation_id: &str, message: String, detail: Value) -> Self {
+ Self::DetailedFailure {
+ operation_id: operation_id.to_owned(),
+ code: "not_implemented".to_owned(),
+ class: "operation".to_owned(),
+ message,
+ exit_code: CliExitCode::RuntimeUnavailable,
+ detail_json: detail.to_string(),
+ }
+ }
+
pub fn network_unavailable_with_detail(
operation_id: &str,
message: String,
diff --git a/src/operation_order.rs b/src/operation_order.rs
@@ -25,9 +25,11 @@ use crate::runtime::config::RuntimeConfig;
use crate::runtime_args::{
OrderCancelArgs, OrderDecisionArg, OrderDecisionArgs, OrderFulfillmentArgs, OrderReceiptArgs,
OrderRevisionDecisionArg, OrderRevisionDecisionArgs, OrderRevisionProposeArgs, OrderStatusArgs,
- OrderSubmitArgs, OrderWatchArgs, RecordLookupArgs,
+ OrderSubmitArgs, RecordLookupArgs,
};
+const ORDER_EVENT_WATCH_DEFERRED_REASON: &str = "relay-backed order event watch is not implemented";
+
pub struct OrderOperationService<'a> {
config: &'a RuntimeConfig,
}
@@ -511,13 +513,18 @@ impl OperationService<OrderEventWatchRequest> for OrderOperationService<'_> {
&self,
request: OperationRequest<OrderEventWatchRequest>,
) -> Result<OperationResult<Self::Result>, OperationAdapterError> {
- let args = OrderWatchArgs {
- key: required_order_key(&request)?,
- frames: usize_input(&request, "frames").or(Some(1)),
- interval_ms: u64_input(&request, "interval_ms").unwrap_or(1_000),
- };
- let view = map_runtime(crate::runtime::order::watch(self.config, &args))?;
- serialized_target_result::<OrderEventWatchResult, _>(&view)
+ let order_id = required_order_key(&request)?;
+ let action = format!("radroots order status get {order_id}");
+ Err(OperationAdapterError::not_implemented_with_detail(
+ request.operation_id(),
+ ORDER_EVENT_WATCH_DEFERRED_REASON.to_owned(),
+ json!({
+ "state": "not_implemented",
+ "order_id": order_id,
+ "reason": ORDER_EVENT_WATCH_DEFERRED_REASON,
+ "actions": [action],
+ }),
+ ))
}
}
@@ -1232,18 +1239,6 @@ where
request.payload.input().get(key).and_then(Value::as_bool)
}
-fn usize_input<P>(request: &OperationRequest<P>, key: &str) -> Option<usize>
-where
- P: OperationRequestPayload + OperationRequestData,
-{
- request
- .payload
- .input()
- .get(key)
- .and_then(Value::as_u64)
- .and_then(|value| usize::try_from(value).ok())
-}
-
fn u32_input<P>(request: &OperationRequest<P>, key: &str) -> Option<u32>
where
P: OperationRequestPayload + OperationRequestData,
@@ -1256,13 +1251,6 @@ where
.and_then(|value| u32::try_from(value).ok())
}
-fn u64_input<P>(request: &OperationRequest<P>, key: &str) -> Option<u64>
-where
- P: OperationRequestPayload + OperationRequestData,
-{
- request.payload.input().get(key).and_then(Value::as_u64)
-}
-
fn map_runtime<T>(result: Result<T, RuntimeError>) -> Result<T, OperationAdapterError> {
result.map_err(|error| OperationAdapterError::Runtime(error.to_string()))
}
@@ -1713,7 +1701,7 @@ mod tests {
}
#[test]
- fn order_event_watch_reports_missing_order_with_target_actions() {
+ fn order_event_watch_returns_deferred_error_with_target_action() {
let dir = tempdir().expect("tempdir");
let config = sample_config(dir.path());
let service = OperationAdapter::new(OrderOperationService::new(&config));
@@ -1722,15 +1710,30 @@ mod tests {
OrderEventWatchRequest::from_data(data(&[("order_id", "ord_missing")])),
)
.expect("order event watch request");
- let envelope = service
+ let error = service
.execute(request)
- .expect("order event watch result")
- .to_envelope(OperationContext::default().envelope_context("req_order_watch"))
- .expect("order event watch envelope");
+ .expect_err("order event watch deferred");
+ let envelope = crate::output_contract::OutputEnvelope::failure(
+ "order.event.watch",
+ error.to_output_error(),
+ OperationContext::default().envelope_context("req_order_watch"),
+ );
assert_eq!(envelope.operation_id, "order.event.watch");
- assert_eq!(envelope.result["state"], "missing");
- assert_eq!(envelope.result["actions"][0], "radroots order list");
+ assert!(envelope.result.is_null());
+ assert_eq!(envelope.errors[0].code, "not_implemented");
+ assert_eq!(
+ envelope.errors[0].detail.as_ref().unwrap()["state"],
+ "not_implemented"
+ );
+ assert_eq!(
+ envelope.errors[0].detail.as_ref().unwrap()["order_id"],
+ "ord_missing"
+ );
+ assert_eq!(
+ envelope.next_actions[0].command,
+ "radroots order status get ord_missing"
+ );
}
fn sample_config(root: &Path) -> RuntimeConfig {
diff --git a/src/output_contract.rs b/src/output_contract.rs
@@ -77,6 +77,7 @@ impl OutputEnvelope {
context: EnvelopeContext,
) -> Self {
let operation_id = operation_id.into();
+ let next_actions = next_actions_from_error_detail(&error);
Self {
schema_version: OUTPUT_SCHEMA_VERSION,
kind: operation_id.clone(),
@@ -89,7 +90,7 @@ impl OutputEnvelope {
result: Value::Null,
warnings: Vec::new(),
errors: vec![error],
- next_actions: Vec::new(),
+ next_actions,
}
}
@@ -131,6 +132,81 @@ impl OutputEnvelope {
}
}
+pub fn next_actions_from_result_value(result: &Value) -> Vec<NextAction> {
+ next_actions_from_actions_value(result.get("actions"))
+}
+
+fn next_actions_from_error_detail(error: &OutputError) -> Vec<NextAction> {
+ next_actions_from_actions_value(
+ error
+ .detail
+ .as_ref()
+ .and_then(|detail| detail.get("actions")),
+ )
+}
+
+fn next_actions_from_actions_value(actions_value: Option<&Value>) -> Vec<NextAction> {
+ actions_value
+ .and_then(Value::as_array)
+ .into_iter()
+ .flatten()
+ .filter_map(Value::as_str)
+ .filter_map(next_action_from_action_string)
+ .fold(Vec::<NextAction>::new(), |mut actions, action| {
+ if !actions
+ .iter()
+ .any(|existing| existing.command == action.command)
+ {
+ actions.push(action);
+ }
+ actions
+ })
+}
+
+fn next_action_from_action_string(action: &str) -> Option<NextAction> {
+ let command = action.trim().strip_prefix("run ").unwrap_or(action).trim();
+ if !command.starts_with("radroots ") {
+ return None;
+ }
+ Some(NextAction {
+ label: next_action_label(command),
+ command: command.to_owned(),
+ })
+}
+
+fn next_action_label(command: &str) -> String {
+ let parts = command.split_whitespace().collect::<Vec<_>>();
+ let mut index = usize::from(parts.first().is_some_and(|part| *part == "radroots"));
+ let mut labels = Vec::new();
+ while index < parts.len() {
+ let part = parts[index];
+ if part.starts_with("--") {
+ index += 1;
+ if matches!(
+ part,
+ "--format"
+ | "--account-id"
+ | "--relay"
+ | "--publish-mode"
+ | "--idempotency-key"
+ | "--correlation-id"
+ | "--approval-token"
+ ) && index < parts.len()
+ {
+ index += 1;
+ }
+ continue;
+ }
+ labels.push(part);
+ index += 1;
+ }
+ if labels.is_empty() {
+ "radroots".to_owned()
+ } else {
+ labels.join(" ")
+ }
+}
+
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct OutputWarning {
pub code: String,