Skip to main content

agent_tui_adapters/adapters/daemon/
error.rs

1//! Daemon adapter error types.
2
3use 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
13/// Adapter-layer trait for presenting errors to external consumers.
14///
15/// This trait keeps presentation concerns (error codes, categories, suggestions)
16/// in the adapter layer rather than extending inner-layer types with inherent methods.
17pub 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;