commit ce55a8592556ad3fe4f522232482f3cba029cc36
parent d5aaed12621292f487d1465e3f03fbe537e8bcb5
Author: triesap <tyson@radroots.org>
Date: Mon, 27 Apr 2026 08:50:15 +0000
cli: classify local runtime failures
- add stable not_found and validation_failed output errors
- route listing runtime failures through the typed classifier
- preserve account and signer classifications for listing writes
- cover missing and invalid listing draft failures in tests
Diffstat:
3 files changed, 160 insertions(+), 15 deletions(-)
diff --git a/src/operation_adapter.rs b/src/operation_adapter.rs
@@ -1,6 +1,7 @@
#![allow(dead_code)]
use std::fmt::Debug;
+use std::io::ErrorKind;
use serde::Serialize;
use serde_json::{Map, Value, json};
@@ -10,6 +11,7 @@ use crate::output_contract::{
CliExitCode, EnvelopeActor, EnvelopeContext, NextAction, OUTPUT_SCHEMA_VERSION, OutputEnvelope,
OutputError, OutputWarning,
};
+use crate::runtime::RuntimeError;
use crate::target_cli::{TargetCliArgs, TargetOutputFormat};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -298,6 +300,16 @@ pub enum OperationAdapterError {
operation_id: String,
message: String,
},
+ #[error("resource not found for `{operation_id}`: {message}")]
+ NotFound {
+ operation_id: String,
+ message: String,
+ },
+ #[error("validation failed for `{operation_id}`: {message}")]
+ ValidationFailed {
+ operation_id: String,
+ message: String,
+ },
#[error("approval required for `{operation_id}`: {message}")]
ApprovalRequired {
operation_id: String,
@@ -386,6 +398,35 @@ impl OperationAdapterError {
)
}
+ pub fn runtime_failure(operation_id: &str, error: RuntimeError) -> Self {
+ let message = error.to_string();
+ let lowered = message.to_ascii_lowercase();
+ match &error {
+ RuntimeError::Io(io_error) if io_error.kind() == ErrorKind::NotFound => {
+ Self::NotFound {
+ operation_id: operation_id.to_owned(),
+ message,
+ }
+ }
+ RuntimeError::Config(_) if looks_like_not_found(&lowered) => Self::NotFound {
+ operation_id: operation_id.to_owned(),
+ message,
+ },
+ RuntimeError::Config(_) if looks_like_validation_failure(&lowered) => {
+ Self::ValidationFailed {
+ operation_id: operation_id.to_owned(),
+ message,
+ }
+ }
+ RuntimeError::Accounts(_) => classify_runtime_failure(
+ operation_id,
+ message,
+ RuntimeFailureAvailability::Unavailable,
+ ),
+ _ => Self::Runtime(message),
+ }
+ }
+
pub fn to_output_error(&self) -> OutputError {
match self {
Self::ApprovalRequired { message, .. } => OutputError::new(
@@ -396,6 +437,26 @@ impl OperationAdapterError {
Self::InvalidInput { message, .. } => {
OutputError::new("invalid_input", message.clone(), CliExitCode::InvalidInput)
}
+ Self::NotFound {
+ operation_id,
+ message,
+ } => runtime_output_error(
+ "not_found",
+ operation_id,
+ "resource",
+ message,
+ CliExitCode::NotFound,
+ ),
+ Self::ValidationFailed {
+ operation_id,
+ message,
+ } => runtime_output_error(
+ "validation_failed",
+ operation_id,
+ "validation",
+ message,
+ CliExitCode::ValidationFailed,
+ ),
Self::OfflineForbidden {
operation_id,
message,
@@ -633,6 +694,32 @@ fn contains_any(value: &str, needles: &[&str]) -> bool {
needles.iter().any(|needle| value.contains(needle))
}
+fn looks_like_not_found(value: &str) -> bool {
+ contains_any(
+ value,
+ &[
+ "not found",
+ "no such file or directory",
+ "path not found",
+ "missing file",
+ ],
+ )
+}
+
+fn looks_like_validation_failure(value: &str) -> bool {
+ contains_any(
+ value,
+ &[
+ "invalid",
+ "parse ",
+ "parse:",
+ "must not",
+ "must be",
+ "validation",
+ ],
+ )
+}
+
fn runtime_output_error(
code: &str,
operation_id: &str,
@@ -1056,6 +1143,8 @@ pub fn adapter_registry_linkage_is_valid() -> bool {
#[cfg(test)]
mod tests {
+ use std::io;
+
use clap::Parser;
use serde_json::json;
@@ -1066,6 +1155,7 @@ mod tests {
adapter_registry_linkage_is_valid,
};
use crate::operation_registry::OPERATION_REGISTRY;
+ use crate::runtime::RuntimeError;
use crate::target_cli::TargetCliArgs;
#[test]
@@ -1250,6 +1340,24 @@ mod tests {
"operation",
3,
),
+ (
+ OperationAdapterError::runtime_failure(
+ "listing.publish",
+ RuntimeError::Io(io::Error::new(io::ErrorKind::NotFound, "missing draft")),
+ ),
+ "not_found",
+ "resource",
+ 4,
+ ),
+ (
+ OperationAdapterError::runtime_failure(
+ "listing.validate",
+ RuntimeError::Config("invalid listing draft listing.toml".to_owned()),
+ ),
+ "validation_failed",
+ "validation",
+ 10,
+ ),
];
for (error, code, class, exit_code) in cases {
diff --git a/src/operation_listing.rs b/src/operation_listing.rs
@@ -60,7 +60,10 @@ impl OperationService<ListingCreateRequest> for ListingOperationService<'_> {
}));
}
- let view = map_runtime(crate::runtime::listing::scaffold(self.config, &args))?;
+ let view = map_runtime(
+ request.operation_id(),
+ crate::runtime::listing::scaffold(self.config, &args),
+ )?;
serialized_operation_result::<ListingCreateResult, _>(&view)
}
}
@@ -75,7 +78,10 @@ impl OperationService<ListingGetRequest> for ListingOperationService<'_> {
let args = RecordLookupArgs {
key: required_string(&request, "key")?,
};
- let view = map_runtime(crate::runtime::listing::get(self.config, &args))?;
+ let view = map_runtime(
+ request.operation_id(),
+ crate::runtime::listing::get(self.config, &args),
+ )?;
serialized_operation_result::<ListingGetResult, _>(&view)
}
}
@@ -107,7 +113,10 @@ impl OperationService<ListingUpdateRequest> for ListingOperationService<'_> {
) -> Result<OperationResult<Self::Result>, OperationAdapterError> {
let args = mutation_args(&request)?;
let config = mutation_config(self.config, &request);
- let view = map_runtime(crate::runtime::listing::update(&config, &args))?;
+ let view = map_runtime(
+ request.operation_id(),
+ crate::runtime::listing::update(&config, &args),
+ )?;
serialized_operation_result::<ListingUpdateResult, _>(&view)
}
}
@@ -122,7 +131,10 @@ impl OperationService<ListingValidateRequest> for ListingOperationService<'_> {
let args = ListingFileArgs {
file: required_path(&request, "file")?,
};
- let view = map_runtime(crate::runtime::listing::validate(self.config, &args))?;
+ let view = map_runtime(
+ request.operation_id(),
+ crate::runtime::listing::validate(self.config, &args),
+ )?;
serialized_operation_result::<ListingValidateResult, _>(&view)
}
}
@@ -157,7 +169,10 @@ impl OperationService<ListingArchiveRequest> for ListingOperationService<'_> {
}
let args = mutation_args(&request)?;
let config = mutation_config(self.config, &request);
- let view = map_runtime(crate::runtime::listing::archive(&config, &args))?;
+ let view = map_runtime(
+ request.operation_id(),
+ crate::runtime::listing::archive(&config, &args),
+ )?;
mutation_result::<ListingArchiveResult>(request.operation_id(), &view)
}
}
@@ -262,8 +277,11 @@ where
OperationResult::new(R::from_value(value))
}
-fn map_runtime<T>(result: Result<T, RuntimeError>) -> Result<T, OperationAdapterError> {
- result.map_err(|error| OperationAdapterError::Runtime(error.to_string()))
+fn map_runtime<T>(
+ operation_id: &str,
+ result: Result<T, RuntimeError>,
+) -> Result<T, OperationAdapterError> {
+ result.map_err(|error| OperationAdapterError::runtime_failure(operation_id, error))
}
fn publish_runtime_error(operation_id: &str, error: RuntimeError) -> OperationAdapterError {
@@ -276,13 +294,7 @@ fn publish_runtime_error(operation_id: &str, error: RuntimeError) -> OperationAd
{
return OperationAdapterError::unconfigured(operation_id, message);
}
- if matches!(&error, RuntimeError::Config(_)) {
- return OperationAdapterError::InvalidInput {
- operation_id: operation_id.to_owned(),
- message,
- };
- }
- OperationAdapterError::Runtime(message)
+ OperationAdapterError::runtime_failure(operation_id, error)
}
fn required_string<P>(
diff --git a/tests/target_cli.rs b/tests/target_cli.rs
@@ -1,5 +1,7 @@
mod support;
+use std::fs;
+
use serde_json::Value;
use support::{
@@ -345,7 +347,8 @@ fn listing_publish_dry_run_validates_missing_file() {
assert!(!output.status.success());
assert_eq!(value["operation_id"], "listing.publish");
assert_eq!(value["result"], Value::Null);
- assert_eq!(value["errors"][0]["code"], "runtime_error");
+ assert_eq!(value["errors"][0]["code"], "not_found");
+ assert_eq!(value["errors"][0]["exit_code"], 4);
assert_no_removed_command_reference(
&value,
&["listing", "publish", "--dry-run", "missing-listing.toml"],
@@ -353,6 +356,28 @@ fn listing_publish_dry_run_validates_missing_file() {
}
#[test]
+fn listing_publish_invalid_draft_returns_validation_failure() {
+ let sandbox = RadrootsCliSandbox::new();
+ let invalid = sandbox.root().join("invalid-listing.toml");
+ fs::write(&invalid, "listing = [").expect("write invalid listing");
+
+ let (output, value) = sandbox.json_output(&[
+ "--format",
+ "json",
+ "--dry-run",
+ "listing",
+ "publish",
+ invalid.to_string_lossy().as_ref(),
+ ]);
+
+ assert!(!output.status.success());
+ assert_eq!(value["operation_id"], "listing.publish");
+ assert_eq!(value["result"], Value::Null);
+ assert_eq!(value["errors"][0]["code"], "validation_failed");
+ assert_eq!(value["errors"][0]["exit_code"], 10);
+}
+
+#[test]
fn online_requires_relay_for_external_network_operations() {
let output = radroots()
.args(["--format", "json", "--online", "market", "refresh"])