ralph/cli/machine/
error.rs1use anyhow::Result;
19
20use crate::contracts::{MACHINE_ERROR_VERSION, MachineErrorCode, MachineErrorDocument};
21
22pub fn print_machine_error(err: &anyhow::Error) -> Result<()> {
23 eprintln!(
24 "{}",
25 serde_json::to_string_pretty(&build_machine_error_document(err))?
26 );
27 Ok(())
28}
29
30fn build_machine_error_document(err: &anyhow::Error) -> MachineErrorDocument {
31 let detail = sanitized_detail(err);
32 let normalized = detail.to_ascii_lowercase();
33
34 let (code, message, retryable) = if normalized.contains("task mutation conflict for") {
35 (
36 MachineErrorCode::TaskMutationConflict,
37 "Task changed on disk before Ralph could apply the mutation.",
38 false,
39 )
40 } else if normalized.contains("permission denied") {
41 (
42 MachineErrorCode::PermissionDenied,
43 "Permission denied.",
44 false,
45 )
46 } else if normalized.contains("queue file") && normalized.contains("no such file") {
47 (
48 MachineErrorCode::QueueCorrupted,
49 "No Ralph queue file found.",
50 false,
51 )
52 } else if normalized.contains("queue validation failed")
53 || normalized.contains("done archive validation failed")
54 || (normalized.contains("queue")
55 && (normalized.contains("corrupt") || normalized.contains("invalid")))
56 || normalized.contains("duplicate id")
57 || normalized.contains("invalid timestamp")
58 {
59 (
60 MachineErrorCode::QueueCorrupted,
61 "Queue data appears corrupted.",
62 false,
63 )
64 } else if normalized.contains("load project config")
65 || normalized.contains("load global config")
66 || normalized.contains("unsupported config version")
67 || (normalized.contains("unknown field") && normalized.contains("config"))
68 {
69 (
70 MachineErrorCode::ConfigIncompatible,
71 "Workspace config is incompatible with this Ralph version.",
72 false,
73 )
74 } else if normalized.contains("version")
75 && (normalized.contains("minimum supported version")
76 || normalized.contains("newer than supported")
77 || normalized.contains("too old")
78 || normalized.contains("too new"))
79 {
80 (
81 MachineErrorCode::VersionMismatch,
82 "Ralph CLI version is incompatible with this app.",
83 false,
84 )
85 } else if normalized.contains("network")
86 || normalized.contains("connection")
87 || normalized.contains("timed out")
88 {
89 (
90 MachineErrorCode::NetworkError,
91 "Network operation failed.",
92 false,
93 )
94 } else if normalized.contains("resource temporarily unavailable")
95 || normalized.contains("resource busy")
96 || normalized.contains("file locked")
97 || normalized.contains("operation would block")
98 || normalized.contains("device or resource busy")
99 || normalized.contains("eagain")
100 || normalized.contains("ewouldblock")
101 || normalized.contains("ebusy")
102 {
103 (
104 MachineErrorCode::ResourceBusy,
105 "Resource temporarily unavailable.",
106 true,
107 )
108 } else if normalized.contains("parse")
109 || normalized.contains("decode")
110 || normalized.contains("json")
111 {
112 (
113 MachineErrorCode::ParseError,
114 "Unable to parse CLI output.",
115 false,
116 )
117 } else {
118 (
119 MachineErrorCode::Unknown,
120 "Ralph CLI command failed.",
121 false,
122 )
123 };
124
125 let detail = if detail == message {
126 None
127 } else {
128 Some(detail)
129 };
130
131 MachineErrorDocument {
132 version: MACHINE_ERROR_VERSION,
133 code,
134 message: message.to_string(),
135 detail,
136 retryable,
137 }
138}
139
140fn sanitized_detail(err: &anyhow::Error) -> String {
141 let redacted = crate::redaction::redact_text(&format!("{:#}", err));
142 let trimmed = redacted.trim();
143 if trimmed.is_empty() {
144 return "Ralph CLI command failed.".to_string();
145 }
146
147 let truncated: String = trimmed.chars().take(2_000).collect();
148 if truncated.chars().count() == trimmed.chars().count() {
149 truncated
150 } else {
151 format!("{truncated}…")
152 }
153}
154
155#[cfg(test)]
156mod tests {
157 use super::*;
158
159 #[test]
160 fn build_machine_error_document_classifies_queue_missing() {
161 let err = anyhow::anyhow!(
162 "read queue file /tmp/example/.ralph/queue.jsonc: No such file or directory (os error 2)"
163 );
164
165 let document = build_machine_error_document(&err);
166 assert_eq!(document.code, MachineErrorCode::QueueCorrupted);
167 assert_eq!(document.message, "No Ralph queue file found.");
168 assert!(!document.retryable);
169 assert!(
170 document
171 .detail
172 .as_deref()
173 .unwrap_or_default()
174 .contains("queue.jsonc")
175 );
176 }
177
178 #[test]
179 fn build_machine_error_document_classifies_task_conflict() {
180 let err = anyhow::anyhow!(
181 "Task mutation conflict for RQ-0001: expected updated_at 2026-03-30T00:00:00Z, found 2026-03-30T00:01:00Z."
182 );
183
184 let document = build_machine_error_document(&err);
185 assert_eq!(document.code, MachineErrorCode::TaskMutationConflict);
186 assert_eq!(
187 document.message,
188 "Task changed on disk before Ralph could apply the mutation."
189 );
190 assert!(!document.retryable);
191 }
192
193 #[test]
194 fn build_machine_error_document_sanitizes_unknown_failures() {
195 let err = anyhow::anyhow!("unexpected bearer sk-test-123 failure");
196
197 let document = build_machine_error_document(&err);
198 assert_eq!(document.code, MachineErrorCode::Unknown);
199 assert_eq!(document.message, "Ralph CLI command failed.");
200 let detail = document
201 .detail
202 .expect("unknown failures keep sanitized detail");
203 assert!(!detail.contains("sk-test-123"));
204 assert!(detail.contains("[REDACTED]"));
205 }
206}