Skip to main content

winx_code_agent/
errors.rs

1use std::path::PathBuf;
2use thiserror::Error;
3
4/// Machine-readable reason an edit must be preceded by another `ReadFiles` call.
5#[derive(Clone, Copy, Debug, Eq, PartialEq)]
6pub enum ReadRequirement {
7    NeverRead,
8    Stale,
9    InsufficientCoverage,
10}
11
12/// Errors that can occur in the Winx application
13#[derive(Error, Debug)]
14pub enum WinxError {
15    /// Error when initializing the shell
16    #[error("Failed to initialize shell: {0}")]
17    ShellInitializationError(String),
18
19    /// Error when operating on a workspace path
20    #[error("Workspace path error: {0}")]
21    WorkspacePathError(String),
22
23    /// Error when locking the bash state
24    #[error("Failed to lock the bash state: {0}")]
25    BashStateLockError(String),
26
27    /// Error when the bash state is not initialized
28    #[error("Bash state not initialized. Please call Initialize first with type=\"first_call\" and a valid workspace path.")]
29    BashStateNotInitialized,
30
31    /// Error when a command fails for an internal execution reason.
32    #[error("Command execution failed: {0}")]
33    CommandExecutionError(String),
34
35    /// A status/input action targeted a shell with no running command.
36    #[error("{0}")]
37    NoActiveCommand(String),
38
39    /// A background command identifier is unknown or has already exited.
40    #[error("{0}")]
41    BackgroundSessionNotFound(String),
42
43    /// An interactive input action was supplied without any payload.
44    #[error("Failure: {action} cannot be empty")]
45    EmptyInteractiveInput { action: String },
46
47    /// Interactive bytes were sent to an idle shell instead of a running program.
48    #[error("{0}")]
49    InteractiveTargetNotRunning(String),
50
51    /// Error when parsing arguments
52    #[error("Failed to parse arguments: {0}")]
53    ArgumentParseError(String),
54
55    /// Error when trying to access a file or directory
56    #[error("File access error for {path}: {message}")]
57    FileAccessError { path: PathBuf, message: String },
58
59    /// A requested path does not exist.
60    #[error("File not found: {path}")]
61    FileNotFound { path: PathBuf },
62
63    /// The edit guard requires a fresh or broader read before mutation.
64    #[error("Fresh read required for {path}: {message}")]
65    FileReadRequired {
66        path: PathBuf,
67        reason: ReadRequirement,
68        ranges: Vec<String>,
69        message: String,
70    },
71
72    /// An undo/edit postcondition no longer matches the on-disk file.
73    #[error("File changed after the last Winx edit: {path}: {message}")]
74    FileChangedAfterEdit { path: PathBuf, message: String },
75
76    /// No in-session undo checkpoint exists for the requested file.
77    #[error("No undo checkpoint exists for {path}")]
78    UndoCheckpointNotFound { path: PathBuf },
79
80    /// A file operation was rejected by an explicit policy or OS permission.
81    #[error("File operation denied for {path}: {message}")]
82    FileOperationDenied { path: PathBuf, message: String },
83
84    /// A live file changed repeatedly while Winx was taking a bounded snapshot.
85    #[error("File changed during read after {attempts} attempts: {path}")]
86    ConcurrentFileModification { path: PathBuf, attempts: usize },
87
88    /// A compare-and-swap edit targeted a different file revision.
89    #[error("File revision mismatch for {path}: expected {expected}, current {actual}")]
90    FileRevisionMismatch { path: PathBuf, expected: String, actual: String },
91
92    /// Security error - path traversal or symlink escape attempt
93    #[error("Security violation: {message}")]
94    PathSecurityError { path: PathBuf, message: String },
95
96    /// A model-owned helper violated the managed `.winx/tmp/<session>` contract.
97    #[error("Temporary artifact policy rejected {path}: {message}")]
98    TemporaryArtifactPolicy { path: PathBuf, temporary_artifact_dir: PathBuf, message: String },
99
100    /// Bash created dynamic helpers that could not be projected safely before
101    /// execution and the active managed session is now over budget.
102    #[error(
103        "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."
104    )]
105    TemporaryArtifactBudgetExceeded {
106        temporary_artifact_dir: PathBuf,
107        total_bytes: u64,
108        max_total_bytes: u64,
109        session_bytes: u64,
110        max_session_bytes: u64,
111        session_files: usize,
112        max_session_files: usize,
113        largest_file_bytes: u64,
114        max_file_bytes: u64,
115    },
116
117    /// A valid delivery policy was paired with an action it cannot represent.
118    #[error(
119        "wait_policy={wait_policy} is not valid for BashCommand action {action}; use until_complete only with a finite foreground command"
120    )]
121    InvalidWaitPolicyForAction { wait_policy: String, action: String },
122
123    /// Syntax navigation over derived helpers exceeded its bounded session budget.
124    #[error("Derived CodeMap budget rejected {path}: {message}")]
125    DerivedCodeMapBudget {
126        path: PathBuf,
127        temporary_artifact_dir: PathBuf,
128        calls_used: usize,
129        calls_limit: usize,
130        unique_files_used: usize,
131        unique_files_limit: usize,
132        message: String,
133    },
134
135    /// Error when a command is not allowed in the current mode
136    #[error("Command not allowed: {0}")]
137    CommandNotAllowed(String),
138
139    /// Error when chat IDs don't match
140    #[error("Thread ID mismatch: {0}")]
141    ThreadIdMismatch(String),
142
143    /// A remote stateful call omitted the explicit workspace half of its
144    /// session binding.
145    #[error(
146        "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."
147    )]
148    WorkspaceBindingRequired { thread_id: String },
149
150    /// The supplied workspace and thread cannot belong to the same affinity
151    /// key, so executing the request could select another project's shell.
152    #[error(
153        "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."
154    )]
155    WorkspaceThreadMismatch { thread_id: String, workspace_root: PathBuf },
156
157    /// The requested binding disagrees with the workspace stored in the
158    /// selected durable session.
159    #[error(
160        "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."
161    )]
162    WorkspaceBindingMismatch {
163        thread_id: String,
164        requested_workspace: PathBuf,
165        bound_workspace: PathBuf,
166    },
167
168    /// Remote sessions never change project identity in place. A new first
169    /// call gives the target workspace its own coherent session binding.
170    #[error(
171        "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."
172    )]
173    WorkspaceChangeRequiresNewSession { workspace_root: PathBuf },
174
175    /// Error when deserializing data
176    #[error("Deserialization error: {0}")]
177    DeserializationError(String),
178
179    /// Error when serializing data
180    #[error("Serialization error: {0}")]
181    SerializationError(String),
182
183    /// Error in the search/replace format
184    #[error("Search/replace syntax error: {0}")]
185    SearchReplaceSyntaxError(String),
186
187    /// Error when search block is not found in content
188    #[error("Search block not found in content: {0}")]
189    SearchBlockNotFound(String),
190
191    /// Error when search block matches multiple locations (WCGW-style)
192    #[error("Search block matched multiple times")]
193    SearchBlockAmbiguous { block_content: String, match_count: usize, suggestions: Vec<String> },
194
195    /// Path-aware context for a planning failure in an atomic multi-file edit.
196    /// The boxed source preserves the original recovery class instead of
197    /// flattening it into an argument/string error.
198    #[error(
199        "MultiFileEdit aborted before writing anything - file {index} ({path}) failed validation: {source}"
200    )]
201    MultiFilePlanError { index: usize, path: PathBuf, source: Box<WinxError> },
202
203    /// Enhanced search/replace syntax error with detailed context
204    #[error("Search/replace syntax error: {message}")]
205    SearchReplaceSyntaxErrorDetailed {
206        message: String,
207        line_number: Option<usize>,
208        block_type: Option<String>,
209        suggestions: Vec<String>,
210    },
211
212    /// Error when JSON parsing fails
213    #[error("Invalid JSON: {0}")]
214    JsonParseError(String),
215
216    /// Error when a file is too large for operation
217    #[error("File {path} is too large: {size} bytes (max {max_size})")]
218    FileTooLarge { path: PathBuf, size: u64, max_size: u64 },
219
220    /// Error when writing to a file
221    #[error("Failed to write file {path}: {message}")]
222    FileWriteError { path: PathBuf, message: String },
223
224    /// Error loading data
225    #[error("Failed to load data: {0}")]
226    DataLoadingError(String),
227
228    /// Parameter validation error
229    #[error("Invalid parameter: {field} - {message}")]
230    ParameterValidationError { field: String, message: String },
231
232    /// Required parameter missing error
233    #[error("Required parameter missing: {field} - {message}")]
234    MissingParameterError { field: String, message: String },
235
236    /// Null or undefined value error
237    #[error("Null or undefined value where object expected: {field}")]
238    NullValueError { field: String },
239
240    /// Recovery suggestion error with potential solutions
241    #[error("{message} - {suggestion}")]
242    RecoverableSuggestionError { message: String, suggestion: String },
243
244    /// Context save error
245    #[error("Context save error: {0}")]
246    ContextSaveError(String),
247
248    /// Command timeout error
249    #[error("Command timed out after {timeout_seconds}s: {command}")]
250    CommandTimeout { command: String, timeout_seconds: u64 },
251
252    /// Interactive command detected error
253    #[error(
254        "Interactive command detected: {command}. Use appropriate flags or consider alternatives."
255    )]
256    InteractiveCommandDetected { command: String },
257
258    /// Command already running error
259    #[error("A command is already running: '{current_command}' (for {duration_seconds:.1}s). Use status_check, send_text, or interrupt.")]
260    CommandAlreadyRunning { current_command: String, duration_seconds: f64 },
261
262    /// Process cleanup error
263    #[error("Failed to cleanup process: {message}")]
264    ProcessCleanupError { message: String },
265
266    /// Buffer overflow error
267    #[error("Command output exceeded maximum size: {size} bytes (max {max_size})")]
268    BufferOverflow { size: usize, max_size: usize },
269
270    /// Session recovery error
271    #[error("Failed to recover bash session: {message}")]
272    SessionRecoveryError { message: String },
273
274    /// Resource allocation error
275    #[error("Resource allocation failed: {message}")]
276    ResourceAllocationError { message: String },
277
278    /// IO error
279    #[error("IO error: {0}")]
280    IoError(#[from] std::io::Error),
281
282    /// Configuration error
283    #[error("Configuration error: {0}")]
284    ConfigurationError(String),
285
286    /// Parse error for responses
287    #[error("Parse error: {0}")]
288    ParseError(String),
289
290    /// Invalid input error
291    #[error("Invalid input: {0}")]
292    InvalidInput(String),
293
294    /// File error for file operations
295    #[error("File error: {0}")]
296    FileError(String),
297}
298
299/// Type alias for Result with `WinxError`
300pub type Result<T> = std::result::Result<T, WinxError>;
301
302/// Conversion from `anyhow::Error` to `WinxError`
303impl From<anyhow::Error> for WinxError {
304    fn from(error: anyhow::Error) -> Self {
305        // `{:#}` renders the full anyhow context chain inline ("failed X: caused
306        // by Y: root Z") instead of just the outermost message, so a PTY-spawn or
307        // persistence failure keeps its real cause instead of a bare one-liner.
308        WinxError::CommandExecutionError(format!("{error:#}"))
309    }
310}
311
312impl WinxError {
313    /// A SEARCH conflict means the caller's exact-text evidence was insufficient.
314    /// File tools use this classification to invalidate the prior read permit and
315    /// force a visible `ReadFiles` refresh before another edit attempt.
316    pub(crate) fn is_search_match_conflict(&self) -> bool {
317        match self {
318            Self::SearchBlockNotFound(_) | Self::SearchBlockAmbiguous { .. } => true,
319            Self::MultiFilePlanError { source, .. } => source.is_search_match_conflict(),
320            _ => false,
321        }
322    }
323}
324
325/// Advanced error recovery and suggestion options
326pub struct ErrorRecovery;
327
328impl ErrorRecovery {
329    pub fn suggest(error: WinxError, _suggestion: &str) -> WinxError {
330        error
331    }
332
333    pub fn param_error(field: &str, message: &str) -> WinxError {
334        WinxError::ParameterValidationError {
335            field: field.to_string(),
336            message: message.to_string(),
337        }
338    }
339
340    pub fn missing_param(field: &str, message: &str) -> WinxError {
341        WinxError::MissingParameterError { field: field.to_string(), message: message.to_string() }
342    }
343
344    pub fn null_value(field: &str) -> WinxError {
345        WinxError::NullValueError { field: field.to_string() }
346    }
347}