agent_tui_adapters/adapters/daemon/
error.rs1use crate::common::error_codes;
4use crate::common::error_codes::ErrorCategory;
5use crate::usecases::SpawnError;
6use crate::usecases::ports::SessionError;
7use crate::usecases::ports::SpawnErrorKind;
8use crate::usecases::ports::TerminalError;
9use serde_json::Value;
10use serde_json::json;
11use thiserror::Error;
12
13pub trait ErrorPresentation {
18 fn code(&self) -> i32;
19 fn category(&self) -> ErrorCategory;
20 fn context(&self) -> Value;
21 fn suggestion(&self) -> String;
22 fn is_retryable(&self) -> bool;
23}
24
25impl ErrorPresentation for SessionError {
26 fn code(&self) -> i32 {
27 match self {
28 SessionError::NotFound(_) => error_codes::SESSION_NOT_FOUND,
29 SessionError::NotRunning { .. } => error_codes::SESSION_NOT_FOUND,
30 SessionError::AlreadyExists(_) => error_codes::SESSION_ALREADY_EXISTS,
31 SessionError::NoActiveSession => error_codes::NO_ACTIVE_SESSION,
32 SessionError::InvalidKey(_) => error_codes::INVALID_KEY,
33 SessionError::InvalidInput { .. } => error_codes::INVALID_INPUT,
34 SessionError::LimitReached(_) => error_codes::SESSION_LIMIT,
35 SessionError::Terminal(_) => error_codes::PTY_ERROR,
36 SessionError::Persistence { .. } => error_codes::PERSISTENCE_ERROR,
37 }
38 }
39
40 fn category(&self) -> ErrorCategory {
41 error_codes::category_for_code(self.code())
42 }
43
44 fn context(&self) -> Value {
45 match self {
46 SessionError::NotFound(id) => json!({ "session_id": id }),
47 SessionError::NotRunning { session_id } => {
48 json!({ "session_id": session_id, "state": "not_running" })
49 }
50 SessionError::AlreadyExists(id) => json!({ "session_id": id }),
51 SessionError::NoActiveSession => json!({}),
52 SessionError::InvalidKey(key) => json!({ "key": key }),
53 SessionError::InvalidInput { field, reason } => {
54 json!({ "field": field, "reason": reason })
55 }
56 SessionError::LimitReached(max) => json!({ "max_sessions": max }),
57 SessionError::Terminal(terminal_err) => json!({
58 "operation": terminal_err.operation(),
59 "reason": terminal_err.reason()
60 }),
61 SessionError::Persistence {
62 operation, reason, ..
63 } => {
64 json!({ "operation": operation, "reason": reason })
65 }
66 }
67 }
68
69 fn suggestion(&self) -> String {
70 match self {
71 SessionError::NotFound(_)
72 | SessionError::NotRunning { .. }
73 | SessionError::AlreadyExists(_)
74 | SessionError::NoActiveSession => {
75 if matches!(self, SessionError::NotRunning { .. }) {
76 "Run 'sessions' to inspect the stopped session, or 'restart <session_id>' to start it again."
77 .to_string()
78 } else {
79 "Run 'sessions' to list active sessions or 'spawn <cmd>' to start a new one."
80 .to_string()
81 }
82 }
83 SessionError::InvalidKey(_) => {
84 "Supported keys: Enter, Tab, Escape, Backspace, Delete, ArrowUp/Down/Left/Right, Home, End, PageUp/Down, F1-F12. Modifiers: Ctrl+, Alt+, Shift+".to_string()
85 }
86 SessionError::InvalidInput { .. } => {
87 "Adjust the invalid input and retry the command.".to_string()
88 }
89 SessionError::LimitReached(_) => {
90 "Kill unused sessions with 'kill <session_id>' or increase limit with AGENT_TUI_MAX_SESSIONS env var.".to_string()
91 }
92 SessionError::Terminal(terminal_err) => match terminal_err {
93 TerminalError::Open { .. } => {
94 "Terminal allocation failed. Check system resource limits (ulimit -n) or try restarting."
95 .to_string()
96 }
97 TerminalError::Spawn { kind, .. } => match kind {
98 SpawnErrorKind::NotFound => {
99 "Command not found. Check if the command exists and is in PATH."
100 .to_string()
101 }
102 SpawnErrorKind::PermissionDenied => {
103 "Permission denied. Check file permissions.".to_string()
104 }
105 SpawnErrorKind::Other => {
106 "Process spawn failed. Check command syntax and permissions.".to_string()
107 }
108 },
109 TerminalError::Write { .. } => {
110 "Failed to send input to terminal. The session may have ended. Run 'sessions' to check status."
111 .to_string()
112 }
113 TerminalError::Read { .. } => {
114 "Failed to read terminal output. The session may have ended. Run 'sessions' to check status."
115 .to_string()
116 }
117 TerminalError::Resize { .. } => {
118 "Failed to resize terminal. Try again or restart the session.".to_string()
119 }
120 },
121 SessionError::Persistence { .. } => {
122 "Persistence error is non-fatal. Session continues to operate normally.".to_string()
123 }
124 }
125 }
126
127 fn is_retryable(&self) -> bool {
128 match self {
129 SessionError::Terminal(terminal_err) => terminal_err.is_retryable(),
130 SessionError::Persistence { .. } => true,
131 _ => error_codes::is_retryable(self.code()),
132 }
133 }
134}
135
136#[derive(Error, Debug)]
137pub enum DomainError {
138 #[error("Session not found: {session_id}")]
139 SessionNotFound { session_id: String },
140
141 #[error("Session not running: {session_id}")]
142 SessionNotRunning { session_id: String },
143
144 #[error("Session already exists: {session_id}")]
145 SessionAlreadyExists { session_id: String },
146
147 #[error("No active session")]
148 NoActiveSession,
149
150 #[error("Invalid key: {key}")]
151 InvalidKey { key: String },
152
153 #[error("Invalid input for {field}: {reason}")]
154 InvalidInput { field: String, reason: String },
155
156 #[error("Session limit reached: maximum {max} sessions allowed")]
157 SessionLimitReached { max: usize },
158
159 #[error("Lock timeout{}", session_id.as_ref().map(|id| format!(" for session: {id}")).unwrap_or_default())]
160 LockTimeout { session_id: Option<String> },
161
162 #[error("Terminal error during {operation}: {reason}")]
163 TerminalError { operation: String, reason: String },
164
165 #[error("Timeout waiting for: {condition}")]
166 WaitTimeout {
167 condition: String,
168 elapsed_ms: u64,
169 timeout_ms: u64,
170 },
171
172 #[error("Command not found: {command}")]
173 CommandNotFound { command: String },
174
175 #[error("Permission denied: {command}")]
176 PermissionDenied { command: String },
177
178 #[error("Persistence error during {operation}: {reason}")]
179 PersistenceError { operation: String, reason: String },
180
181 #[error("{message}")]
182 Generic { message: String },
183}
184
185impl DomainError {
186 pub fn code(&self) -> i32 {
187 match self {
188 DomainError::SessionNotFound { .. } => error_codes::SESSION_NOT_FOUND,
189 DomainError::SessionNotRunning { .. } => error_codes::SESSION_NOT_FOUND,
190 DomainError::SessionAlreadyExists { .. } => error_codes::SESSION_ALREADY_EXISTS,
191 DomainError::NoActiveSession => error_codes::NO_ACTIVE_SESSION,
192 DomainError::InvalidKey { .. } => error_codes::INVALID_KEY,
193 DomainError::InvalidInput { .. } => error_codes::INVALID_INPUT,
194 DomainError::SessionLimitReached { .. } => error_codes::SESSION_LIMIT,
195 DomainError::LockTimeout { .. } => error_codes::LOCK_TIMEOUT,
196 DomainError::TerminalError { .. } => error_codes::PTY_ERROR,
197 DomainError::WaitTimeout { .. } => error_codes::WAIT_TIMEOUT,
198 DomainError::CommandNotFound { .. } => error_codes::COMMAND_NOT_FOUND,
199 DomainError::PermissionDenied { .. } => error_codes::PERMISSION_DENIED,
200 DomainError::PersistenceError { .. } => error_codes::PERSISTENCE_ERROR,
201 DomainError::Generic { .. } => error_codes::GENERIC_ERROR,
202 }
203 }
204
205 pub fn category(&self) -> ErrorCategory {
206 error_codes::category_for_code(self.code())
207 }
208
209 pub fn context(&self) -> Value {
210 match self {
211 DomainError::SessionNotFound { session_id } => {
212 json!({ "session_id": session_id })
213 }
214 DomainError::SessionNotRunning { session_id } => {
215 json!({ "session_id": session_id, "state": "not_running" })
216 }
217 DomainError::SessionAlreadyExists { session_id } => {
218 json!({ "session_id": session_id })
219 }
220 DomainError::NoActiveSession => json!({}),
221 DomainError::InvalidKey { key } => {
222 json!({ "key": key })
223 }
224 DomainError::InvalidInput { field, reason } => {
225 json!({ "field": field, "reason": reason })
226 }
227 DomainError::SessionLimitReached { max } => {
228 json!({ "max_sessions": max })
229 }
230 DomainError::LockTimeout { session_id } => match session_id {
231 Some(id) => json!({ "session_id": id }),
232 None => json!({}),
233 },
234 DomainError::TerminalError { operation, reason } => {
235 json!({
236 "operation": operation,
237 "reason": reason
238 })
239 }
240 DomainError::WaitTimeout {
241 condition,
242 elapsed_ms,
243 timeout_ms,
244 } => {
245 json!({
246 "condition": condition,
247 "elapsed_ms": elapsed_ms,
248 "timeout_ms": timeout_ms
249 })
250 }
251 DomainError::CommandNotFound { command } => {
252 json!({ "command": command })
253 }
254 DomainError::PermissionDenied { command } => {
255 json!({ "command": command })
256 }
257 DomainError::PersistenceError { operation, reason } => {
258 json!({ "operation": operation, "reason": reason })
259 }
260 DomainError::Generic { message } => {
261 json!({ "message": message })
262 }
263 }
264 }
265
266 pub fn suggestion(&self) -> String {
267 match self {
268 DomainError::SessionNotFound { .. }
269 | DomainError::SessionNotRunning { .. }
270 | DomainError::SessionAlreadyExists { .. }
271 | DomainError::NoActiveSession => {
272 if matches!(self, DomainError::SessionNotRunning { .. }) {
273 "Run 'sessions' to inspect the stopped session, or 'restart <session_id>' to start it again."
274 .to_string()
275 } else {
276 "Run 'sessions' to list active sessions or 'spawn <cmd>' to start a new one."
277 .to_string()
278 }
279 }
280 DomainError::InvalidKey { .. } => {
281 "Supported keys: Enter, Tab, Escape, Backspace, Delete, ArrowUp/Down/Left/Right, Home, End, PageUp/Down, F1-F12. Modifiers: Ctrl+, Alt+, Shift+".to_string()
282 }
283 DomainError::InvalidInput { .. } => {
284 "Adjust the invalid input and retry the command.".to_string()
285 }
286 DomainError::SessionLimitReached { .. } => {
287 "Kill unused sessions with 'kill <session_id>' or increase limit with AGENT_TUI_MAX_SESSIONS env var.".to_string()
288 }
289 DomainError::LockTimeout { .. } => {
290 "Session is busy. Try again in a moment, or run 'sessions' to check session status."
291 .to_string()
292 }
293 DomainError::TerminalError { .. } => {
294 "Terminal communication error. The session may have ended. Run 'sessions' to check status.".to_string()
295 }
296 DomainError::WaitTimeout { condition, .. } => {
297 format!(
298 "Condition '{condition}' not met. The app may still be loading. Try 'wait --stable' or increase timeout with '-t'."
299 )
300 }
301 DomainError::CommandNotFound { command } => {
302 format!(
303 "Command '{command}' not found. Check if the command exists and is in PATH."
304 )
305 }
306 DomainError::PermissionDenied { command } => {
307 format!(
308 "Cannot execute '{command}'. Check file permissions."
309 )
310 }
311 DomainError::PersistenceError { .. } => {
312 "Persistence error is non-fatal. Session continues to operate normally."
313 .to_string()
314 }
315 DomainError::Generic { .. } => {
316 "Run 'screenshot' to see current screen state.".to_string()
317 }
318 }
319 }
320}
321
322impl From<SessionError> for DomainError {
323 fn from(err: SessionError) -> Self {
324 match err {
325 SessionError::NotFound(id) => DomainError::SessionNotFound { session_id: id },
326 SessionError::NotRunning { session_id } => {
327 DomainError::SessionNotRunning { session_id }
328 }
329 SessionError::AlreadyExists(id) => DomainError::SessionAlreadyExists { session_id: id },
330 SessionError::NoActiveSession => DomainError::NoActiveSession,
331 SessionError::InvalidKey(key) => DomainError::InvalidKey { key },
332 SessionError::InvalidInput { field, reason } => {
333 DomainError::InvalidInput { field, reason }
334 }
335 SessionError::LimitReached(max) => DomainError::SessionLimitReached { max },
336 SessionError::Terminal(terminal_err) => DomainError::TerminalError {
337 operation: terminal_err.operation().to_string(),
338 reason: terminal_err.reason().to_string(),
339 },
340 SessionError::Persistence {
341 operation, reason, ..
342 } => DomainError::PersistenceError { operation, reason },
343 }
344 }
345}
346
347impl From<SpawnError> for DomainError {
348 fn from(err: SpawnError) -> Self {
349 match err {
350 SpawnError::SessionLimitReached { max } => DomainError::SessionLimitReached { max },
351 SpawnError::SessionAlreadyExists { session_id } => {
352 DomainError::SessionAlreadyExists { session_id }
353 }
354 SpawnError::CommandNotFound { command } => DomainError::CommandNotFound { command },
355 SpawnError::PermissionDenied { command } => DomainError::PermissionDenied { command },
356 SpawnError::TerminalError { operation, reason } => {
357 DomainError::TerminalError { operation, reason }
358 }
359 }
360 }
361}
362
363#[cfg(test)]
364#[path = "error_tests.rs"]
365mod tests;