Skip to main content

task_graph_mcp/tools/
files.rs

1//! File coordination tools (advisory marks and exclusive locks).
2//!
3//! Supports two modes:
4//! - **Advisory marks** (default): warns if another agent holds a file mark.
5//! - **Exclusive locks** (`lock:` prefix): rejects with error if another agent holds the lock.
6//!
7//! The `lock:` namespace uses the same `file_locks` table but enforces mutual exclusion.
8//! Example: `mark_file(file="lock:git-commit")` acquires an exclusive lock on the
9//! resource "git-commit". Another agent attempting the same lock will receive an error.
10
11use super::{
12    IdList, get_string, get_string_or_array, get_string_or_array_or_wildcard,
13    make_tool_with_prompts,
14};
15use crate::config::Prompts;
16use crate::db::Database;
17use crate::db::locks::ExclusiveLockResult;
18use crate::error::ToolError;
19use crate::format::{OutputFormat, markdown_to_json};
20use anyhow::Result;
21use rmcp::model::Tool;
22use serde_json::{Value, json};
23use std::path::{Component, Path, PathBuf};
24
25/// The prefix that triggers exclusive lock semantics.
26const LOCK_PREFIX: &str = "lock:";
27
28/// Normalize a file path to an absolute, canonical form.
29///
30/// This function:
31/// 1. Resolves relative paths against the current working directory
32/// 2. Normalizes path components (removes `.`, resolves `..`)
33/// 3. Uses forward slashes for consistency across platforms
34/// 4. Works with non-existent files (doesn't require file to exist)
35///
36/// # Examples
37/// - `src/main.rs` -> `/project/src/main.rs`
38/// - `./src/../src/main.rs` -> `/project/src/main.rs`
39/// - `/absolute/path.rs` -> `/absolute/path.rs`
40fn normalize_file_path(path: &str) -> String {
41    let path = Path::new(path);
42
43    // Get absolute path
44    let absolute = if path.is_absolute() {
45        path.to_path_buf()
46    } else {
47        // Resolve relative to current directory
48        std::env::current_dir()
49            .unwrap_or_else(|_| PathBuf::from("."))
50            .join(path)
51    };
52
53    // Normalize the path (resolve . and ..)
54    let normalized = normalize_path_components(&absolute);
55
56    // Convert to string with forward slashes for consistency
57    path_to_forward_slashes(&normalized)
58}
59
60/// Normalize path components without requiring the file to exist.
61/// Handles `.` and `..` components.
62fn normalize_path_components(path: &Path) -> PathBuf {
63    let mut components = Vec::new();
64
65    for component in path.components() {
66        match component {
67            Component::Prefix(p) => {
68                // Windows drive prefix (e.g., C:)
69                components.push(Component::Prefix(p));
70            }
71            Component::RootDir => {
72                components.push(Component::RootDir);
73            }
74            Component::CurDir => {
75                // Skip `.` - it refers to current directory
76            }
77            Component::ParentDir => {
78                // Go up one directory if possible
79                if let Some(Component::Normal(_)) = components.last() {
80                    components.pop();
81                } else {
82                    // Can't go up from root, keep the component
83                    // (this handles edge cases like `/../foo`)
84                    components.push(Component::ParentDir);
85                }
86            }
87            Component::Normal(name) => {
88                components.push(Component::Normal(name));
89            }
90        }
91    }
92
93    components.iter().collect()
94}
95
96/// Convert path to string using forward slashes.
97fn path_to_forward_slashes(path: &Path) -> String {
98    path.to_string_lossy().replace('\\', "/")
99}
100
101/// Normalize a vector of file paths.
102fn normalize_file_paths(paths: Vec<String>) -> Vec<String> {
103    paths.into_iter().map(|p| normalize_file_path(&p)).collect()
104}
105
106/// Format milliseconds as human-readable duration (e.g., "5m 30s", "2h 15m")
107fn format_duration(ms: i64) -> String {
108    if ms < 1000 {
109        return format!("{}ms", ms);
110    }
111    let secs = ms / 1000;
112    if secs < 60 {
113        return format!("{}s", secs);
114    }
115    let mins = secs / 60;
116    if mins < 60 {
117        let rem_secs = secs % 60;
118        return if rem_secs > 0 {
119            format!("{}m {}s", mins, rem_secs)
120        } else {
121            format!("{}m", mins)
122        };
123    }
124    let hours = mins / 60;
125    let rem_mins = mins % 60;
126    if rem_mins > 0 {
127        format!("{}h {}m", hours, rem_mins)
128    } else {
129        format!("{}h", hours)
130    }
131}
132
133pub fn get_tools(prompts: &Prompts) -> Vec<Tool> {
134    vec![
135        make_tool_with_prompts(
136            "mark_file",
137            "Mark a file to signal intent to work on it (advisory, non-blocking). Returns warning if another agent has marked the file. Track changes via mark_updates.\n\nUse the `lock:` prefix for exclusive locks: `lock:resource-name` will reject (not just warn) if another agent holds the lock. Example: `mark_file(file=\"lock:git-commit\")` acquires a mutual-exclusion lock on the resource \"git-commit\".",
138            json!({
139                "agent": {
140                    "type": "string",
141                    "description": "Agent ID"
142                },
143                "file": {
144                    "oneOf": [
145                        { "type": "string" },
146                        { "type": "array", "items": { "type": "string" } }
147                    ],
148                    "description": "Relative file path, array of file paths, or lock resource(s) with 'lock:' prefix (e.g. 'lock:git-commit' for exclusive locks)"
149                },
150                "task": {
151                    "type": "string",
152                    "description": "Optional task ID to associate with the mark (for auto-cleanup when task completes)"
153                },
154                "reason": {
155                    "type": "string",
156                    "description": "Optional reason for marking (visible to other agents)"
157                }
158            }),
159            vec!["agent", "file"],
160            prompts,
161        ),
162        make_tool_with_prompts(
163            "unmark_file",
164            "Remove mark from a file. Optionally include a note for the next agent.",
165            json!({
166                "agent": {
167                    "type": "string",
168                    "description": "Agent ID"
169                },
170                "file": {
171                    "oneOf": [
172                        { "type": "string" },
173                        { "type": "array", "items": { "type": "string" } }
174                    ],
175                    "description": "Relative file path, array of paths, or '*' to unmark all files held by this agent"
176                },
177                "task": {
178                    "type": "string",
179                    "description": "Optional task ID - unmark all files associated with this task"
180                },
181                "reason": {
182                    "type": "string",
183                    "description": "Optional reason/note for next agent"
184                }
185            }),
186            vec!["agent"],
187            prompts,
188        ),
189        make_tool_with_prompts(
190            "list_marks",
191            "Get current file marks. Requires at least one filter: agent, task, or files.",
192            json!({
193                "files": {
194                    "type": "array",
195                    "items": { "type": "string" },
196                    "description": "Specific file paths to check"
197                },
198                "agent": {
199                    "type": "string",
200                    "description": "Filter by agent ID"
201                },
202                "task": {
203                    "type": "string",
204                    "description": "Filter by task ID"
205                }
206            }),
207            vec![],
208            prompts,
209        ),
210        make_tool_with_prompts(
211            "mark_updates",
212            "Poll for file mark changes since last call. Returns new marks and removals. Use for coordination between agents.",
213            json!({
214                "agent": {
215                    "type": "string",
216                    "description": "Agent ID (tracks poll position)"
217                }
218            }),
219            vec!["agent"],
220            prompts,
221        ),
222    ]
223}
224
225pub fn mark_file(db: &Database, args: Value) -> Result<Value> {
226    let worker_id = get_string(&args, "agent").ok_or_else(|| ToolError::missing_field("agent"))?;
227    let file_paths =
228        get_string_or_array(&args, "file").ok_or_else(|| ToolError::missing_field("file"))?;
229    let task_id = get_string(&args, "task");
230    let reason = get_string(&args, "reason");
231
232    // Separate lock: prefixed paths from regular file paths
233    let mut lock_paths: Vec<String> = Vec::new();
234    let mut regular_paths: Vec<String> = Vec::new();
235
236    for path in file_paths {
237        if path.starts_with(LOCK_PREFIX) {
238            // lock: namespace - store as-is (no path normalization)
239            lock_paths.push(path);
240        } else {
241            regular_paths.push(path);
242        }
243    }
244
245    // Normalize regular file paths to absolute canonical form
246    let normalized_regular = normalize_file_paths(regular_paths);
247
248    let mut results = Vec::new();
249    let mut warnings = Vec::new();
250    let mut locks_acquired = Vec::new();
251
252    // Process exclusive locks first - fail fast on conflicts
253    for lock_path in &lock_paths {
254        let result = db.lock_file_exclusive(
255            lock_path.clone(),
256            &worker_id,
257            reason.clone(),
258            task_id.clone(),
259        )?;
260
261        match result {
262            ExclusiveLockResult::HeldByOther(other_agent) => {
263                // Exclusive lock conflict - return error immediately
264                return Err(ToolError::lock_conflict(lock_path, &other_agent).into());
265            }
266            ExclusiveLockResult::Acquired => {
267                locks_acquired.push(lock_path.clone());
268            }
269            ExclusiveLockResult::AlreadyHeldBySelf => {
270                locks_acquired.push(lock_path.clone());
271            }
272        }
273    }
274
275    // Process advisory marks (existing behavior)
276    for file_path in &normalized_regular {
277        let warning = db.lock_file(
278            file_path.clone(),
279            &worker_id,
280            reason.clone(),
281            task_id.clone(),
282        )?;
283
284        if let Some(other_agent) = warning {
285            warnings.push(json!({
286                "file": file_path,
287                "marked_by": other_agent
288            }));
289        }
290        results.push(file_path.clone());
291    }
292
293    let mut response = json!({
294        "success": true,
295        "marked": results
296    });
297
298    if !locks_acquired.is_empty() {
299        response["locks_acquired"] = json!(locks_acquired);
300    }
301
302    if !warnings.is_empty() {
303        response["warnings"] = json!(warnings);
304    }
305
306    Ok(response)
307}
308
309pub fn unmark_file(db: &Database, args: Value) -> Result<Value> {
310    let worker_id = get_string(&args, "agent").ok_or_else(|| ToolError::missing_field("agent"))?;
311    let reason = get_string(&args, "reason");
312    let task_id = get_string(&args, "task");
313
314    // If task_id is provided, unmark all files for that task
315    if let Some(tid) = task_id {
316        let unmarked = db.release_task_locks_verbose(&tid, reason)?;
317        return Ok(json!({
318            "success": true,
319            "unmarked": unmarked.iter().map(|(f, w)| json!({
320                "file": f,
321                "agent": w
322            })).collect::<Vec<_>>(),
323            "count": unmarked.len()
324        }));
325    }
326
327    // Get file parameter - can be string, array, or '*'
328    let file_param = get_string_or_array_or_wildcard(&args, "file");
329
330    match file_param {
331        Some(IdList::Wildcard) => {
332            // Wildcard: unmark all files held by this agent
333            let unmarked = db.release_worker_locks_verbose(&worker_id, reason)?;
334            Ok(json!({
335                "success": true,
336                "unmarked": unmarked.iter().map(|(f, w)| json!({
337                    "file": f,
338                    "agent": w
339                })).collect::<Vec<_>>(),
340                "count": unmarked.len()
341            }))
342        }
343        Some(IdList::Ids(files)) => {
344            // Separate lock: paths (no normalization) from regular paths (normalize)
345            let mut all_paths: Vec<String> = Vec::new();
346            for f in files {
347                if f.starts_with(LOCK_PREFIX) {
348                    all_paths.push(f);
349                } else {
350                    all_paths.push(normalize_file_path(&f));
351                }
352            }
353            // Unmark each one
354            let unmarked = db.unlock_files_verbose(all_paths, &worker_id, reason)?;
355            Ok(json!({
356                "success": true,
357                "unmarked": unmarked.iter().map(|(f, w)| json!({
358                    "file": f,
359                    "agent": w
360                })).collect::<Vec<_>>(),
361                "count": unmarked.len()
362            }))
363        }
364        None => {
365            // No file specified and no task - error
366            Err(ToolError::missing_field("file or task").into())
367        }
368    }
369}
370
371pub fn list_marks(db: &Database, default_format: OutputFormat, args: Value) -> Result<Value> {
372    let files = get_string_or_array(&args, "files");
373    let worker_id = get_string(&args, "agent");
374    let task_id = get_string(&args, "task");
375    let format = get_string(&args, "format")
376        .and_then(|s| OutputFormat::parse(&s))
377        .unwrap_or(default_format);
378
379    // Require at least one filter
380    if files.is_none() && worker_id.is_none() && task_id.is_none() {
381        return Err(ToolError::invalid_value(
382            "filter",
383            "At least one filter required: agent, task, or files",
384        )
385        .into());
386    }
387
388    // Normalize file paths in the filter if provided (skip lock: prefixed paths)
389    let normalized_files = files.map(|paths| {
390        paths
391            .into_iter()
392            .map(|p| {
393                if p.starts_with(LOCK_PREFIX) {
394                    p
395                } else {
396                    normalize_file_path(&p)
397                }
398            })
399            .collect()
400    });
401
402    let marks = db.get_file_locks(normalized_files, worker_id.as_deref(), task_id.as_deref())?;
403    let now = crate::db::now_ms();
404
405    match format {
406        OutputFormat::Markdown => {
407            let mut md = String::from("# File Marks\n\n");
408            if marks.is_empty() {
409                md.push_str("No marks found.\n");
410            } else {
411                md.push_str("| File | Type | Agent | Task | Reason | Age |\n");
412                md.push_str("|------|------|-------|------|--------|-----|\n");
413                for (path, mark) in &marks {
414                    let age_ms = now - mark.locked_at;
415                    let age_str = format_duration(age_ms);
416                    let lock_type = if path.starts_with(LOCK_PREFIX) {
417                        "exclusive"
418                    } else {
419                        "advisory"
420                    };
421                    md.push_str(&format!(
422                        "| {} | {} | {} | {} | {} | {} |\n",
423                        path,
424                        lock_type,
425                        mark.worker_id,
426                        mark.task_id.as_deref().unwrap_or("-"),
427                        mark.reason.as_deref().unwrap_or("-"),
428                        age_str
429                    ));
430                }
431            }
432            Ok(markdown_to_json(md))
433        }
434        OutputFormat::Json => {
435            let marks_json: Vec<Value> = marks
436                .into_iter()
437                .map(|(path, mark)| {
438                    let is_lock = path.starts_with(LOCK_PREFIX);
439                    let age_ms = now - mark.locked_at;
440                    json!({
441                        "file": path,
442                        "is_lock": is_lock,
443                        "agent": mark.worker_id,
444                        "task_id": mark.task_id,
445                        "reason": mark.reason,
446                        "marked_at": mark.locked_at,
447                        "mark_age_ms": age_ms
448                    })
449                })
450                .collect();
451
452            Ok(json!({ "marks": marks_json }))
453        }
454    }
455}
456
457/// Async version of mark_updates.
458pub async fn mark_updates_async(db: std::sync::Arc<Database>, args: Value) -> Result<Value> {
459    let worker_id = get_string(&args, "agent").ok_or_else(|| ToolError::missing_field("agent"))?;
460
461    // Run on blocking thread pool since db operations are synchronous
462    let updates = tokio::task::spawn_blocking(move || db.claim_updates(&worker_id))
463        .await
464        .map_err(|e| anyhow::anyhow!("Task join error: {}", e))??;
465
466    Ok(json!({
467        "new_marks": updates.new_claims.iter().map(|e| json!({
468            "file": e.file_path,
469            "agent": e.worker_id,
470            "reason": e.reason,
471            "marked_at": e.timestamp
472        })).collect::<Vec<_>>(),
473        "removed_marks": updates.dropped_claims.iter().map(|e| json!({
474            "file": e.file_path,
475            "agent": e.worker_id,
476            "reason": e.reason,
477            "removed_at": e.timestamp
478        })).collect::<Vec<_>>(),
479        "sequence": updates.sequence
480    }))
481}
482
483/// Synchronous version of mark_updates.
484pub fn mark_updates(db: &Database, args: Value) -> Result<Value> {
485    let worker_id = get_string(&args, "agent").ok_or_else(|| ToolError::missing_field("agent"))?;
486
487    let updates = db.claim_updates(&worker_id)?;
488
489    Ok(json!({
490        "new_marks": updates.new_claims.iter().map(|e| json!({
491            "file": e.file_path,
492            "agent": e.worker_id,
493            "reason": e.reason,
494            "marked_at": e.timestamp
495        })).collect::<Vec<_>>(),
496        "removed_marks": updates.dropped_claims.iter().map(|e| json!({
497            "file": e.file_path,
498            "agent": e.worker_id,
499            "reason": e.reason,
500            "removed_at": e.timestamp
501        })).collect::<Vec<_>>(),
502        "sequence": updates.sequence
503    }))
504}
505
506#[cfg(test)]
507mod tests {
508    use super::*;
509
510    #[test]
511    fn test_normalize_path_components() {
512        // Test removing current directory markers
513        let path = Path::new("/foo/./bar/./baz");
514        let normalized = normalize_path_components(path);
515        assert_eq!(path_to_forward_slashes(&normalized), "/foo/bar/baz");
516
517        // Test resolving parent directory markers
518        let path = Path::new("/foo/bar/../baz");
519        let normalized = normalize_path_components(path);
520        assert_eq!(path_to_forward_slashes(&normalized), "/foo/baz");
521
522        // Test complex case
523        let path = Path::new("/foo/bar/./baz/../qux");
524        let normalized = normalize_path_components(path);
525        assert_eq!(path_to_forward_slashes(&normalized), "/foo/bar/qux");
526    }
527
528    #[test]
529    fn test_path_to_forward_slashes() {
530        // Test Windows-style path
531        let path = Path::new("C:\\foo\\bar\\baz");
532        assert_eq!(path_to_forward_slashes(path), "C:/foo/bar/baz");
533
534        // Test Unix-style path (no change)
535        let path = Path::new("/foo/bar/baz");
536        assert_eq!(path_to_forward_slashes(path), "/foo/bar/baz");
537    }
538
539    #[test]
540    fn test_normalize_file_paths() {
541        // Test that normalization is applied to all paths in a vector
542        let paths = vec!["src/main.rs".to_string(), "./src/lib.rs".to_string()];
543        let normalized = normalize_file_paths(paths);
544
545        // All paths should be absolute (start with / or drive letter on Windows)
546        for path in &normalized {
547            assert!(
548                path.starts_with('/') || (path.len() > 2 && path.chars().nth(1) == Some(':')),
549                "Path should be absolute: {}",
550                path
551            );
552        }
553    }
554}