commit bccb5d0432da311b68a4a003184e1a93330ff488
parent 6ba026c0f188b650567b5388611b279133d62cbe
Author: triesap <tyson@radroots.org>
Date: Mon, 27 Apr 2026 06:17:25 +0000
cli: split runtime failure codes
- map runtime failures to precise public codes
- attach class detail for account signer provider and network errors
- remove the collapsed availability label from output paths
- update signer runtime coverage for signer failures
Diffstat:
5 files changed, 315 insertions(+), 28 deletions(-)
diff --git a/src/operation_adapter.rs b/src/operation_adapter.rs
@@ -3,7 +3,7 @@
use std::fmt::Debug;
use serde::Serialize;
-use serde_json::{Map, Value};
+use serde_json::{Map, Value, json};
use crate::operation_registry::{OPERATION_REGISTRY, OperationSpec, get_operation};
use crate::output_contract::{
@@ -312,13 +312,43 @@ pub enum OperationAdapterError {
operation_id: String,
message: String,
},
- #[error("operation runtime error: {0}")]
- Runtime(String),
- #[error("operation `{operation_id}` is unavailable or unconfigured: {message}")]
- UnavailableOrUnconfigured {
+ #[error("account unresolved for `{operation_id}`: {message}")]
+ AccountUnresolved {
+ operation_id: String,
+ message: String,
+ },
+ #[error("account is watch-only for `{operation_id}`: {message}")]
+ AccountWatchOnly {
+ operation_id: String,
+ message: String,
+ },
+ #[error("signer unconfigured for `{operation_id}`: {message}")]
+ SignerUnconfigured {
+ operation_id: String,
+ message: String,
+ },
+ #[error("signer unavailable for `{operation_id}`: {message}")]
+ SignerUnavailable {
+ operation_id: String,
+ message: String,
+ },
+ #[error("provider unconfigured for `{operation_id}`: {message}")]
+ ProviderUnconfigured {
operation_id: String,
message: String,
},
+ #[error("provider unavailable for `{operation_id}`: {message}")]
+ ProviderUnavailable {
+ operation_id: String,
+ message: String,
+ },
+ #[error("operation `{operation_id}` is unavailable: {message}")]
+ OperationUnavailable {
+ operation_id: String,
+ message: String,
+ },
+ #[error("operation runtime error: {0}")]
+ Runtime(String),
}
impl OperationAdapterError {
@@ -329,6 +359,22 @@ impl OperationAdapterError {
}
}
+ pub fn unconfigured(operation_id: &str, message: String) -> Self {
+ classify_runtime_failure(
+ operation_id,
+ message,
+ RuntimeFailureAvailability::Unconfigured,
+ )
+ }
+
+ pub fn unavailable(operation_id: &str, message: String) -> Self {
+ classify_runtime_failure(
+ operation_id,
+ message,
+ RuntimeFailureAvailability::Unavailable,
+ )
+ }
+
pub fn to_output_error(&self) -> OutputError {
match self {
Self::ApprovalRequired { message, .. } => OutputError::new(
@@ -339,16 +385,96 @@ impl OperationAdapterError {
Self::InvalidInput { message, .. } => {
OutputError::new("invalid_input", message.clone(), CliExitCode::InvalidInput)
}
- Self::OfflineForbidden { message, .. } => OutputError::new(
+ Self::OfflineForbidden {
+ operation_id,
+ message,
+ } => runtime_output_error(
"offline_forbidden",
- message.clone(),
+ operation_id,
+ "network",
+ message,
CliExitCode::SyncOrNetworkFailure,
),
- Self::NetworkUnavailable { message, .. } => OutputError::new(
+ Self::NetworkUnavailable {
+ operation_id,
+ message,
+ } => runtime_output_error(
"network_unavailable",
- message.clone(),
+ operation_id,
+ "network",
+ message,
CliExitCode::SyncOrNetworkFailure,
),
+ Self::AccountUnresolved {
+ operation_id,
+ message,
+ } => runtime_output_error(
+ "account_unresolved",
+ operation_id,
+ "account",
+ message,
+ CliExitCode::AuthorizationFailed,
+ ),
+ Self::AccountWatchOnly {
+ operation_id,
+ message,
+ } => runtime_output_error(
+ "account_watch_only",
+ operation_id,
+ "account",
+ message,
+ CliExitCode::SignerUnavailable,
+ ),
+ Self::SignerUnconfigured {
+ operation_id,
+ message,
+ } => runtime_output_error(
+ "signer_unconfigured",
+ operation_id,
+ "signer",
+ message,
+ CliExitCode::SignerUnavailable,
+ ),
+ Self::SignerUnavailable {
+ operation_id,
+ message,
+ } => runtime_output_error(
+ "signer_unavailable",
+ operation_id,
+ "signer",
+ message,
+ CliExitCode::SignerUnavailable,
+ ),
+ Self::ProviderUnconfigured {
+ operation_id,
+ message,
+ } => runtime_output_error(
+ "provider_unconfigured",
+ operation_id,
+ "provider",
+ message,
+ CliExitCode::RuntimeUnavailable,
+ ),
+ Self::ProviderUnavailable {
+ operation_id,
+ message,
+ } => runtime_output_error(
+ "provider_unavailable",
+ operation_id,
+ "provider",
+ message,
+ CliExitCode::RuntimeUnavailable,
+ ),
+ Self::OperationUnavailable {
+ operation_id,
+ message,
+ } => runtime_output_error(
+ "operation_unavailable",
+ operation_id,
+ "operation",
+ message,
+ CliExitCode::RuntimeUnavailable,
+ ),
Self::UnknownOperation(operation_id) => OutputError::new(
"unknown_operation",
format!("unknown operation `{operation_id}`"),
@@ -367,15 +493,114 @@ impl OperationAdapterError {
Self::Runtime(message) => {
OutputError::new("runtime_error", message.clone(), CliExitCode::InternalError)
}
- Self::UnavailableOrUnconfigured { message, .. } => OutputError::new(
- "unavailable_or_unconfigured",
- message.clone(),
- CliExitCode::UnavailableOrUnconfigured,
- ),
}
}
}
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum RuntimeFailureAvailability {
+ Unconfigured,
+ Unavailable,
+}
+
+fn classify_runtime_failure(
+ operation_id: &str,
+ message: String,
+ availability: RuntimeFailureAvailability,
+) -> OperationAdapterError {
+ let lowered = message.to_ascii_lowercase();
+ if contains_any(&lowered, &["watch_only", "watch-only", "watch only"]) {
+ return OperationAdapterError::AccountWatchOnly {
+ operation_id: operation_id.to_owned(),
+ message,
+ };
+ }
+ if contains_any(
+ &lowered,
+ &[
+ "no account",
+ "account selection",
+ "unresolved account",
+ "selected account",
+ ],
+ ) {
+ return OperationAdapterError::AccountUnresolved {
+ operation_id: operation_id.to_owned(),
+ message,
+ };
+ }
+ if contains_any(
+ &lowered,
+ &[
+ "signer",
+ "sign_event",
+ "remote_nip46",
+ "nip46",
+ "secret-backed",
+ "secret backed",
+ ],
+ ) {
+ return match availability {
+ RuntimeFailureAvailability::Unconfigured => OperationAdapterError::SignerUnconfigured {
+ operation_id: operation_id.to_owned(),
+ message,
+ },
+ RuntimeFailureAvailability::Unavailable => OperationAdapterError::SignerUnavailable {
+ operation_id: operation_id.to_owned(),
+ message,
+ },
+ };
+ }
+ if contains_any(
+ &lowered,
+ &[
+ "provider",
+ "write-plane",
+ "write plane",
+ "radrootsd",
+ "bridge",
+ "rpc",
+ "daemon",
+ ],
+ ) {
+ return match availability {
+ RuntimeFailureAvailability::Unconfigured => {
+ OperationAdapterError::ProviderUnconfigured {
+ operation_id: operation_id.to_owned(),
+ message,
+ }
+ }
+ RuntimeFailureAvailability::Unavailable => OperationAdapterError::ProviderUnavailable {
+ operation_id: operation_id.to_owned(),
+ message,
+ },
+ };
+ }
+ OperationAdapterError::OperationUnavailable {
+ operation_id: operation_id.to_owned(),
+ message,
+ }
+}
+
+fn contains_any(value: &str, needles: &[&str]) -> bool {
+ needles.iter().any(|needle| value.contains(needle))
+}
+
+fn runtime_output_error(
+ code: &str,
+ operation_id: &str,
+ class: &str,
+ message: &str,
+ exit_code: CliExitCode,
+) -> OutputError {
+ let mut error = OutputError::new(code, message.to_owned(), exit_code);
+ error.detail = Some(json!({
+ "operation_id": operation_id,
+ "class": class,
+ }));
+ error
+}
+
macro_rules! mvp_operation_contracts {
($( $variant:ident => ($request:ident, $result:ident, $operation_id:literal) ),+ $(,)?) => {
#[derive(Debug, Clone, PartialEq)]
@@ -873,4 +1098,65 @@ mod tests {
assert_eq!(output_error.exit_code, 6);
assert!(output_error.message.contains("approval_token"));
}
+
+ #[test]
+ fn runtime_failures_map_to_specific_machine_codes() {
+ let cases = [
+ (
+ OperationAdapterError::unconfigured(
+ "listing.publish",
+ "no selected account for seller write".to_owned(),
+ ),
+ "account_unresolved",
+ "account",
+ 5,
+ ),
+ (
+ OperationAdapterError::unconfigured(
+ "listing.publish",
+ "watch_only account cannot sign".to_owned(),
+ ),
+ "account_watch_only",
+ "account",
+ 7,
+ ),
+ (
+ OperationAdapterError::unconfigured(
+ "listing.publish",
+ "signer.remote_nip46 binding is missing".to_owned(),
+ ),
+ "signer_unconfigured",
+ "signer",
+ 7,
+ ),
+ (
+ OperationAdapterError::unavailable(
+ "listing.publish",
+ "radrootsd bridge is unavailable".to_owned(),
+ ),
+ "provider_unavailable",
+ "provider",
+ 3,
+ ),
+ (
+ OperationAdapterError::unconfigured(
+ "basket.quote.create",
+ "quote engine not ready".to_owned(),
+ ),
+ "operation_unavailable",
+ "operation",
+ 3,
+ ),
+ ];
+
+ for (error, code, class, exit_code) in cases {
+ let output = error.to_output_error();
+ assert_eq!(output.code, code);
+ assert_eq!(output.exit_code, exit_code);
+ assert_eq!(
+ output.detail.expect("detail")["class"],
+ serde_json::Value::String(class.to_owned())
+ );
+ }
+ }
}
diff --git a/src/operation_listing.rs b/src/operation_listing.rs
@@ -247,11 +247,11 @@ fn disposition_error(
) -> OperationAdapterError {
match disposition {
CommandDisposition::Success => OperationAdapterError::Runtime(message),
- CommandDisposition::Unconfigured | CommandDisposition::ExternalUnavailable => {
- OperationAdapterError::UnavailableOrUnconfigured {
- operation_id: operation_id.to_owned(),
- message,
- }
+ CommandDisposition::Unconfigured => {
+ OperationAdapterError::unconfigured(operation_id, message)
+ }
+ CommandDisposition::ExternalUnavailable => {
+ OperationAdapterError::unavailable(operation_id, message)
}
CommandDisposition::Unsupported => OperationAdapterError::InvalidInput {
operation_id: operation_id.to_owned(),
diff --git a/src/operation_order.rs b/src/operation_order.rs
@@ -154,11 +154,11 @@ fn disposition_error(
) -> OperationAdapterError {
match disposition {
CommandDisposition::Success => OperationAdapterError::Runtime(message),
- CommandDisposition::Unconfigured | CommandDisposition::ExternalUnavailable => {
- OperationAdapterError::UnavailableOrUnconfigured {
- operation_id: operation_id.to_owned(),
- message,
- }
+ CommandDisposition::Unconfigured => {
+ OperationAdapterError::unconfigured(operation_id, message)
+ }
+ CommandDisposition::ExternalUnavailable => {
+ OperationAdapterError::unavailable(operation_id, message)
}
CommandDisposition::Unsupported => OperationAdapterError::InvalidInput {
operation_id: operation_id.to_owned(),
diff --git a/src/output_contract.rs b/src/output_contract.rs
@@ -128,7 +128,7 @@ pub enum CliExitCode {
Success,
InternalError,
InvalidInput,
- UnavailableOrUnconfigured,
+ RuntimeUnavailable,
NotFound,
AuthorizationFailed,
ApprovalRequiredOrDenied,
@@ -145,7 +145,7 @@ impl CliExitCode {
Self::Success => 0,
Self::InternalError => 1,
Self::InvalidInput => 2,
- Self::UnavailableOrUnconfigured => 3,
+ Self::RuntimeUnavailable => 3,
Self::NotFound => 4,
Self::AuthorizationFailed => 5,
Self::ApprovalRequiredOrDenied => 6,
diff --git a/tests/signer_runtime_modes.rs b/tests/signer_runtime_modes.rs
@@ -289,8 +289,9 @@ fn myc_listing_publish_does_not_fallback_to_local_account() {
assert!(!output.status.success());
assert_eq!(value["operation_id"], "listing.publish");
assert_eq!(value["result"], serde_json::Value::Null);
- assert_eq!(value["errors"][0]["code"], "unavailable_or_unconfigured");
- assert_eq!(value["errors"][0]["exit_code"], 3);
+ assert_eq!(value["errors"][0]["code"], "signer_unconfigured");
+ assert_eq!(value["errors"][0]["exit_code"], 7);
+ assert_eq!(value["errors"][0]["detail"]["class"], "signer");
assert_contains(&value["errors"][0]["message"], "signer.remote_nip46");
}