Skip to main content

koda_core/
file_tracker.rs

1//! File lifecycle tracker — tracks files created by Koda during a session.
2//!
3//! Inspired by Rust's ownership model (#465):
4//! - **Ownership**: files created via `Write` are "owned" by the session.
5//! - **Auto-approve cleanup**: deleting an owned file skips the destructive
6//!   confirmation gate (the net effect is zero — Koda created it, Koda removes it).
7//! - **Persistence**: state is backed by SQLite so it survives compaction,
8//!   token limits, and process crashes.
9//!
10//! Ownership is deliberately narrow: only `Write` (create) confers ownership.
11//! `Edit` of a user's file does not. Editing an already-owned file preserves
12//! ownership (Koda still created the file).
13
14use std::collections::HashSet;
15use std::path::{Path, PathBuf};
16
17use path_clean::PathClean;
18
19use crate::db::Database;
20
21/// Extract and resolve a file path from tool call arguments.
22///
23/// Looks for `"path"` or `"file_path"` in the JSON args, then resolves
24/// relative paths against `project_root`. The result is cleaned
25/// (normalized) so that `./foo/../bar.txt` and `bar.txt` resolve to
26/// the same path, preventing duplicate tracking (#474).
27///
28/// Uses `canonicalize()` when the file already exists (resolves symlinks),
29/// falling back to `PathClean::clean()` for new files that don't exist yet.
30pub(crate) fn resolve_file_path_from_args(
31    args: &serde_json::Value,
32    project_root: &Path,
33) -> Option<PathBuf> {
34    let path_str = args
35        .get("path")
36        .or(args.get("file_path"))
37        .and_then(|v| v.as_str())?;
38    let requested = Path::new(path_str);
39    let abs_path = if requested.is_absolute() {
40        requested.to_path_buf()
41    } else {
42        project_root.join(requested)
43    };
44    // Prefer canonicalize (resolves symlinks, e.g. macOS /var → /private/var)
45    // but fall back to clean() for files that don't exist yet (Write creates them).
46    Some(abs_path.canonicalize().unwrap_or_else(|_| abs_path.clean()))
47}
48
49/// Tracks files created by Koda in the current session.
50///
51/// In-memory `HashSet` for fast lookups, with DB persistence for
52/// crash recovery and session resume.
53#[derive(Debug)]
54pub struct FileTracker {
55    /// Files owned (created) by Koda in this session.
56    owned: HashSet<PathBuf>,
57    /// Session ID for DB persistence.
58    session_id: String,
59    /// Database handle.
60    db: Database,
61}
62
63impl FileTracker {
64    /// Create a new tracker, loading any persisted state from a previous run.
65    pub async fn new(session_id: &str, db: Database) -> Self {
66        let owned = db.load_owned_files(session_id).await.unwrap_or_default();
67        Self {
68            owned,
69            session_id: session_id.to_string(),
70            db,
71        }
72    }
73
74    /// Record that Koda created a file via `Write`.
75    ///
76    /// The path should be the resolved absolute path.
77    pub async fn track_created(&mut self, path: PathBuf) {
78        if self.owned.insert(path.clone())
79            && let Err(e) = self.db.insert_owned_file(&self.session_id, &path).await
80        {
81            tracing::warn!("file_tracker: failed to persist owned file {:?}: {e}", path);
82        }
83    }
84
85    /// Remove a file from the owned set (after successful deletion).
86    pub async fn untrack(&mut self, path: &Path) {
87        if self.owned.remove(path)
88            && let Err(e) = self.db.delete_owned_file(&self.session_id, path).await
89        {
90            tracing::warn!("file_tracker: failed to remove owned file {:?}: {e}", path);
91        }
92    }
93
94    /// Check whether Koda owns (created) this file.
95    ///
96    /// Used by the approval system to auto-approve deletion of
97    /// files that Koda itself created.
98    pub fn is_owned(&self, path: &Path) -> bool {
99        self.owned.contains(path)
100    }
101
102    /// Return the number of currently owned files (for diagnostics).
103    #[cfg(test)]
104    pub fn len(&self) -> usize {
105        self.owned.len()
106    }
107
108    /// Whether the tracker has no owned files.
109    #[cfg(test)]
110    pub fn is_empty(&self) -> bool {
111        self.owned.is_empty()
112    }
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118    use crate::persistence::Persistence;
119    use tempfile::TempDir;
120
121    async fn test_db() -> (Database, TempDir) {
122        let dir = TempDir::new().unwrap();
123        let db = Database::open(&dir.path().join("test.db")).await.unwrap();
124        db.create_session("test-agent", dir.path()).await.unwrap();
125        (db, dir)
126    }
127
128    #[tokio::test]
129    async fn track_and_check_ownership() {
130        let (db, _dir) = test_db().await;
131        let mut tracker = FileTracker::new("test-session", db).await;
132
133        let path = PathBuf::from("/tmp/koda_test_file.md");
134        assert!(!tracker.is_owned(&path));
135
136        tracker.track_created(path.clone()).await;
137        assert!(tracker.is_owned(&path));
138        assert_eq!(tracker.len(), 1);
139    }
140
141    #[tokio::test]
142    async fn untrack_removes_ownership() {
143        let (db, _dir) = test_db().await;
144        let mut tracker = FileTracker::new("test-session", db).await;
145
146        let path = PathBuf::from("/tmp/koda_test_file.md");
147        tracker.track_created(path.clone()).await;
148        assert!(tracker.is_owned(&path));
149
150        tracker.untrack(&path).await;
151        assert!(!tracker.is_owned(&path));
152        assert_eq!(tracker.len(), 0);
153    }
154
155    #[tokio::test]
156    async fn persists_across_tracker_instances() {
157        let (db, _dir) = test_db().await;
158        let session_id = "persist-test";
159        let path = PathBuf::from("/tmp/koda_persist.md");
160
161        // Create and track
162        {
163            let mut tracker = FileTracker::new(session_id, db.clone()).await;
164            tracker.track_created(path.clone()).await;
165        }
166
167        // New tracker for same session — should see the file
168        {
169            let tracker = FileTracker::new(session_id, db.clone()).await;
170            assert!(tracker.is_owned(&path));
171        }
172    }
173
174    #[tokio::test]
175    async fn different_sessions_isolated() {
176        let (db, _dir) = test_db().await;
177        let path = PathBuf::from("/tmp/koda_isolated.md");
178
179        let mut tracker_a = FileTracker::new("session-a", db.clone()).await;
180        tracker_a.track_created(path.clone()).await;
181
182        let tracker_b = FileTracker::new("session-b", db).await;
183        assert!(!tracker_b.is_owned(&path));
184    }
185
186    #[tokio::test]
187    async fn duplicate_track_is_idempotent() {
188        let (db, _dir) = test_db().await;
189        let mut tracker = FileTracker::new("test-session", db).await;
190
191        let path = PathBuf::from("/tmp/koda_dup.md");
192        tracker.track_created(path.clone()).await;
193        tracker.track_created(path.clone()).await;
194        assert_eq!(tracker.len(), 1);
195    }
196
197    #[tokio::test]
198    async fn untrack_nonexistent_is_noop() {
199        let (db, _dir) = test_db().await;
200        let mut tracker = FileTracker::new("test-session", db).await;
201
202        let path = PathBuf::from("/tmp/never_tracked.md");
203        tracker.untrack(&path).await; // should not panic
204        assert_eq!(tracker.len(), 0);
205    }
206
207    #[tokio::test]
208    async fn absolute_path_ownership() {
209        let (db, _dir) = test_db().await;
210        let mut tracker = FileTracker::new("test-session", db).await;
211
212        let abs = PathBuf::from("/home/user/project/output.csv");
213        tracker.track_created(abs.clone()).await;
214        assert!(tracker.is_owned(&abs));
215
216        // Same absolute path via different PathBuf instance still matches
217        let abs2 = PathBuf::from("/home/user/project/output.csv");
218        assert!(tracker.is_owned(&abs2));
219
220        // Different path is not owned
221        let other = PathBuf::from("/home/user/project/readme.md");
222        assert!(!tracker.is_owned(&other));
223    }
224
225    #[tokio::test]
226    async fn cross_session_resume_preserves_ownership_for_approval() {
227        use crate::trust::{ToolApproval, TrustMode, check_tool_with_tracker};
228
229        let (db, _dir) = test_db().await;
230        let session_id = "resume-test";
231        let root = Path::new("/home/user/project");
232        let owned_path = root.join("ephemeral.md");
233
234        // Session 1: track a created file, then "crash"
235        {
236            let mut tracker = FileTracker::new(session_id, db.clone()).await;
237            tracker.track_created(owned_path.clone()).await;
238            assert!(tracker.is_owned(&owned_path));
239        }
240
241        // Session 2: resume — tracker should load from DB and still
242        // auto-approve deletion of the owned file
243        {
244            let tracker = FileTracker::new(session_id, db.clone()).await;
245            assert!(
246                tracker.is_owned(&owned_path),
247                "Resumed tracker should still own the file"
248            );
249
250            let args = serde_json::json!({"path": "ephemeral.md"});
251            assert_eq!(
252                check_tool_with_tracker(
253                    "Delete",
254                    &args,
255                    TrustMode::Auto,
256                    Some(root),
257                    Some(&tracker),
258                ),
259                ToolApproval::AutoApprove,
260                "Delete of resumed owned file should auto-approve"
261            );
262        }
263    }
264}