Apply stable CLI failure exit codes

This commit is contained in:
Michel Paulissen 2026-07-03 22:43:03 +02:00
parent 50d298ead7
commit b808dad0e5
8 changed files with 224 additions and 7 deletions

View file

@ -1676,12 +1676,16 @@ fn admin_suspend_tenant_report(args: AdminSuspendTenantArgs) -> Result<Value> {
}
fn emit_report<T: Serialize>(report: &T, json_output: bool) -> Result<()> {
let value = serde_json::to_value(report)?;
let mut value = serde_json::to_value(report)?;
let exit_code = apply_command_report_exit_code(&mut value);
if json_output {
println!("{}", serde_json::to_string_pretty(&value)?);
} else {
println!("{}", human_report(&value));
}
if let Some(exit_code) = exit_code {
std::process::exit(exit_code);
}
Ok(())
}
@ -2046,6 +2050,33 @@ fn compact_json(value: &Value) -> String {
serde_json::to_string(value).unwrap_or_else(|_| "<unprintable>".to_owned())
}
fn apply_command_report_exit_code(value: &mut Value) -> Option<i32> {
for pointer in [
"/machine_error",
"/run_start/machine_error",
"/restart_request/machine_error",
"/cancel_request/machine_error",
"/task_restart/machine_error",
] {
let Some(machine_error) = value.pointer_mut(pointer) else {
continue;
};
let Some(exit_code) = machine_error
.get("stable_exit_code")
.and_then(Value::as_i64)
.and_then(|code| i32::try_from(code).ok())
.filter(|code| *code != 0)
else {
continue;
};
if let Some(object) = machine_error.as_object_mut() {
object.insert("process_exit_code_applied".to_owned(), json!(true));
}
return Some(exit_code);
}
None
}
fn cli_error_summary(message: &str) -> Value {
let category = classify_cli_error_message(message);
cli_error_summary_for_category(category, message)
@ -2230,7 +2261,25 @@ fn cli_error_next_actions(category: &str) -> Vec<&'static str> {
}
}
fn main() -> Result<()> {
fn main() {
if let Err(error) = run_cli() {
let message = error.to_string();
let machine_error = cli_error_summary(&message);
let category = machine_error
.get("category")
.and_then(Value::as_str)
.unwrap_or("unknown");
let exit_code = machine_error
.get("stable_exit_code")
.and_then(Value::as_i64)
.and_then(|code| i32::try_from(code).ok())
.unwrap_or(1);
eprintln!("Error ({category}, exit {exit_code}): {error:#}");
std::process::exit(exit_code);
}
}
fn run_cli() -> Result<()> {
let cli = Cli::parse();
match cli.command {
Commands::Doctor(args) => {
@ -2912,15 +2961,25 @@ fn artifact_name_from_path(path: &str) -> String {
path.rsplit('/').next().unwrap_or(path).to_owned()
}
fn response_error_message(response: &Value, fallback: &str) -> String {
response
.get("message")
.and_then(Value::as_str)
.map(str::to_owned)
.unwrap_or_else(|| fallback.to_owned())
}
fn process_restart_request_summary(response: &Value, requires_confirmation: bool) -> Value {
if response.get("type").and_then(Value::as_str) != Some("process_started") {
let message = response_error_message(response, "coordinator rejected process restart");
return json!({
"status": response.get("type").and_then(Value::as_str).unwrap_or("coordinator_response"),
"operation": "restart_virtual_process",
"accepted": false,
"requires_confirmation": requires_confirmation,
"explicit_user_action": true,
"error": response.get("message").cloned().unwrap_or(Value::Null),
"error": message,
"machine_error": cli_error_summary(&message),
});
}
@ -2939,6 +2998,7 @@ fn process_restart_request_summary(response: &Value, requires_confirmation: bool
fn process_cancel_request_summary(response: &Value, requires_confirmation: bool) -> Value {
if response.get("type").and_then(Value::as_str) != Some("process_cancellation_requested") {
let message = response_error_message(response, "coordinator rejected process cancel");
return json!({
"status": response.get("type").and_then(Value::as_str).unwrap_or("coordinator_response"),
"operation": "cancel_virtual_process",
@ -2946,7 +3006,8 @@ fn process_cancel_request_summary(response: &Value, requires_confirmation: bool)
"requires_confirmation": requires_confirmation,
"explicit_user_action": true,
"whole_process_cancel_available": true,
"error": response.get("message").cloned().unwrap_or(Value::Null),
"error": message,
"machine_error": cli_error_summary(&message),
});
}
@ -2975,6 +3036,7 @@ fn process_cancel_request_summary(response: &Value, requires_confirmation: bool)
fn task_restart_request_summary(response: &Value, requires_confirmation: bool) -> Value {
if response.get("type").and_then(Value::as_str) != Some("task_restart") {
let message = response_error_message(response, "coordinator rejected task restart");
return json!({
"status": response.get("type").and_then(Value::as_str).unwrap_or("coordinator_response"),
"operation": "restart_selected_task",
@ -2982,7 +3044,8 @@ fn task_restart_request_summary(response: &Value, requires_confirmation: bool) -
"requires_confirmation": requires_confirmation,
"explicit_user_action": true,
"clean_boundary_required": true,
"error": response.get("message").cloned().unwrap_or(Value::Null),
"error": message,
"machine_error": cli_error_summary(&message),
});
}
@ -4415,6 +4478,39 @@ mod tests {
}
}
#[test]
fn command_report_exit_code_marks_command_failures_only() {
let run_start = run_start_summary(&json!({
"type": "error",
"message": "quota unavailable: resource limit exceeded for api_calls",
}));
let mut report = json!({
"command": "run",
"status": run_start["status"].clone(),
"run_start": run_start,
});
assert_eq!(apply_command_report_exit_code(&mut report), Some(22));
assert_eq!(
report["run_start"]["machine_error"]["process_exit_code_applied"],
true
);
let mut task_list = json!({
"command": "task list",
"tasks": [{
"task": "compile",
"state": "failed",
"machine_error": cli_error_summary_for_category("program", "task exited with status 1"),
}],
});
assert_eq!(apply_command_report_exit_code(&mut task_list), None);
assert_eq!(
task_list["tasks"][0]["machine_error"]["process_exit_code_applied"],
false
);
}
#[test]
fn top_level_version_is_available() {
let command = Cli::command();