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    /// An opaque in-memory undo checkpoint was evicted or lost on restart.
81    #[error(
82        "Undo checkpoint {undo_id} is unavailable for {path}; it expired or the server restarted"
83    )]
84    UndoExpired { path: PathBuf, undo_id: String },
85
86    /// A receipt-bound undo may only consume the newest checkpoint for a file.
87    #[error(
88        "Undo checkpoint {undo_id} is not the latest checkpoint for {path}; use {latest_undo_id}"
89    )]
90    UndoOutOfOrder { path: PathBuf, undo_id: String, latest_undo_id: String },
91
92    /// A file operation was rejected by an explicit policy or OS permission.
93    #[error("File operation denied for {path}: {message}")]
94    FileOperationDenied { path: PathBuf, message: String },
95
96    /// A live file changed repeatedly while Winx was taking a bounded snapshot.
97    #[error("File changed during read after {attempts} attempts: {path}")]
98    ConcurrentFileModification { path: PathBuf, attempts: usize },
99
100    /// A compare-and-swap edit targeted a different file revision.
101    #[error("File revision mismatch for {path}: expected {expected}, current {actual}")]
102    FileRevisionMismatch { path: PathBuf, expected: String, actual: String },
103
104    /// Security error - path traversal or symlink escape attempt
105    #[error("Security violation: {message}")]
106    PathSecurityError { path: PathBuf, message: String },
107
108    /// A model-owned helper violated the managed `.winx/tmp/<session>` contract.
109    #[error("Temporary artifact policy rejected {path}: {message}")]
110    TemporaryArtifactPolicy { path: PathBuf, temporary_artifact_dir: PathBuf, message: String },
111
112    /// Bash created dynamic helpers that could not be projected safely before
113    /// execution and the active managed session is now over budget.
114    #[error(
115        "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."
116    )]
117    TemporaryArtifactBudgetExceeded {
118        temporary_artifact_dir: PathBuf,
119        total_bytes: u64,
120        max_total_bytes: u64,
121        session_bytes: u64,
122        max_session_bytes: u64,
123        session_files: usize,
124        max_session_files: usize,
125        largest_file_bytes: u64,
126        max_file_bytes: u64,
127    },
128
129    /// A valid delivery policy was paired with an action it cannot represent.
130    #[error(
131        "wait_policy={wait_policy} is not valid for BashCommand action {action}; use until_complete only with a finite foreground command"
132    )]
133    InvalidWaitPolicyForAction { wait_policy: String, action: String },
134
135    /// Syntax navigation over derived helpers exceeded its bounded session budget.
136    #[error("Derived CodeMap budget rejected {path}: {message}")]
137    DerivedCodeMapBudget {
138        path: PathBuf,
139        temporary_artifact_dir: PathBuf,
140        calls_used: usize,
141        calls_limit: usize,
142        unique_files_used: usize,
143        unique_files_limit: usize,
144        message: String,
145    },
146
147    /// Error when a command is not allowed in the current mode
148    #[error("Command not allowed: {0}")]
149    CommandNotAllowed(String),
150
151    /// Error when chat IDs don't match
152    #[error("Thread ID mismatch: {0}")]
153    ThreadIdMismatch(String),
154
155    /// A remote stateful call omitted the explicit workspace half of its
156    /// session binding.
157    #[error(
158        "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."
159    )]
160    WorkspaceBindingRequired { thread_id: String },
161
162    /// The supplied workspace and thread cannot belong to the same affinity
163    /// key, so executing the request could select another project's shell.
164    #[error(
165        "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."
166    )]
167    WorkspaceThreadMismatch { thread_id: String, workspace_root: PathBuf },
168
169    /// The requested binding disagrees with the workspace stored in the
170    /// selected durable session.
171    #[error(
172        "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."
173    )]
174    WorkspaceBindingMismatch {
175        thread_id: String,
176        requested_workspace: PathBuf,
177        bound_workspace: PathBuf,
178    },
179
180    /// Remote sessions never change project identity in place. A new first
181    /// call gives the target workspace its own coherent session binding.
182    #[error(
183        "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."
184    )]
185    WorkspaceChangeRequiresNewSession { workspace_root: PathBuf },
186
187    /// Error when deserializing data
188    #[error("Deserialization error: {0}")]
189    DeserializationError(String),
190
191    /// Error when serializing data
192    #[error("Serialization error: {0}")]
193    SerializationError(String),
194
195    /// Error in the search/replace format
196    #[error("Search/replace syntax error: {0}")]
197    SearchReplaceSyntaxError(String),
198
199    /// Error when search block is not found in content
200    #[error("Search block not found in content: {0}")]
201    SearchBlockNotFound(String),
202
203    /// Error when search block matches multiple locations (WCGW-style)
204    #[error("Search block matched multiple times")]
205    SearchBlockAmbiguous { block_content: String, match_count: usize, suggestions: Vec<String> },
206
207    /// Path-aware context for a planning failure in an atomic multi-file edit.
208    /// The boxed source preserves the original recovery class instead of
209    /// flattening it into an argument/string error.
210    #[error(
211        "MultiFileEdit aborted before writing anything - file {index} ({path}) failed validation: {source}"
212    )]
213    MultiFilePlanError { index: usize, path: PathBuf, source: Box<WinxError> },
214
215    /// Path context for a single-file edit without changing its historical
216    /// display text. Recovery consumes the typed path instead of re-parsing the
217    /// public request shape.
218    #[error("{source}")]
219    EditContextError { path: PathBuf, source: Box<WinxError> },
220
221    /// Neutral path/index context for the unified batch planner. Legacy
222    /// `MultiFileEdit` retains its historical error wording separately.
223    #[error(
224        "EditFiles aborted before writing anything - file {index} ({path}, mode={mode}) failed validation: {source}"
225    )]
226    IndexedEditError { index: usize, path: PathBuf, mode: String, source: Box<WinxError> },
227
228    /// Enhanced search/replace syntax error with detailed context
229    #[error("Search/replace syntax error: {message}")]
230    SearchReplaceSyntaxErrorDetailed {
231        message: String,
232        line_number: Option<usize>,
233        block_type: Option<String>,
234        suggestions: Vec<String>,
235    },
236
237    /// Error when JSON parsing fails
238    #[error("Invalid JSON: {0}")]
239    JsonParseError(String),
240
241    /// Error when a file is too large for operation
242    #[error("File {path} is too large: {size} bytes (max {max_size})")]
243    FileTooLarge { path: PathBuf, size: u64, max_size: u64 },
244
245    /// Error when writing to a file
246    #[error("Failed to write file {path}: {message}")]
247    FileWriteError { path: PathBuf, message: String },
248
249    /// Error loading data
250    #[error("Failed to load data: {0}")]
251    DataLoadingError(String),
252
253    /// Parameter validation error
254    #[error("Invalid parameter: {field} - {message}")]
255    ParameterValidationError { field: String, message: String },
256
257    /// Required parameter missing error
258    #[error("Required parameter missing: {field} - {message}")]
259    MissingParameterError { field: String, message: String },
260
261    /// Null or undefined value error
262    #[error("Null or undefined value where object expected: {field}")]
263    NullValueError { field: String },
264
265    /// Recovery suggestion error with potential solutions
266    #[error("{message} - {suggestion}")]
267    RecoverableSuggestionError { message: String, suggestion: String },
268
269    /// Context save error
270    #[error("Context save error: {0}")]
271    ContextSaveError(String),
272
273    /// Command timeout error
274    #[error("Command timed out after {timeout_seconds}s: {command}")]
275    CommandTimeout { command: String, timeout_seconds: u64 },
276
277    /// Interactive command detected error
278    #[error(
279        "Interactive command detected: {command}. Use appropriate flags or consider alternatives."
280    )]
281    InteractiveCommandDetected { command: String },
282
283    /// Command already running error
284    #[error("A command is already running: '{current_command}' (for {duration_seconds:.1}s). Use status_check, send_text, or interrupt.")]
285    CommandAlreadyRunning { current_command: String, duration_seconds: f64 },
286
287    /// Process cleanup error
288    #[error("Failed to cleanup process: {message}")]
289    ProcessCleanupError { message: String },
290
291    /// Buffer overflow error
292    #[error("Command output exceeded maximum size: {size} bytes (max {max_size})")]
293    BufferOverflow { size: usize, max_size: usize },
294
295    /// Session recovery error
296    #[error("Failed to recover bash session: {message}")]
297    SessionRecoveryError { message: String },
298
299    /// Resource allocation error
300    #[error("Resource allocation failed: {message}")]
301    ResourceAllocationError { message: String },
302
303    /// IO error
304    #[error("IO error: {0}")]
305    IoError(#[from] std::io::Error),
306
307    /// Configuration error
308    #[error("Configuration error: {0}")]
309    ConfigurationError(String),
310
311    /// Parse error for responses
312    #[error("Parse error: {0}")]
313    ParseError(String),
314
315    /// Invalid input error
316    #[error("Invalid input: {0}")]
317    InvalidInput(String),
318
319    /// File error for file operations
320    #[error("File error: {0}")]
321    FileError(String),
322}
323
324/// Type alias for Result with `WinxError`
325pub type Result<T> = std::result::Result<T, WinxError>;
326
327/// Conversion from `anyhow::Error` to `WinxError`
328impl From<anyhow::Error> for WinxError {
329    fn from(error: anyhow::Error) -> Self {
330        // `{:#}` renders the full anyhow context chain inline ("failed X: caused
331        // by Y: root Z") instead of just the outermost message, so a PTY-spawn or
332        // persistence failure keeps its real cause instead of a bare one-liner.
333        WinxError::CommandExecutionError(format!("{error:#}"))
334    }
335}
336
337impl WinxError {
338    /// Whether a failed `BashCommand` is evidence that replacing the PTY may be
339    /// useful. Expected command-domain outcomes (nonzero exits are successful
340    /// runtime results), policy denials, invalid actions, and idle status/input
341    /// calls deliberately do not qualify.
342    pub(crate) fn is_shell_runtime_failure(&self) -> bool {
343        matches!(
344            self,
345            Self::ShellInitializationError(_)
346                | Self::BashStateLockError(_)
347                | Self::BashStateNotInitialized
348                | Self::CommandExecutionError(_)
349                | Self::CommandTimeout { .. }
350                | Self::ProcessCleanupError { .. }
351                | Self::SessionRecoveryError { .. }
352        )
353    }
354
355    /// A SEARCH conflict means the caller's exact-text evidence was insufficient.
356    /// File tools use this classification to invalidate the prior read permit and
357    /// force a visible `ReadFiles` refresh before another edit attempt.
358    pub(crate) fn is_search_match_conflict(&self) -> bool {
359        match self {
360            Self::SearchBlockNotFound(_) | Self::SearchBlockAmbiguous { .. } => true,
361            Self::MultiFilePlanError { source, .. }
362            | Self::EditContextError { source, .. }
363            | Self::IndexedEditError { source, .. } => source.is_search_match_conflict(),
364            _ => false,
365        }
366    }
367
368    /// Innermost typed cause after removing edit-index context. Error recovery
369    /// and structured result shaping must never infer the class from display text.
370    pub(crate) fn edit_source(&self) -> &Self {
371        match self {
372            Self::MultiFilePlanError { source, .. }
373            | Self::EditContextError { source, .. }
374            | Self::IndexedEditError { source, .. } => source.edit_source(),
375            _ => self,
376        }
377    }
378}
379
380/// Advanced error recovery and suggestion options
381pub struct ErrorRecovery;
382
383impl ErrorRecovery {
384    pub fn suggest(error: WinxError, _suggestion: &str) -> WinxError {
385        error
386    }
387
388    pub fn param_error(field: &str, message: &str) -> WinxError {
389        WinxError::ParameterValidationError {
390            field: field.to_string(),
391            message: message.to_string(),
392        }
393    }
394
395    pub fn missing_param(field: &str, message: &str) -> WinxError {
396        WinxError::MissingParameterError { field: field.to_string(), message: message.to_string() }
397    }
398
399    pub fn null_value(field: &str) -> WinxError {
400        WinxError::NullValueError { field: field.to_string() }
401    }
402}