Skip to main content

task_graph_mcp/
error.rs

1//! Structured error and warning types for tool responses.
2
3use serde::Serialize;
4use std::fmt;
5
6/// Error codes for programmatic error handling.
7#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
8#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
9pub enum ErrorCode {
10    // Validation errors (4xx-like)
11    MissingRequiredField,
12    InvalidFieldValue,
13    InvalidState,
14    InvalidPath,
15    InvalidPrefix,
16
17    // Not found errors
18    AgentNotFound,
19    TaskNotFound,
20    FileNotFound,
21    AttachmentNotFound,
22
23    // Conflict errors
24    AlreadyClaimed,
25    AlreadyExists,
26    LockConflict,
27    DependencyCycle,
28    TagMismatch,
29    NotOwner,
30    DependencyNotSatisfied,
31    GatesNotSatisfied,
32
33    // Internal errors
34    DatabaseError,
35    InternalError,
36    UnknownTool,
37}
38
39/// Warning codes for non-fatal issues.
40#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
41#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
42pub enum WarningCode {
43    /// Referenced task does not exist (link skipped)
44    TaskNotFound,
45    /// Referenced dependency does not exist (link skipped)
46    DependencyNotFound,
47    /// Tag is not in the known tags list
48    UnknownTag,
49    /// Phase is not in the known phases list
50    UnknownPhase,
51    /// Duplicate operation (no-op)
52    Duplicate,
53    /// Deprecated feature or parameter
54    Deprecated,
55}
56
57/// A warning about a non-fatal issue in a tool operation.
58#[derive(Debug, Clone, Serialize)]
59pub struct ToolWarning {
60    pub code: WarningCode,
61    pub message: String,
62    #[serde(skip_serializing_if = "Option::is_none")]
63    pub field: Option<String>,
64    #[serde(skip_serializing_if = "Option::is_none")]
65    pub value: Option<String>,
66}
67
68impl ToolWarning {
69    pub fn new(code: WarningCode, message: impl Into<String>) -> Self {
70        Self {
71            code,
72            message: message.into(),
73            field: None,
74            value: None,
75        }
76    }
77
78    pub fn with_field(mut self, field: impl Into<String>) -> Self {
79        self.field = Some(field.into());
80        self
81    }
82
83    pub fn with_value(mut self, value: impl Into<String>) -> Self {
84        self.value = Some(value.into());
85        self
86    }
87
88    // Convenience constructors
89
90    pub fn task_not_found(task_id: &str) -> Self {
91        Self::new(
92            WarningCode::TaskNotFound,
93            format!("Task '{}' not found, skipped", task_id),
94        )
95        .with_value(task_id)
96    }
97
98    pub fn dependency_not_found(task_id: &str, field: &str) -> Self {
99        Self::new(
100            WarningCode::DependencyNotFound,
101            format!("Dependency target '{}' not found, link skipped", task_id),
102        )
103        .with_field(field)
104        .with_value(task_id)
105    }
106
107    pub fn unknown_tag(tag: &str) -> Self {
108        Self::new(
109            WarningCode::UnknownTag,
110            format!("Tag '{}' is not in known tags list", tag),
111        )
112        .with_value(tag)
113    }
114
115    pub fn unknown_phase(phase: &str) -> Self {
116        Self::new(
117            WarningCode::UnknownPhase,
118            format!("Phase '{}' is not in known phases list", phase),
119        )
120        .with_value(phase)
121    }
122
123    pub fn duplicate(what: &str) -> Self {
124        Self::new(WarningCode::Duplicate, format!("{} already exists", what))
125    }
126
127    pub fn deprecated(feature: &str, alternative: &str) -> Self {
128        Self::new(
129            WarningCode::Deprecated,
130            format!("'{}' is deprecated, use '{}' instead", feature, alternative),
131        )
132    }
133}
134
135impl fmt::Display for ToolWarning {
136    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
137        write!(f, "{}", self.message)
138    }
139}
140
141/// Structured error for tool responses.
142#[derive(Debug, Serialize)]
143pub struct ToolError {
144    pub code: ErrorCode,
145    pub message: String,
146    #[serde(skip_serializing_if = "Option::is_none")]
147    pub field: Option<String>,
148    #[serde(skip_serializing_if = "Option::is_none")]
149    pub details: Option<String>,
150    #[serde(skip_serializing_if = "Option::is_none")]
151    pub blocked_by: Option<Vec<String>>,
152    #[serde(skip_serializing_if = "Option::is_none")]
153    pub suggestion: Option<String>,
154}
155
156impl ToolError {
157    pub fn new(code: ErrorCode, message: impl Into<String>) -> Self {
158        Self {
159            code,
160            message: message.into(),
161            field: None,
162            details: None,
163            blocked_by: None,
164            suggestion: None,
165        }
166    }
167
168    pub fn with_field(mut self, field: impl Into<String>) -> Self {
169        self.field = Some(field.into());
170        self
171    }
172
173    pub fn with_details(mut self, details: impl Into<String>) -> Self {
174        self.details = Some(details.into());
175        self
176    }
177
178    pub fn with_blocked_by(mut self, blocked_by: Vec<String>) -> Self {
179        self.blocked_by = Some(blocked_by);
180        self
181    }
182
183    pub fn with_suggestion(mut self, suggestion: impl Into<String>) -> Self {
184        self.suggestion = Some(suggestion.into());
185        self
186    }
187
188    // Convenience constructors
189
190    pub fn missing_field(field: &str) -> Self {
191        Self::new(
192            ErrorCode::MissingRequiredField,
193            format!("{} is required", field),
194        )
195        .with_field(field)
196    }
197
198    pub fn invalid_value(field: &str, reason: &str) -> Self {
199        Self::new(ErrorCode::InvalidFieldValue, reason).with_field(field)
200    }
201
202    pub fn agent_not_found(agent_id: &str) -> Self {
203        Self::new(
204            ErrorCode::AgentNotFound,
205            format!("Agent not found: {}", agent_id),
206        )
207    }
208
209    pub fn task_not_found(task_id: &str) -> Self {
210        Self::new(
211            ErrorCode::TaskNotFound,
212            format!("Task not found: {}", task_id),
213        )
214    }
215
216    pub fn lock_conflict(resource: &str, held_by: &str) -> Self {
217        Self::new(
218            ErrorCode::LockConflict,
219            format!(
220                "Lock '{}' is exclusively held by agent '{}'",
221                resource, held_by
222            ),
223        )
224        .with_field("file")
225        .with_details(format!("held_by: {}", held_by))
226        .with_suggestion(
227            "Wait for the lock to be released, or coordinate with the holding agent".to_string(),
228        )
229    }
230
231    pub fn already_claimed(task_id: &str, owner: &str) -> Self {
232        Self::new(
233            ErrorCode::AlreadyClaimed,
234            format!("Task {} already claimed by {}", task_id, owner),
235        )
236    }
237
238    pub fn not_owner(task_id: &str, agent_id: &str) -> Self {
239        Self::new(
240            ErrorCode::NotOwner,
241            format!("Agent {} does not own task {}", agent_id, task_id),
242        )
243    }
244
245    pub fn dependency_cycle(blocker: &str, blocked: &str) -> Self {
246        Self::new(
247            ErrorCode::DependencyCycle,
248            format!(
249                "Adding dependency {} -> {} would create a cycle",
250                blocker, blocked
251            ),
252        )
253    }
254
255    pub fn tag_mismatch(missing: &str) -> Self {
256        Self::new(
257            ErrorCode::TagMismatch,
258            format!("Agent missing required tag(s): {}", missing),
259        )
260    }
261
262    pub fn deps_not_satisfied(blockers: &[String]) -> Self {
263        Self::new(
264            ErrorCode::DependencyNotSatisfied,
265            format!(
266                "Task blocked by unsatisfied dependencies: {}",
267                blockers.join(", ")
268            ),
269        )
270        .with_blocked_by(blockers.to_vec())
271        .with_suggestion(
272            "Wait for blocking tasks to complete. Meanwhile: (1) call list_tasks(ready=true) to find unblocked work, (2) use scan(task=<id>, direction=\"before\") to inspect the dependency chain, (3) call thinking() regularly to maintain heartbeat while waiting."
273                .to_string(),
274        )
275    }
276
277    pub fn gates_not_satisfied(status: &str, gates: &[String]) -> Self {
278        let gate_list = gates.join(", ");
279        let how_to_fix: Vec<String> = gates
280            .iter()
281            .map(|g| {
282                // Extract the gate type from "gate_type (description)" format
283                let gate_type = g.split(" (").next().unwrap_or(g);
284                format!(
285                    "  - Satisfy '{}': attach(task=<id>, type=\"{}\", content=\"...\")",
286                    gate_type, gate_type
287                )
288            })
289            .collect();
290        Self::new(
291            ErrorCode::GatesNotSatisfied,
292            format!(
293                "Cannot exit '{}': unsatisfied gates: {}",
294                status, gate_list
295            ),
296        )
297        .with_details(format!(
298            "How to satisfy:\n{}\n\nOr use force=true with a reason to skip warn-level gates.",
299            how_to_fix.join("\n")
300        ))
301        .with_suggestion(
302            "Attach the required artifacts, then retry the transition. For warn-level gates, you can use update(..., force=true, reason=\"...\") to proceed.".to_string(),
303        )
304    }
305
306    pub fn invalid_path(path: &str, reason: &str) -> Self {
307        Self::new(
308            ErrorCode::InvalidPath,
309            format!("Invalid path '{}': {}", path, reason),
310        )
311    }
312
313    pub fn prefix_not_lowercase(prefix: &str) -> Self {
314        Self::new(
315            ErrorCode::InvalidPrefix,
316            format!("Path prefix '{}' must be lowercase", prefix),
317        )
318    }
319
320    pub fn unknown_prefix(prefix: &str) -> Self {
321        Self::new(
322            ErrorCode::InvalidPrefix,
323            format!("Unknown path prefix: {}", prefix),
324        )
325    }
326
327    pub fn sandbox_escape(path: &str, root: &str) -> Self {
328        Self::new(
329            ErrorCode::InvalidPath,
330            format!("Path '{}' escapes sandbox root '{}'", path, root),
331        )
332    }
333
334    pub fn database(err: impl fmt::Display) -> Self {
335        Self::new(ErrorCode::DatabaseError, err.to_string())
336    }
337
338    pub fn internal(err: impl fmt::Display) -> Self {
339        Self::new(ErrorCode::InternalError, err.to_string())
340    }
341
342    pub fn unknown_tool(name: &str) -> Self {
343        Self::new(ErrorCode::UnknownTool, format!("Unknown tool: {}", name))
344    }
345}
346
347impl fmt::Display for ToolError {
348    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
349        write!(f, "{}", self.message)
350    }
351}
352
353impl std::error::Error for ToolError {}
354
355// Allow using ? with anyhow errors by converting them
356impl From<anyhow::Error> for ToolError {
357    fn from(err: anyhow::Error) -> Self {
358        // Try to downcast to ToolError first
359        match err.downcast::<ToolError>() {
360            Ok(tool_err) => tool_err,
361            Err(err) => ToolError::internal(err),
362        }
363    }
364}
365
366/// Result type for tool operations.
367pub type ToolResult<T> = std::result::Result<T, ToolError>;