cli

Command-line interface for Radroots
git clone https://radroots.dev/git/cli.git
Log | Files | Refs | README | LICENSE

commit f114708403f2bfbb404a6f90b192a7b87eba8be1
parent 7b8710675cbbe50f062fe34312bb244ee2fbbb66
Author: triesap <tyson@radroots.org>
Date:   Wed, 13 May 2026 03:06:00 +0000

cli: harden machine output reasons

- add status, output format, resource, and reason code fields
- propagate reason semantics into JSON and NDJSON envelopes
- document the machine-output contract for operators
- cover success, invalid-input, and deferred-payment states

Diffstat:
Msrc/operation_adapter.rs | 27+++++++++++----------------
Msrc/output_contract.rs | 179++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mtests/target_cli.rs | 68+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
3 files changed, 254 insertions(+), 20 deletions(-)

diff --git a/src/operation_adapter.rs b/src/operation_adapter.rs @@ -9,8 +9,8 @@ use serde_json::{Map, Value, json}; 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, next_actions_from_result_value, + CliExitCode, EnvelopeActor, EnvelopeContext, NextAction, OutputEnvelope, OutputError, + OutputFormat, OutputWarning, next_actions_from_result_value, }; use crate::runtime::RuntimeError; use crate::runtime::accounts::AccountRuntimeFailure; @@ -112,6 +112,11 @@ impl OperationContext { pub fn envelope_context(&self, request_id: impl Into<String>) -> EnvelopeContext { let mut context = EnvelopeContext::new(request_id, self.dry_run); + context.output_format = match self.output_format { + OperationOutputFormat::Human => OutputFormat::Human, + OperationOutputFormat::Json => OutputFormat::Json, + OperationOutputFormat::Ndjson => OutputFormat::Ndjson, + }; context.correlation_id = self.correlation_id.clone(); context.idempotency_key = self.idempotency_key.clone(); context.actor = self.account_id.as_ref().map(|account_id| EnvelopeActor { @@ -242,20 +247,10 @@ impl<P: OperationResultPayload> OperationResult<P> { } else { self.next_actions.clone() }; - Ok(OutputEnvelope { - schema_version: OUTPUT_SCHEMA_VERSION, - operation_id: self.operation_id().to_owned(), - kind: self.operation_id().to_owned(), - request_id: context.request_id, - correlation_id: context.correlation_id, - idempotency_key: context.idempotency_key, - dry_run: context.dry_run, - actor: context.actor, - result, - warnings: self.warnings.clone(), - errors: Vec::new(), - next_actions, - }) + let mut envelope = OutputEnvelope::success(self.operation_id(), result, context); + envelope.warnings = self.warnings.clone(); + envelope.next_actions = next_actions; + Ok(envelope) } } diff --git a/src/output_contract.rs b/src/output_contract.rs @@ -10,6 +10,7 @@ pub struct EnvelopeContext { pub request_id: String, pub correlation_id: Option<String>, pub idempotency_key: Option<String>, + pub output_format: OutputFormat, pub dry_run: bool, pub actor: Option<EnvelopeActor>, } @@ -20,6 +21,7 @@ impl EnvelopeContext { request_id: request_id.into(), correlation_id: None, idempotency_key: None, + output_format: OutputFormat::Human, dry_run, actor: None, } @@ -37,12 +39,16 @@ pub struct OutputEnvelope { pub schema_version: &'static str, pub operation_id: String, pub kind: String, + pub status: OutputStatus, + pub output_format: OutputFormat, pub request_id: String, pub correlation_id: Option<String>, pub idempotency_key: Option<String>, pub dry_run: bool, pub actor: Option<EnvelopeActor>, + pub resource: Option<OutputResource>, pub result: Value, + pub reason_code: Option<String>, pub warnings: Vec<OutputWarning>, pub errors: Vec<OutputError>, pub next_actions: Vec<NextAction>, @@ -55,16 +61,22 @@ impl OutputEnvelope { context: EnvelopeContext, ) -> Self { let operation_id = operation_id.into(); + let resource = output_resource_from_value(&result); + let reason_code = output_reason_code_from_value(&result); Self { schema_version: OUTPUT_SCHEMA_VERSION, kind: operation_id.clone(), operation_id, + status: OutputStatus::Ok, + output_format: context.output_format, request_id: context.request_id, correlation_id: context.correlation_id, idempotency_key: context.idempotency_key, dry_run: context.dry_run, actor: context.actor, + resource, result, + reason_code, warnings: Vec::new(), errors: Vec::new(), next_actions: Vec::new(), @@ -78,16 +90,22 @@ impl OutputEnvelope { ) -> Self { let operation_id = operation_id.into(); let next_actions = next_actions_from_error_detail(&error); + let resource = error.detail.as_ref().and_then(output_resource_from_value); + let reason_code = Some(error.reason_code.clone()); Self { schema_version: OUTPUT_SCHEMA_VERSION, kind: operation_id.clone(), operation_id, + status: OutputStatus::Error, + output_format: context.output_format, request_id: context.request_id, correlation_id: context.correlation_id, idempotency_key: context.idempotency_key, dry_run: context.dry_run, actor: context.actor, + resource, result: Value::Null, + reason_code, warnings: Vec::new(), errors: vec![error], next_actions, @@ -102,10 +120,13 @@ impl OutputEnvelope { NdjsonFrameType::Started, json!({ "state": "started", + "status": self.status, + "output_format": self.output_format, "dry_run": self.dry_run, "correlation_id": &self.correlation_id, "idempotency_key": &self.idempotency_key, "actor": &self.actor, + "resource": &self.resource, }), ); let mut terminal = NdjsonFrame::new( @@ -118,6 +139,10 @@ impl OutputEnvelope { NdjsonFrameType::Error }, json!({ + "status": self.status, + "reason_code": &self.reason_code, + "output_format": self.output_format, + "resource": &self.resource, "result": &self.result, "next_actions": &self.next_actions, "dry_run": self.dry_run, @@ -132,6 +157,100 @@ impl OutputEnvelope { } } +fn output_reason_code_from_value(value: &Value) -> Option<String> { + value + .get("reason_code") + .and_then(Value::as_str) + .filter(|reason_code| !reason_code.trim().is_empty()) + .map(str::to_owned) +} + +fn output_resource_from_value(value: &Value) -> Option<OutputResource> { + let object = value.as_object()?; + if let Some(resource) = object.get("resource").and_then(declared_output_resource) { + return Some(resource); + } + output_resource_from_fields(object).or_else(|| { + let nested_fields = [ + "account", + "resolved_account", + "default_account", + "bound_account", + "farm", + "listing", + "basket", + "quote", + "order", + "payment", + "settlement", + ]; + nested_fields + .into_iter() + .filter_map(|field| { + object + .get(field) + .and_then(|value| nested_output_resource(field, value)) + }) + .next() + }) +} + +fn nested_output_resource(field: &str, value: &Value) -> Option<OutputResource> { + let mut resource = output_resource_from_value(value)?; + if resource.kind == "resource" { + resource.kind = match field { + "resolved_account" | "default_account" | "bound_account" => "account", + other => other, + } + .to_owned(); + } + Some(resource) +} + +fn declared_output_resource(value: &Value) -> Option<OutputResource> { + let object = value.as_object()?; + let kind = object + .get("kind") + .and_then(Value::as_str) + .filter(|kind| !kind.trim().is_empty())?; + let id = object + .get("id") + .and_then(Value::as_str) + .filter(|id| !id.trim().is_empty())?; + Some(OutputResource { + kind: kind.to_owned(), + id: id.to_owned(), + }) +} + +fn output_resource_from_fields(object: &serde_json::Map<String, Value>) -> Option<OutputResource> { + [ + ("account_id", "account"), + ("id", "resource"), + ("farm_id", "farm"), + ("seller_account_id", "account"), + ("buyer_account_id", "account"), + ("listing_id", "listing"), + ("listing_address", "listing"), + ("listing_addr", "listing"), + ("basket_id", "basket"), + ("order_id", "order"), + ("payment_event_id", "payment"), + ("settlement_event_id", "settlement"), + ] + .into_iter() + .find_map(|(field, kind)| { + object + .get(field) + .and_then(Value::as_str) + .filter(|id| !id.trim().is_empty()) + .map(|id| OutputResource { + kind: kind.to_owned(), + id: id.to_owned(), + }) + }) +} + pub fn next_actions_from_result_value(result: &Value) -> Vec<NextAction> { next_actions_from_actions_value(result.get("actions")) } @@ -235,9 +354,31 @@ pub struct OutputWarning { pub message: String, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum OutputStatus { + Ok, + Error, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum OutputFormat { + Human, + Json, + Ndjson, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct OutputResource { + pub kind: String, + pub id: String, +} + #[derive(Debug, Clone, PartialEq, Serialize)] pub struct OutputError { pub code: String, + pub reason_code: String, pub message: String, pub exit_code: u8, pub detail: Option<Value>, @@ -249,8 +390,10 @@ impl OutputError { message: impl Into<String>, exit_code: CliExitCode, ) -> Self { + let code = code.into(); Self { - code: code.into(), + reason_code: code.clone(), + code, message: message.into(), exit_code: exit_code.code(), detail: None, @@ -386,11 +529,16 @@ mod tests { assert_eq!(value["schema_version"], OUTPUT_SCHEMA_VERSION); assert_eq!(value["operation_id"], "listing.publish"); assert_eq!(value["kind"], "listing.publish"); + assert_eq!(value["status"], "ok"); + assert_eq!(value["output_format"], "human"); assert_eq!(value["request_id"], "req_test"); assert_eq!(value["correlation_id"], "corr_test"); assert_eq!(value["idempotency_key"], "idem_test"); assert_eq!(value["dry_run"], true); + assert_eq!(value["resource"]["kind"], "listing"); + assert_eq!(value["resource"]["id"], "listing_test"); assert_eq!(value["result"]["listing_id"], "listing_test"); + assert_eq!(value["reason_code"], Value::Null); assert_eq!(value["warnings"].as_array().unwrap().len(), 0); assert_eq!(value["errors"].as_array().unwrap().len(), 0); assert_eq!(value["next_actions"].as_array().unwrap().len(), 0); @@ -412,8 +560,11 @@ mod tests { assert_eq!(value["schema_version"], OUTPUT_SCHEMA_VERSION); assert_eq!(value["operation_id"], "order.submit"); + assert_eq!(value["status"], "error"); + assert_eq!(value["reason_code"], "approval_required"); assert_eq!(value["result"], Value::Null); assert_eq!(value["errors"][0]["code"], "approval_required"); + assert_eq!(value["errors"][0]["reason_code"], "approval_required"); assert_eq!(value["errors"][0]["exit_code"], 6); } @@ -542,6 +693,32 @@ mod tests { } #[test] + fn ndjson_terminal_frame_carries_status_reason_and_resource() { + let mut error = OutputError::new( + "not_implemented", + "payments are deferred", + CliExitCode::RuntimeUnavailable, + ); + error.detail = Some(json!({ + "order_id": "ord_test", + })); + let envelope = OutputEnvelope::failure( + "order.payment.record", + error, + EnvelopeContext::new("req_payment", false), + ); + let frames = envelope.to_ndjson_frames(); + + assert_eq!(frames[0].payload["status"], "error"); + assert_eq!(frames[0].payload["output_format"], "human"); + assert_eq!(frames[1].payload["status"], "error"); + assert_eq!(frames[1].payload["reason_code"], "not_implemented"); + assert_eq!(frames[1].payload["resource"]["kind"], "order"); + assert_eq!(frames[1].payload["resource"]["id"], "ord_test"); + assert_eq!(frames[1].errors[0].reason_code, "not_implemented"); + } + + #[test] fn exit_code_contract_matches_handoff_range() { assert_eq!(CliExitCode::Success.code(), 0); assert_eq!(CliExitCode::InvalidInput.code(), 2); diff --git a/tests/target_cli.rs b/tests/target_cli.rs @@ -26,9 +26,10 @@ use serde_json::json; use support::{ RadrootsCliSandbox, assert_contains, assert_no_daemon_runtime_reference, assert_no_removed_command_reference, create_listing_draft, identity_public, identity_secret, - make_listing_publishable, make_listing_publishable_with_seller, ndjson_from_stdout, radroots, - remove_orderable_listing, replace_latest_listing_event_id, seed_orderable_listing, toml_string, - write_public_identity_profile, write_secret_identity_profile, + json_from_stdout, make_listing_publishable, make_listing_publishable_with_seller, + ndjson_from_stdout, radroots, remove_orderable_listing, replace_latest_listing_event_id, + seed_orderable_listing, toml_string, write_public_identity_profile, + write_secret_identity_profile, }; const LISTING_ADDR: &str = @@ -2199,6 +2200,67 @@ fn unsupported_ndjson_returns_structured_invalid_input() { } #[test] +fn machine_output_exposes_status_format_resource_and_reason_code() { + let sandbox = RadrootsCliSandbox::new(); + + let account = sandbox.json_success(&["--format", "json", "account", "create"]); + assert_eq!(account["status"], "ok"); + assert_eq!(account["output_format"], "json"); + assert_eq!(account["reason_code"], Value::Null); + assert_eq!(account["resource"]["kind"], "account"); + assert_eq!( + account["resource"]["id"], + account["result"]["account"]["id"] + ); + + let (deferred_output, deferred) = sandbox.json_output(&[ + "--format", + "json", + "order", + "payment", + "record", + "ord_pending", + ]); + assert_eq!(deferred_output.status.code(), Some(3)); + assert_eq!(deferred["status"], "error"); + assert_eq!(deferred["output_format"], "json"); + assert_eq!(deferred["reason_code"], "not_implemented"); + assert_eq!(deferred["errors"][0]["reason_code"], "not_implemented"); + + let output = sandbox + .command() + .args(["--format", "json", "--dry-run", "workspace", "get"]) + .output() + .expect("run invalid dry-run"); + assert_eq!(output.status.code(), Some(2)); + let invalid = json_from_stdout(&output); + assert_eq!(invalid["status"], "error"); + assert_eq!(invalid["reason_code"], "invalid_input"); + assert_eq!(invalid["errors"][0]["reason_code"], "invalid_input"); + + let ndjson_output = sandbox + .command() + .args([ + "--format", + "ndjson", + "order", + "payment", + "record", + "ord_pending", + ]) + .output() + .expect("run deferred ndjson"); + assert_eq!(ndjson_output.status.code(), Some(3)); + let frames = ndjson_from_stdout(&ndjson_output); + assert_eq!(frames[0]["payload"]["status"], "error"); + assert_eq!(frames[0]["payload"]["output_format"], "ndjson"); + assert_eq!(frames[1]["payload"]["status"], "error"); + assert_eq!(frames[1]["payload"]["output_format"], "ndjson"); + assert_eq!(frames[1]["payload"]["reason_code"], "not_implemented"); + assert_eq!(frames[1]["errors"][0]["reason_code"], "not_implemented"); +} + +#[test] fn offline_forbids_external_network_operations() { for (operation_id, args) in [ (