commit 2b6ae34c1c4b4e4f53e526cf9ebef53ca922b725
parent 2f3c47373b2927a5f3ccf7e2effa26fe78869f45
Author: triesap <tyson@radroots.org>
Date: Fri, 8 May 2026 15:35:16 +0000
order: preserve failure detail actions
- add detailed not_found support for order failures
- carry status submit history and decision state into error detail
- derive failure next actions from structured error actions
- cover failure action and unconfigured decision envelopes
Diffstat:
3 files changed, 227 insertions(+), 35 deletions(-)
diff --git a/src/operation_adapter.rs b/src/operation_adapter.rs
@@ -465,6 +465,17 @@ impl OperationAdapterError {
}
}
+ pub fn not_found_with_detail(operation_id: &str, message: String, detail: Value) -> Self {
+ Self::DetailedFailure {
+ operation_id: operation_id.to_owned(),
+ code: "not_found".to_owned(),
+ class: "resource".to_owned(),
+ message,
+ exit_code: CliExitCode::NotFound,
+ detail_json: detail.to_string(),
+ }
+ }
+
pub fn not_implemented(operation_id: &str, message: String) -> Self {
Self::NotImplemented {
operation_id: operation_id.to_owned(),
diff --git a/src/operation_order.rs b/src/operation_order.rs
@@ -570,6 +570,18 @@ where
detail,
))
}
+ } else if disposition == CommandDisposition::Unconfigured {
+ Err(OperationAdapterError::operation_unavailable_with_detail(
+ operation_id,
+ message,
+ order_decision_error_detail(view),
+ ))
+ } else if disposition == CommandDisposition::NotFound {
+ Err(OperationAdapterError::not_found_with_detail(
+ operation_id,
+ message,
+ order_decision_error_detail(view),
+ ))
} else {
Err(OperationAdapterError::from_command_disposition(
operation_id,
@@ -1079,15 +1091,66 @@ where
.reason
.clone()
.unwrap_or_else(|| format!("order status finished with state `{}`", view.state));
- Err(OperationAdapterError::from_command_disposition(
- operation_id,
- disposition,
- message,
- ))
+ if disposition == CommandDisposition::ExternalUnavailable {
+ let detail = order_status_error_detail(view);
+ if !view.failed_relays.is_empty() && view.connected_relays.is_empty() {
+ Err(OperationAdapterError::network_unavailable_with_detail(
+ operation_id,
+ message,
+ detail,
+ ))
+ } else {
+ Err(OperationAdapterError::operation_unavailable_with_detail(
+ operation_id,
+ message,
+ detail,
+ ))
+ }
+ } else if disposition == CommandDisposition::Unconfigured {
+ Err(OperationAdapterError::operation_unavailable_with_detail(
+ operation_id,
+ message,
+ order_status_error_detail(view),
+ ))
+ } else {
+ Err(OperationAdapterError::from_command_disposition(
+ operation_id,
+ disposition,
+ message,
+ ))
+ }
}
}
}
+fn order_status_error_detail(view: &OrderStatusView) -> Value {
+ json!({
+ "state": &view.state,
+ "order_id": &view.order_id,
+ "request_event_id": &view.request_event_id,
+ "decision_event_id": &view.decision_event_id,
+ "agreement_event_id": &view.agreement_event_id,
+ "listing_event_id": &view.listing_event_id,
+ "listing_addr": &view.listing_addr,
+ "buyer_pubkey": &view.buyer_pubkey,
+ "seller_pubkey": &view.seller_pubkey,
+ "last_event_id": &view.last_event_id,
+ "revision": &view.revision,
+ "inventory": &view.inventory,
+ "fulfillment": &view.fulfillment,
+ "lifecycle": &view.lifecycle,
+ "payment": &view.payment,
+ "reducer_issues": &view.reducer_issues,
+ "target_relays": &view.target_relays,
+ "connected_relays": &view.connected_relays,
+ "failed_relays": &view.failed_relays,
+ "fetched_count": view.fetched_count,
+ "decoded_count": view.decoded_count,
+ "skipped_count": view.skipped_count,
+ "actions": &view.actions,
+ })
+}
+
fn serialized_target_result<R, T>(value: &T) -> Result<OperationResult<R>, OperationAdapterError>
where
R: OperationResultData,
@@ -1110,45 +1173,81 @@ where
.reason
.clone()
.unwrap_or_else(|| format!("order submit finished with state `{}`", view.state));
- if !view.issues.is_empty()
- && matches!(
- disposition,
- CommandDisposition::Unconfigured | CommandDisposition::ValidationFailed
- )
- {
- let detail = json!({
- "state": &view.state,
- "order_id": &view.order_id,
- "file": &view.file,
- "listing_addr": &view.listing_addr,
- "listing_event_id": &view.listing_event_id,
- "issues": &view.issues,
- "actions": &view.actions,
- });
- if disposition == CommandDisposition::ValidationFailed {
+ let detail = order_submit_error_detail(view);
+ match disposition {
+ CommandDisposition::NotFound => Err(OperationAdapterError::not_found_with_detail(
+ operation_id,
+ message,
+ detail,
+ )),
+ CommandDisposition::ValidationFailed => {
Err(OperationAdapterError::validation_failed_with_detail(
operation_id,
message,
detail,
))
- } else {
+ }
+ CommandDisposition::Unconfigured => {
Err(OperationAdapterError::operation_unavailable_with_detail(
operation_id,
message,
detail,
))
}
- } else {
- Err(OperationAdapterError::from_command_disposition(
+ CommandDisposition::ExternalUnavailable => {
+ if !view.failed_relays.is_empty() && view.connected_relays.is_empty() {
+ Err(OperationAdapterError::network_unavailable_with_detail(
+ operation_id,
+ message,
+ detail,
+ ))
+ } else {
+ Err(OperationAdapterError::operation_unavailable_with_detail(
+ operation_id,
+ message,
+ detail,
+ ))
+ }
+ }
+ _ => Err(OperationAdapterError::from_command_disposition(
operation_id,
disposition,
message,
- ))
+ )),
}
}
}
}
+fn order_submit_error_detail(view: &OrderSubmitView) -> Value {
+ json!({
+ "state": &view.state,
+ "source": &view.source,
+ "order_id": &view.order_id,
+ "file": &view.file,
+ "listing_lookup": &view.listing_lookup,
+ "listing_addr": &view.listing_addr,
+ "listing_event_id": &view.listing_event_id,
+ "buyer_account_id": &view.buyer_account_id,
+ "buyer_pubkey": &view.buyer_pubkey,
+ "seller_pubkey": &view.seller_pubkey,
+ "event_id": &view.event_id,
+ "event_kind": view.event_kind,
+ "dry_run": view.dry_run,
+ "deduplicated": view.deduplicated,
+ "target_relays": &view.target_relays,
+ "connected_relays": &view.connected_relays,
+ "acknowledged_relays": &view.acknowledged_relays,
+ "failed_relays": &view.failed_relays,
+ "idempotency_key": &view.idempotency_key,
+ "signer_mode": &view.signer_mode,
+ "signer_session_id": &view.signer_session_id,
+ "requested_signer_session_id": &view.requested_signer_session_id,
+ "issues": &view.issues,
+ "actions": &view.actions,
+ })
+}
+
fn history_result<R>(
operation_id: &str,
view: &crate::domain::runtime::OrderHistoryView,
@@ -1163,19 +1262,17 @@ where
format!("order event list finished with state `{}`", view.state)
});
if disposition == CommandDisposition::ExternalUnavailable {
+ let detail = order_history_error_detail(view);
Err(OperationAdapterError::network_unavailable_with_detail(
operation_id,
message,
- json!({
- "state": &view.state,
- "seller_pubkey": &view.seller_pubkey,
- "target_relays": &view.target_relays,
- "connected_relays": &view.connected_relays,
- "failed_relays": &view.failed_relays,
- "fetched_count": view.fetched_count,
- "decoded_count": view.decoded_count,
- "skipped_count": view.skipped_count,
- }),
+ detail,
+ ))
+ } else if disposition == CommandDisposition::Unconfigured {
+ Err(OperationAdapterError::operation_unavailable_with_detail(
+ operation_id,
+ message,
+ order_history_error_detail(view),
))
} else {
Err(OperationAdapterError::from_command_disposition(
@@ -1188,6 +1285,21 @@ where
}
}
+fn order_history_error_detail(view: &crate::domain::runtime::OrderHistoryView) -> Value {
+ json!({
+ "state": &view.state,
+ "seller_pubkey": &view.seller_pubkey,
+ "target_relays": &view.target_relays,
+ "connected_relays": &view.connected_relays,
+ "failed_relays": &view.failed_relays,
+ "fetched_count": view.fetched_count,
+ "decoded_count": view.decoded_count,
+ "skipped_count": view.skipped_count,
+ "count": view.count,
+ "actions": &view.actions,
+ })
+}
+
fn required_order_key<P>(request: &OperationRequest<P>) -> Result<String, OperationAdapterError>
where
P: OperationRequestPayload + OperationRequestData,
@@ -1360,6 +1472,18 @@ mod tests {
assert_eq!(output_error.code, "not_found");
assert_eq!(output_error.exit_code, 4);
assert!(output_error.message.contains("ord_missing"));
+ let envelope = crate::output_contract::OutputEnvelope::failure(
+ "order.submit",
+ output_error,
+ context.envelope_context("req_order_submit"),
+ );
+ let detail = envelope.errors[0].detail.as_ref().expect("submit detail");
+ assert_eq!(detail["state"], "missing");
+ assert_eq!(detail["order_id"], "ord_missing");
+ assert_eq!(detail["actions"][0], "radroots order list");
+ assert_eq!(detail["actions"][1], "radroots basket create");
+ assert_eq!(envelope.next_actions[0].command, "radroots order list");
+ assert_eq!(envelope.next_actions[1].command, "radroots basket create");
}
#[test]
@@ -1378,6 +1502,31 @@ mod tests {
}
#[test]
+ fn order_accept_unconfigured_preserves_decision_detail() {
+ let dir = tempdir().expect("tempdir");
+ let config = sample_config(dir.path());
+ let service = OperationAdapter::new(OrderOperationService::new(&config));
+ let mut context = OperationContext::default();
+ context.dry_run = true;
+ let accept = OperationRequest::new(
+ context,
+ OrderAcceptRequest::from_data(data(&[("order_id", "ord_pending")])),
+ )
+ .expect("order accept request");
+ let error = service
+ .execute(accept)
+ .expect_err("order accept unconfigured");
+ let output_error = error.to_output_error();
+ let detail = output_error.detail.as_ref().expect("decision detail");
+
+ assert_eq!(output_error.code, "operation_unavailable");
+ assert_eq!(detail["state"], "unconfigured");
+ assert_eq!(detail["order_id"], "ord_pending");
+ assert_eq!(detail["decision"], "accepted");
+ assert!(detail["target_relays"].as_array().unwrap().is_empty());
+ }
+
+ #[test]
fn order_decision_already_decided_maps_to_validation_failure() {
let view = already_decided_view();
let error = match decision_result::<OrderAcceptResult>("order.accept", &view) {
@@ -1678,6 +1827,12 @@ mod tests {
assert_eq!(output_error.code, "operation_unavailable");
assert!(output_error.message.contains("configured relay"));
+ let detail = output_error.detail.as_ref().expect("status detail");
+ assert_eq!(detail["state"], "unconfigured");
+ assert_eq!(detail["order_id"], "ord_pending");
+ assert_eq!(detail["fetched_count"], 0);
+ assert_eq!(detail["decoded_count"], 0);
+ assert_eq!(detail["skipped_count"], 0);
}
#[test]
diff --git a/src/output_contract.rs b/src/output_contract.rs
@@ -381,6 +381,32 @@ mod tests {
}
#[test]
+ fn failure_envelope_derives_next_actions_from_error_detail() {
+ let mut error = OutputError::new(
+ "not_found",
+ "order draft was not found",
+ CliExitCode::NotFound,
+ );
+ error.detail = Some(json!({
+ "actions": [
+ "radroots order list",
+ "run radroots basket create"
+ ]
+ }));
+ let envelope = OutputEnvelope::failure(
+ "order.submit",
+ error,
+ EnvelopeContext::new("req_order", true),
+ );
+
+ assert_eq!(envelope.next_actions.len(), 2);
+ assert_eq!(envelope.next_actions[0].label, "order list");
+ assert_eq!(envelope.next_actions[0].command, "radroots order list");
+ assert_eq!(envelope.next_actions[1].label, "basket create");
+ assert_eq!(envelope.next_actions[1].command, "radroots basket create");
+ }
+
+ #[test]
fn ndjson_frames_serialize_one_json_object_per_line() {
let frames = [
NdjsonFrame::new(