Skip to main content

winx_code_agent/
errors.rs

1use std::path::PathBuf;
2use thiserror::Error;
3
4/// Errors that can occur in the Winx application
5#[derive(Error, Debug)]
6pub enum WinxError {
7    /// Error when initializing the shell
8    #[error("Failed to initialize shell: {0}")]
9    ShellInitializationError(String),
10
11    /// Error when operating on a workspace path
12    #[error("Workspace path error: {0}")]
13    WorkspacePathError(String),
14
15    /// Error when locking the bash state
16    #[error("Failed to lock the bash state: {0}")]
17    BashStateLockError(String),
18
19    /// Error when the bash state is not initialized
20    #[error("Bash state not initialized. Please call Initialize first with type=\"first_call\" and a valid workspace path.")]
21    BashStateNotInitialized,
22
23    /// Error when a command fails for an internal execution reason.
24    #[error("Command execution failed: {0}")]
25    CommandExecutionError(String),
26
27    /// A status/input action targeted a shell with no running command.
28    #[error("{0}")]
29    NoActiveCommand(String),
30
31    /// A background command identifier is unknown or has already exited.
32    #[error("{0}")]
33    BackgroundSessionNotFound(String),
34
35    /// An interactive input action was supplied without any payload.
36    #[error("Failure: {action} cannot be empty")]
37    EmptyInteractiveInput { action: String },
38
39    /// Interactive bytes were sent to an idle shell instead of a running program.
40    #[error("{0}")]
41    InteractiveTargetNotRunning(String),
42
43    /// Error when parsing arguments
44    #[error("Failed to parse arguments: {0}")]
45    ArgumentParseError(String),
46
47    /// Error when trying to access a file or directory
48    #[error("File access error for {path}: {message}")]
49    FileAccessError { path: PathBuf, message: String },
50
51    /// Security error - path traversal or symlink escape attempt
52    #[error("Security violation: {message}")]
53    PathSecurityError { path: PathBuf, message: String },
54
55    /// A model-owned helper violated the managed `.winx/tmp/<session>` contract.
56    #[error("Temporary artifact policy rejected {path}: {message}")]
57    TemporaryArtifactPolicy { path: PathBuf, temporary_artifact_dir: PathBuf, message: String },
58
59    /// Bash created dynamic helpers that could not be projected safely before
60    /// execution and the active managed session is now over budget.
61    #[error(
62        "Temporary artifact budget exceeded in {temporary_artifact_dir}: workspace {total_bytes}/{max_total_bytes} bytes; session {session_files} files / {session_bytes} bytes (limits: {max_session_files} files / {max_session_bytes} bytes; largest file {largest_file_bytes} bytes, limit {max_file_bytes}). Run an explicit cleanup-only BashCommand against $WINX_TEMP_DIR before continuing."
63    )]
64    TemporaryArtifactBudgetExceeded {
65        temporary_artifact_dir: PathBuf,
66        total_bytes: u64,
67        max_total_bytes: u64,
68        session_bytes: u64,
69        max_session_bytes: u64,
70        session_files: usize,
71        max_session_files: usize,
72        largest_file_bytes: u64,
73        max_file_bytes: u64,
74    },
75
76    /// A valid delivery policy was paired with an action it cannot represent.
77    #[error(
78        "wait_policy={wait_policy} is not valid for BashCommand action {action}; use until_complete only with a finite foreground command"
79    )]
80    InvalidWaitPolicyForAction { wait_policy: String, action: String },
81
82    /// Syntax navigation over derived helpers exceeded its bounded session budget.
83    #[error("Derived CodeMap budget rejected {path}: {message}")]
84    DerivedCodeMapBudget {
85        path: PathBuf,
86        temporary_artifact_dir: PathBuf,
87        calls_used: usize,
88        calls_limit: usize,
89        unique_files_used: usize,
90        unique_files_limit: usize,
91        message: String,
92    },
93
94    /// Error when a command is not allowed in the current mode
95    #[error("Command not allowed: {0}")]
96    CommandNotAllowed(String),
97
98    /// Error when chat IDs don't match
99    #[error("Thread ID mismatch: {0}")]
100    ThreadIdMismatch(String),
101
102    /// A remote stateful call omitted the explicit workspace half of its
103    /// session binding.
104    #[error(
105        "Remote session binding is incomplete for thread_id `{thread_id}`: pass the exact workspace_root returned by Initialize. No operation was executed. workspace_root identifies the project session; it does not restrict target paths."
106    )]
107    WorkspaceBindingRequired { thread_id: String },
108
109    /// The supplied workspace and thread cannot belong to the same affinity
110    /// key, so executing the request could select another project's shell.
111    #[error(
112        "Session coherence check failed: thread_id `{thread_id}` does not belong to workspace_root `{workspace_root}`. No operation was executed. Initialize that workspace and preserve the returned thread_id/workspace_root pair."
113    )]
114    WorkspaceThreadMismatch { thread_id: String, workspace_root: PathBuf },
115
116    /// The requested binding disagrees with the workspace stored in the
117    /// selected durable session.
118    #[error(
119        "Session coherence check failed: thread_id `{thread_id}` is bound to `{bound_workspace}`, not `{requested_workspace}`. No operation was executed. Initialize the intended workspace and use its returned pair."
120    )]
121    WorkspaceBindingMismatch {
122        thread_id: String,
123        requested_workspace: PathBuf,
124        bound_workspace: PathBuf,
125    },
126
127    /// Remote sessions never change project identity in place. A new first
128    /// call gives the target workspace its own coherent session binding.
129    #[error(
130        "Remote workspace changes require a new session binding. No operation was executed. Call Initialize with type=\"first_call\" for `{workspace_root}` and use the new thread_id/workspace_root pair."
131    )]
132    WorkspaceChangeRequiresNewSession { workspace_root: PathBuf },
133
134    /// Error when deserializing data
135    #[error("Deserialization error: {0}")]
136    DeserializationError(String),
137
138    /// Error when serializing data
139    #[error("Serialization error: {0}")]
140    SerializationError(String),
141
142    /// Error in the search/replace format
143    #[error("Search/replace syntax error: {0}")]
144    SearchReplaceSyntaxError(String),
145
146    /// Error when search block is not found in content
147    #[error("Search block not found in content: {0}")]
148    SearchBlockNotFound(String),
149
150    /// Error when search block matches multiple locations (WCGW-style)
151    #[error("Search block matched multiple times")]
152    SearchBlockAmbiguous { block_content: String, match_count: usize, suggestions: Vec<String> },
153
154    /// Path-aware context for a planning failure in an atomic multi-file edit.
155    /// The boxed source preserves the original recovery class instead of
156    /// flattening it into an argument/string error.
157    #[error(
158        "MultiFileEdit aborted before writing anything - file {index} ({path}) failed validation: {source}"
159    )]
160    MultiFilePlanError { index: usize, path: PathBuf, source: Box<WinxError> },
161
162    /// Enhanced search/replace syntax error with detailed context
163    #[error("Search/replace syntax error: {message}")]
164    SearchReplaceSyntaxErrorDetailed {
165        message: String,
166        line_number: Option<usize>,
167        block_type: Option<String>,
168        suggestions: Vec<String>,
169    },
170
171    /// Error when JSON parsing fails
172    #[error("Invalid JSON: {0}")]
173    JsonParseError(String),
174
175    /// Error when a file is too large for operation
176    #[error("File {path} is too large: {size} bytes (max {max_size})")]
177    FileTooLarge { path: PathBuf, size: u64, max_size: u64 },
178
179    /// Error when writing to a file
180    #[error("Failed to write file {path}: {message}")]
181    FileWriteError { path: PathBuf, message: String },
182
183    /// Error loading data
184    #[error("Failed to load data: {0}")]
185    DataLoadingError(String),
186
187    /// Parameter validation error
188    #[error("Invalid parameter: {field} - {message}")]
189    ParameterValidationError { field: String, message: String },
190
191    /// Required parameter missing error
192    #[error("Required parameter missing: {field} - {message}")]
193    MissingParameterError { field: String, message: String },
194
195    /// Null or undefined value error
196    #[error("Null or undefined value where object expected: {field}")]
197    NullValueError { field: String },
198
199    /// Recovery suggestion error with potential solutions
200    #[error("{message} - {suggestion}")]
201    RecoverableSuggestionError { message: String, suggestion: String },
202
203    /// Context save error
204    #[error("Context save error: {0}")]
205    ContextSaveError(String),
206
207    /// Command timeout error
208    #[error("Command timed out after {timeout_seconds}s: {command}")]
209    CommandTimeout { command: String, timeout_seconds: u64 },
210
211    /// Interactive command detected error
212    #[error(
213        "Interactive command detected: {command}. Use appropriate flags or consider alternatives."
214    )]
215    InteractiveCommandDetected { command: String },
216
217    /// Command already running error
218    #[error("A command is already running: '{current_command}' (for {duration_seconds:.1}s). Use status_check, send_text, or interrupt.")]
219    CommandAlreadyRunning { current_command: String, duration_seconds: f64 },
220
221    /// Process cleanup error
222    #[error("Failed to cleanup process: {message}")]
223    ProcessCleanupError { message: String },
224
225    /// Buffer overflow error
226    #[error("Command output exceeded maximum size: {size} bytes (max {max_size})")]
227    BufferOverflow { size: usize, max_size: usize },
228
229    /// Session recovery error
230    #[error("Failed to recover bash session: {message}")]
231    SessionRecoveryError { message: String },
232
233    /// Resource allocation error
234    #[error("Resource allocation failed: {message}")]
235    ResourceAllocationError { message: String },
236
237    /// IO error
238    #[error("IO error: {0}")]
239    IoError(#[from] std::io::Error),
240
241    /// Configuration error
242    #[error("Configuration error: {0}")]
243    ConfigurationError(String),
244
245    /// Parse error for responses
246    #[error("Parse error: {0}")]
247    ParseError(String),
248
249    /// Invalid input error
250    #[error("Invalid input: {0}")]
251    InvalidInput(String),
252
253    /// File error for file operations
254    #[error("File error: {0}")]
255    FileError(String),
256}
257
258/// Type alias for Result with `WinxError`
259pub type Result<T> = std::result::Result<T, WinxError>;
260
261/// Conversion from `anyhow::Error` to `WinxError`
262impl From<anyhow::Error> for WinxError {
263    fn from(error: anyhow::Error) -> Self {
264        // `{:#}` renders the full anyhow context chain inline ("failed X: caused
265        // by Y: root Z") instead of just the outermost message, so a PTY-spawn or
266        // persistence failure keeps its real cause instead of a bare one-liner.
267        WinxError::CommandExecutionError(format!("{error:#}"))
268    }
269}
270
271/// Advanced error recovery and suggestion options
272pub struct ErrorRecovery;
273
274impl ErrorRecovery {
275    pub fn suggest(error: WinxError, _suggestion: &str) -> WinxError {
276        error
277    }
278
279    pub fn param_error(field: &str, message: &str) -> WinxError {
280        WinxError::ParameterValidationError {
281            field: field.to_string(),
282            message: message.to_string(),
283        }
284    }
285
286    pub fn missing_param(field: &str, message: &str) -> WinxError {
287        WinxError::MissingParameterError { field: field.to_string(), message: message.to_string() }
288    }
289
290    pub fn null_value(field: &str) -> WinxError {
291        WinxError::NullValueError { field: field.to_string() }
292    }
293}
294
295/// Enable cloning for `WinxError`
296impl Clone for WinxError {
297    #[allow(clippy::too_many_lines)] // exhaustive variant-preserving clone
298    fn clone(&self) -> Self {
299        match self {
300            Self::ShellInitializationError(msg) => Self::ShellInitializationError(msg.clone()),
301            Self::WorkspacePathError(msg) => Self::WorkspacePathError(msg.clone()),
302            Self::BashStateLockError(msg) => Self::BashStateLockError(msg.clone()),
303            Self::BashStateNotInitialized => Self::BashStateNotInitialized,
304            Self::CommandExecutionError(msg) => Self::CommandExecutionError(msg.clone()),
305            Self::NoActiveCommand(msg) => Self::NoActiveCommand(msg.clone()),
306            Self::BackgroundSessionNotFound(msg) => Self::BackgroundSessionNotFound(msg.clone()),
307            Self::EmptyInteractiveInput { action } => {
308                Self::EmptyInteractiveInput { action: action.clone() }
309            }
310            Self::InteractiveTargetNotRunning(msg) => {
311                Self::InteractiveTargetNotRunning(msg.clone())
312            }
313            Self::CommandNotAllowed(msg) => Self::CommandNotAllowed(msg.clone()),
314            Self::ThreadIdMismatch(msg) => Self::ThreadIdMismatch(msg.clone()),
315            Self::WorkspaceBindingRequired { thread_id } => {
316                Self::WorkspaceBindingRequired { thread_id: thread_id.clone() }
317            }
318            Self::WorkspaceThreadMismatch { thread_id, workspace_root } => {
319                Self::WorkspaceThreadMismatch {
320                    thread_id: thread_id.clone(),
321                    workspace_root: workspace_root.clone(),
322                }
323            }
324            Self::WorkspaceBindingMismatch { thread_id, requested_workspace, bound_workspace } => {
325                Self::WorkspaceBindingMismatch {
326                    thread_id: thread_id.clone(),
327                    requested_workspace: requested_workspace.clone(),
328                    bound_workspace: bound_workspace.clone(),
329                }
330            }
331            Self::WorkspaceChangeRequiresNewSession { workspace_root } => {
332                Self::WorkspaceChangeRequiresNewSession { workspace_root: workspace_root.clone() }
333            }
334            Self::ArgumentParseError(msg) => Self::ArgumentParseError(msg.clone()),
335            Self::FileAccessError { path, message } => {
336                Self::FileAccessError { path: path.clone(), message: message.clone() }
337            }
338            Self::TemporaryArtifactPolicy { path, temporary_artifact_dir, message } => {
339                Self::TemporaryArtifactPolicy {
340                    path: path.clone(),
341                    temporary_artifact_dir: temporary_artifact_dir.clone(),
342                    message: message.clone(),
343                }
344            }
345            Self::TemporaryArtifactBudgetExceeded {
346                temporary_artifact_dir,
347                total_bytes,
348                max_total_bytes,
349                session_bytes,
350                max_session_bytes,
351                session_files,
352                max_session_files,
353                largest_file_bytes,
354                max_file_bytes,
355            } => Self::TemporaryArtifactBudgetExceeded {
356                temporary_artifact_dir: temporary_artifact_dir.clone(),
357                total_bytes: *total_bytes,
358                max_total_bytes: *max_total_bytes,
359                session_bytes: *session_bytes,
360                max_session_bytes: *max_session_bytes,
361                session_files: *session_files,
362                max_session_files: *max_session_files,
363                largest_file_bytes: *largest_file_bytes,
364                max_file_bytes: *max_file_bytes,
365            },
366            Self::InvalidWaitPolicyForAction { wait_policy, action } => {
367                Self::InvalidWaitPolicyForAction {
368                    wait_policy: wait_policy.clone(),
369                    action: action.clone(),
370                }
371            }
372            Self::DerivedCodeMapBudget {
373                path,
374                temporary_artifact_dir,
375                calls_used,
376                calls_limit,
377                unique_files_used,
378                unique_files_limit,
379                message,
380            } => Self::DerivedCodeMapBudget {
381                path: path.clone(),
382                temporary_artifact_dir: temporary_artifact_dir.clone(),
383                calls_used: *calls_used,
384                calls_limit: *calls_limit,
385                unique_files_used: *unique_files_used,
386                unique_files_limit: *unique_files_limit,
387                message: message.clone(),
388            },
389            Self::DeserializationError(msg) => Self::DeserializationError(msg.clone()),
390            Self::SerializationError(msg) => Self::SerializationError(msg.clone()),
391            Self::SearchReplaceSyntaxError(msg) => Self::SearchReplaceSyntaxError(msg.clone()),
392            Self::SearchBlockNotFound(msg) => Self::SearchBlockNotFound(msg.clone()),
393            Self::SearchBlockAmbiguous { block_content, match_count, suggestions } => {
394                Self::SearchBlockAmbiguous {
395                    block_content: block_content.clone(),
396                    match_count: *match_count,
397                    suggestions: suggestions.clone(),
398                }
399            }
400            Self::MultiFilePlanError { index, path, source } => Self::MultiFilePlanError {
401                index: *index,
402                path: path.clone(),
403                source: Box::new((**source).clone()),
404            },
405            Self::SearchReplaceSyntaxErrorDetailed {
406                message,
407                line_number,
408                block_type,
409                suggestions,
410            } => Self::SearchReplaceSyntaxErrorDetailed {
411                message: message.clone(),
412                line_number: *line_number,
413                block_type: block_type.clone(),
414                suggestions: suggestions.clone(),
415            },
416            Self::JsonParseError(msg) => Self::JsonParseError(msg.clone()),
417            Self::FileTooLarge { path, size, max_size } => {
418                Self::FileTooLarge { path: path.clone(), size: *size, max_size: *max_size }
419            }
420            Self::FileWriteError { path, message } => {
421                Self::FileWriteError { path: path.clone(), message: message.clone() }
422            }
423            Self::DataLoadingError(msg) => Self::DataLoadingError(msg.clone()),
424            Self::ParameterValidationError { field, message } => {
425                Self::ParameterValidationError { field: field.clone(), message: message.clone() }
426            }
427            Self::MissingParameterError { field, message } => {
428                Self::MissingParameterError { field: field.clone(), message: message.clone() }
429            }
430            Self::NullValueError { field } => Self::NullValueError { field: field.clone() },
431            Self::RecoverableSuggestionError { message, suggestion } => {
432                Self::RecoverableSuggestionError {
433                    message: message.clone(),
434                    suggestion: suggestion.clone(),
435                }
436            }
437            Self::ContextSaveError(msg) => Self::ContextSaveError(msg.clone()),
438            Self::CommandTimeout { command, timeout_seconds } => {
439                Self::CommandTimeout { command: command.clone(), timeout_seconds: *timeout_seconds }
440            }
441            Self::InteractiveCommandDetected { command } => {
442                Self::InteractiveCommandDetected { command: command.clone() }
443            }
444            Self::CommandAlreadyRunning { current_command, duration_seconds } => {
445                Self::CommandAlreadyRunning {
446                    current_command: current_command.clone(),
447                    duration_seconds: *duration_seconds,
448                }
449            }
450            Self::ProcessCleanupError { message } => {
451                Self::ProcessCleanupError { message: message.clone() }
452            }
453            Self::BufferOverflow { size, max_size } => {
454                Self::BufferOverflow { size: *size, max_size: *max_size }
455            }
456            Self::SessionRecoveryError { message } => {
457                Self::SessionRecoveryError { message: message.clone() }
458            }
459            Self::ResourceAllocationError { message } => {
460                Self::ResourceAllocationError { message: message.clone() }
461            }
462            Self::IoError(err) => Self::IoError(std::io::Error::new(err.kind(), err.to_string())),
463            Self::ConfigurationError(msg) => Self::ConfigurationError(msg.clone()),
464            Self::ParseError(msg) => Self::ParseError(msg.clone()),
465            Self::InvalidInput(msg) => Self::InvalidInput(msg.clone()),
466            Self::FileError(msg) => Self::FileError(msg.clone()),
467            Self::PathSecurityError { path, message } => {
468                Self::PathSecurityError { path: path.clone(), message: message.clone() }
469            }
470        }
471    }
472}