1use crate::audit::{FileConflictAuditEvent, FileConflictAuditLog};
2use crate::config::PermissionsConfig;
3use crate::tools::file_ops::{build_diff_preview, diff_preview_error_skip};
4use anyhow::{Context, Result};
5use notify::{EventKind, RecommendedWatcher, RecursiveMode, Watcher};
6use parking_lot::Mutex;
7use serde_json::{Value, json};
8use std::collections::{HashMap, HashSet, VecDeque};
9use std::path::{Path, PathBuf};
10use std::sync::Arc;
11use std::sync::Mutex as StdMutex;
12use std::sync::atomic::{AtomicU64, Ordering};
13use std::sync::mpsc::{self, Receiver, RecvTimeoutError};
14use std::thread;
15use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
16use tokio::sync::Notify;
17use vtcode_commons::utils::calculate_sha256;
18use vtcode_commons::{canonicalize, workspace_relative_display};
19
20pub const FILE_CONFLICT_OVERRIDE_ARG: &str = "__vtcode_conflict_override";
21pub const FILE_CONFLICT_DETECTED_FIELD: &str = "conflict_detected";
22pub const FILE_CONFLICT_PATH_FIELD: &str = "conflict_path";
23
24#[derive(Clone, Debug, PartialEq, Eq)]
25pub struct FileSnapshot {
26 pub exists: bool,
27 pub size_bytes: u64,
28 pub modified_millis: Option<u128>,
29 pub sha256: String,
30 pub text_content: Option<String>,
31}
32
33impl FileSnapshot {
34 fn fingerprint(&self) -> String {
35 let modified = self
36 .modified_millis
37 .map(|value| value.to_string())
38 .unwrap_or_else(|| "none".to_string());
39 format!("exists={};size={};modified={};sha256={}", self.exists, self.size_bytes, modified, self.sha256)
40 }
41
42 fn to_json(&self) -> Value {
43 json!({
44 "exists": self.exists,
45 "size_bytes": self.size_bytes,
46 "modified_millis": self.modified_millis,
47 "sha256": self.sha256,
48 })
49 }
50
51 fn same_contents(&self, other: &Self) -> bool {
52 self.exists == other.exists && self.size_bytes == other.size_bytes && self.sha256 == other.sha256
53 }
54
55 fn same_identity(&self, other: &Self) -> bool {
56 self.same_contents(other) && self.modified_millis == other.modified_millis
57 }
58
59 fn from_identity_value(value: &Value) -> Option<Self> {
60 let object = value.as_object()?;
61 let modified_millis = match object.get("modified_millis") {
62 Some(Value::Null) | None => None,
63 Some(value) => value.as_u64().map(u128::from).or_else(|| {
64 value.as_i64().filter(|millis| *millis >= 0).map(|millis| {
65 #[allow(
66 clippy::cast_sign_loss,
67 reason = "Intentional compatibility, platform, or test-only suppression."
68 )]
69 let val: u64 = millis as u64;
70 val as u128
71 }) }),
73 };
74
75 Some(Self {
76 exists: object.get("exists")?.as_bool()?,
77 size_bytes: object.get("size_bytes")?.as_u64()?,
78 modified_millis,
79 sha256: object.get("sha256")?.as_str()?.to_string(),
80 text_content: None,
81 })
82 }
83
84 fn from_text_content(content: &str) -> Self {
85 let bytes = content.as_bytes();
86 let sha256 = calculate_sha256(bytes);
87
88 Self {
89 exists: true,
90 size_bytes: bytes.len() as u64,
91 modified_millis: None,
92 sha256,
93 text_content: Some(content.to_string()),
94 }
95 }
96
97 fn missing() -> Self {
98 Self {
99 exists: false,
100 size_bytes: 0,
101 modified_millis: None,
102 sha256: String::new(),
103 text_content: None,
104 }
105 }
106}
107
108#[derive(Clone, Debug)]
109pub struct FileConflict {
110 pub path: PathBuf,
111 pub read_snapshot: Option<FileSnapshot>,
112 pub disk_snapshot: Option<FileSnapshot>,
113 pub intended_content: Option<String>,
114 pub emit_hitl_notification: bool,
115}
116
117#[derive(Clone, Debug, PartialEq, Eq)]
118pub struct TrackedPathFreshness {
119 pub path: PathBuf,
120 pub is_stale: bool,
121 pub fingerprint: Option<String>,
122}
123
124impl FileConflict {
125 pub fn to_tool_output(&self, workspace_root: &Path) -> Value {
126 let display_path = workspace_relative_display(workspace_root, &self.path);
127 let disk_content = self.disk_snapshot.as_ref().and_then(|snapshot| snapshot.text_content.clone());
128 let diff_preview = match (&disk_content, &self.intended_content) {
129 (Some(before), Some(after)) => build_diff_preview(&display_path, Some(before), after),
130 (None, Some(_)) => diff_preview_error_skip("binary_or_non_utf8_disk_content", None),
131 _ => diff_preview_error_skip("missing_intended_content", None),
132 };
133
134 json!({
135 "success": true,
136 FILE_CONFLICT_DETECTED_FIELD: true,
137 FILE_CONFLICT_PATH_FIELD: display_path,
138 "message": "File changed on disk since the agent last read it.",
139 "resolution": "pending",
140 "emit_hitl_notification": self.emit_hitl_notification,
141 "disk_content": disk_content,
142 "intended_content": self.intended_content,
143 "read_snapshot": self.read_snapshot.as_ref().map(FileSnapshot::to_json),
144 "disk_snapshot": self.disk_snapshot.as_ref().map(FileSnapshot::to_json),
145 "diff_preview": diff_preview,
146 })
147 }
148}
149
150#[derive(Clone, Debug)]
151struct StaleConflictState {
152 notification_emitted: bool,
153}
154
155#[derive(Clone, Debug)]
156struct TrackedFileState {
157 last_read_snapshot: Option<FileSnapshot>,
158 last_known_disk_snapshot: Option<FileSnapshot>,
159 last_agent_write_snapshot: Option<FileSnapshot>,
160 active_mutation: Option<u64>,
161 pending_mutations: VecDeque<u64>,
162 stale_conflict: Option<StaleConflictState>,
163 notify: Arc<Notify>,
164}
165
166impl TrackedFileState {
167 fn new() -> Self {
168 Self {
169 last_read_snapshot: None,
170 last_known_disk_snapshot: None,
171 last_agent_write_snapshot: None,
172 active_mutation: None,
173 pending_mutations: VecDeque::new(),
174 stale_conflict: None,
175 notify: Arc::new(Notify::new()),
176 }
177 }
178}
179
180#[derive(Default)]
181struct MonitorState {
182 tracked_files: HashMap<PathBuf, TrackedFileState>,
183 watched_parents: HashSet<PathBuf>,
184}
185
186impl MonitorState {
187 fn tracked_entry(&mut self, path: &Path) -> &mut TrackedFileState {
188 self.tracked_files
189 .entry(path.to_path_buf())
190 .or_insert_with(TrackedFileState::new)
191 }
192}
193
194struct EditedFileMonitorInner {
195 state: Mutex<MonitorState>,
196 watcher: StdMutex<Option<RecommendedWatcher>>,
197 audit_log: Mutex<Option<FileConflictAuditLog>>,
198 next_mutation_id: AtomicU64,
199 debounce_duration: Duration,
200 inspection: crate::tools::source_inspection::SourceInspection,
201}
202
203#[derive(Clone)]
204pub struct EditedFileMonitor {
205 inner: Arc<EditedFileMonitorInner>,
206}
207
208pub struct MutationLease {
209 inner: Arc<EditedFileMonitorInner>,
210 path: PathBuf,
211 mutation_id: u64,
212 released: bool,
213}
214
215struct PendingMutationGuard {
216 inner: Arc<EditedFileMonitorInner>,
217 path: PathBuf,
218 mutation_id: u64,
219 armed: bool,
220}
221
222impl PendingMutationGuard {
223 fn new(inner: Arc<EditedFileMonitorInner>, path: PathBuf, mutation_id: u64) -> Self {
224 Self { inner, path, mutation_id, armed: true }
225 }
226
227 fn disarm(&mut self) {
228 self.armed = false;
229 }
230}
231
232impl Drop for PendingMutationGuard {
233 fn drop(&mut self) {
234 if !self.armed {
235 return;
236 }
237
238 let mut state = self.inner.state.lock();
239 let Some(entry) = state.tracked_files.get_mut(&self.path) else {
240 return;
241 };
242
243 if remove_pending_mutation(entry, self.mutation_id) {
244 entry.notify.notify_waiters();
245 }
246 }
247}
248
249impl MutationLease {
250 pub fn path(&self) -> &Path {
251 &self.path
252 }
253}
254
255impl Drop for MutationLease {
256 fn drop(&mut self) {
257 if self.released {
258 return;
259 }
260 let mut state = self.inner.state.lock();
261 if let Some(entry) = state.tracked_files.get_mut(&self.path) {
262 let mut released_active = false;
263 if entry.active_mutation == Some(self.mutation_id) {
264 entry.active_mutation = None;
265 released_active = true;
266 }
267 let removed_pending = remove_pending_mutation(entry, self.mutation_id);
268 if released_active || removed_pending {
269 entry.notify.notify_waiters();
270 }
271 }
272 self.released = true;
273 }
274}
275
276impl EditedFileMonitor {
277 pub fn source_inspection(&self) -> &crate::tools::source_inspection::SourceInspection {
278 &self.inner.inspection
279 }
280
281 pub fn new() -> Self {
282 let (event_tx, event_rx) = mpsc::channel::<PathBuf>();
283 let watcher = RecommendedWatcher::new(
284 move |event: notify::Result<notify::Event>| {
285 let Ok(event) = event else {
286 return;
287 };
288
289 if !matches!(event.kind, EventKind::Modify(_) | EventKind::Create(_) | EventKind::Remove(_)) {
290 return;
291 }
292
293 for path in event.paths {
294 let _ = event_tx.send(path);
295 }
296 },
297 notify::Config::default(),
298 )
299 .ok();
300
301 let inner = Arc::new(EditedFileMonitorInner {
302 state: Mutex::new(MonitorState::default()),
303 watcher: StdMutex::new(watcher),
304 audit_log: Mutex::new(None),
305 next_mutation_id: AtomicU64::new(1),
306 debounce_duration: Duration::from_millis(250),
307 inspection: Default::default(),
308 });
309
310 let monitor = Self { inner: Arc::clone(&inner) };
311 spawn_event_loop(inner, event_rx);
312 monitor
313 }
314
315 pub fn apply_permissions_config(&self, permissions: &PermissionsConfig) {
316 let mut audit_log = self.inner.audit_log.lock();
317 if !permissions.audit_enabled {
318 *audit_log = None;
319 return;
320 }
321
322 let audit_dir = vtcode_commons::paths::expand_tilde(&permissions.audit_directory);
323 match FileConflictAuditLog::new(audit_dir) {
324 Ok(log) => *audit_log = Some(log),
325 Err(err) => {
326 tracing::warn!(error = %err, "Failed to initialize file conflict audit log");
327 *audit_log = None;
328 }
329 }
330 }
331
332 pub async fn track_read(&self, path: &Path) -> Result<()> {
333 let path = normalize_event_path(path);
334 let snapshot = snapshot_path_async(path.clone()).await?;
335 self.record_read_snapshot(&path, snapshot)
336 }
337
338 pub async fn accept_disk_version(&self, path: &Path) -> Result<()> {
339 let path = normalize_event_path(path);
340 let snapshot = snapshot_path_async(path.clone()).await?;
341 self.record_read_snapshot(&path, snapshot)
342 }
343
344 pub fn record_read_snapshot(&self, path: &Path, snapshot: FileSnapshot) -> Result<()> {
345 let path = normalize_event_path(path);
346 {
347 let mut state = self.inner.state.lock();
348 let entry = state.tracked_entry(&path);
349 entry.last_read_snapshot = Some(snapshot.clone());
350 entry.last_known_disk_snapshot = Some(snapshot);
351 entry.stale_conflict = None;
352 }
353 self.watch_parent(&path);
354 Ok(())
355 }
356
357 pub fn record_read_text(&self, path: &Path, content: &str) -> Result<()> {
358 self.record_read_snapshot(path, FileSnapshot::from_text_content(content))
359 }
360
361 pub fn record_agent_write_snapshot(&self, path: &Path, snapshot: FileSnapshot) -> Result<()> {
362 let path = normalize_event_path(path);
363 {
364 let mut state = self.inner.state.lock();
365 let entry = state.tracked_entry(&path);
366 let snap_for_disk = snapshot.clone();
367 entry.last_read_snapshot = Some(snap_for_disk);
368 entry.last_known_disk_snapshot = Some(snapshot.clone());
369 entry.last_agent_write_snapshot = Some(snapshot);
370 entry.stale_conflict = None;
371 }
372 self.watch_parent(&path);
373 Ok(())
374 }
375
376 pub(crate) async fn record_agent_write(&self, path: &Path) -> Result<()> {
377 let root = normalize_event_path(path);
378 let mut paths = self
379 .tracked_paths()
380 .into_iter()
381 .filter(|path| path.starts_with(&root))
382 .collect::<Vec<_>>();
383 paths.push(root);
384 self.record_agent_paths(paths).await
385 }
386
387 pub(crate) async fn record_agent_move(&self, source: &Path, destination: &Path) -> Result<()> {
388 let source = normalize_event_path(source);
389 let destination = normalize_event_path(destination);
390 let mut paths = vec![source.clone(), destination.clone()];
391 for path in self.tracked_paths() {
392 if let Ok(relative) = path.strip_prefix(&source) {
393 paths.push(destination.join(relative));
394 paths.push(path);
395 } else if path.starts_with(&destination) {
396 paths.push(path);
397 }
398 }
399 self.record_agent_paths(paths).await
400 }
401
402 async fn record_agent_paths(&self, mut paths: Vec<PathBuf>) -> Result<()> {
403 paths.sort();
404 paths.dedup();
405 for path in paths {
406 let snapshot = snapshot_path_async(path.clone()).await?;
407 self.record_agent_write_snapshot(&path, snapshot)?;
408 }
409 Ok(())
410 }
411
412 pub fn record_agent_write_text(&self, path: &Path, content: &str) -> Result<()> {
413 self.record_agent_write_snapshot(path, FileSnapshot::from_text_content(content))
414 }
415
416 pub fn record_agent_removal(&self, path: &Path) -> Result<()> {
417 self.record_agent_write_snapshot(path, FileSnapshot::missing())
418 }
419
420 pub async fn tracked_read_text(&self, path: &Path) -> Option<String> {
421 let path = normalize_event_path(path);
422 let state = self.inner.state.lock();
423 state
424 .tracked_files
425 .get(&path)
426 .and_then(|entry| entry.last_read_snapshot.as_ref())
427 .and_then(|snapshot| snapshot.text_content.clone())
428 }
429
430 pub fn tracked_paths(&self) -> Vec<PathBuf> {
431 let state = self.inner.state.lock();
432 let mut paths = state.tracked_files.keys().cloned().collect::<Vec<_>>();
433 paths.sort();
434 paths
435 }
436
437 pub fn stale_tracked_paths(&self) -> Vec<PathBuf> {
438 self.tracked_path_freshness()
439 .into_iter()
440 .filter_map(|entry| entry.is_stale.then_some(entry.path))
441 .collect()
442 }
443
444 pub fn tracked_path_freshness(&self) -> Vec<TrackedPathFreshness> {
445 let state = self.inner.state.lock();
446 let mut entries = state
447 .tracked_files
448 .iter()
449 .map(|(path, entry)| {
450 let is_stale = entry
451 .last_read_snapshot
452 .as_ref()
453 .zip(entry.last_known_disk_snapshot.as_ref())
454 .is_some_and(|(read_snapshot, disk_snapshot)| !read_snapshot.same_contents(disk_snapshot));
455 TrackedPathFreshness {
456 path: path.clone(),
457 is_stale,
458 fingerprint: entry.last_known_disk_snapshot.as_ref().map(FileSnapshot::fingerprint),
459 }
460 })
461 .collect::<Vec<_>>();
462 entries.sort_by(|left, right| left.path.cmp(&right.path));
463 entries
464 }
465
466 pub fn tracked_path_fingerprint(&self, path: &Path) -> Option<String> {
467 let path = normalize_event_path(path);
468 let state = self.inner.state.lock();
469 state
470 .tracked_files
471 .get(&path)
472 .and_then(|entry| entry.last_known_disk_snapshot.as_ref())
473 .map(FileSnapshot::fingerprint)
474 }
475
476 pub async fn acquire_mutation(&self, path: &Path) -> MutationLease {
477 let path = normalize_event_path(path);
478 let mutation_id = self.inner.next_mutation_id.fetch_add(1, Ordering::SeqCst);
479 let mut pending_guard = PendingMutationGuard::new(Arc::clone(&self.inner), path.clone(), mutation_id);
480
481 loop {
482 let notify: Arc<Notify>;
483 let waiter = {
484 let mut state = self.inner.state.lock();
485 let entry = state.tracked_entry(&path);
486
487 if entry.active_mutation.is_none() {
488 if let Some(front) = entry.pending_mutations.front() {
489 if *front == mutation_id {
490 let _ = entry.pending_mutations.pop_front();
491 entry.active_mutation = Some(mutation_id);
492 pending_guard.disarm();
493 return MutationLease {
494 inner: Arc::clone(&self.inner),
495 path,
496 mutation_id,
497 released: false,
498 };
499 }
500 } else {
501 entry.active_mutation = Some(mutation_id);
502 pending_guard.disarm();
503 return MutationLease {
504 inner: Arc::clone(&self.inner),
505 path,
506 mutation_id,
507 released: false,
508 };
509 }
510 }
511
512 if !entry.pending_mutations.iter().any(|pending_id| *pending_id == mutation_id) {
513 entry.pending_mutations.push_back(mutation_id);
514 }
515
516 notify = entry.notify.clone();
517 let mut waiter = Box::pin(notify.notified());
523 waiter.as_mut().enable();
524 waiter
525 };
526
527 waiter.await;
528 }
529 }
530
531 pub async fn detect_conflict(
532 &self,
533 path: &Path,
534 intended_content: Option<String>,
535 approved_snapshot: Option<FileSnapshot>,
536 ) -> Result<Option<FileConflict>> {
537 let path = normalize_event_path(path);
538 let current_snapshot = snapshot_path_async(path.clone()).await?;
539 let mut should_audit = false;
540
541 let maybe_conflict = {
542 let mut state = self.inner.state.lock();
543 let entry = state.tracked_entry(&path);
544 entry.last_known_disk_snapshot = Some(current_snapshot.clone());
545
546 if entry
547 .last_agent_write_snapshot
548 .as_ref()
549 .is_some_and(|snapshot| snapshot.same_contents(¤t_snapshot))
550 {
551 entry.last_agent_write_snapshot = None;
552 entry.last_read_snapshot = Some(current_snapshot);
553 entry.stale_conflict = None;
554 return Ok(None);
555 }
556
557 let Some(read_snapshot) = entry.last_read_snapshot.clone() else {
558 return Ok(None);
559 };
560
561 if read_snapshot.same_contents(¤t_snapshot) {
562 entry.stale_conflict = None;
563 return Ok(None);
564 }
565
566 if approved_snapshot
567 .as_ref()
568 .is_some_and(|snapshot| snapshot.same_identity(¤t_snapshot))
569 {
570 entry.stale_conflict = None;
571 return Ok(None);
572 }
573
574 let emit_hitl_notification = match entry.stale_conflict.as_mut() {
575 Some(existing) => {
576 let emit = !existing.notification_emitted;
577 existing.notification_emitted = true;
578 emit
579 }
580 None => {
581 entry.stale_conflict = Some(StaleConflictState { notification_emitted: true });
582 should_audit = true;
583 true
584 }
585 };
586
587 Some(FileConflict {
588 path: path.clone(),
589 read_snapshot: Some(read_snapshot),
590 disk_snapshot: Some(current_snapshot.clone()),
591 intended_content,
592 emit_hitl_notification,
593 })
594 };
595
596 if should_audit {
597 self.record_conflict_audit(&path, ¤t_snapshot, "pre_write_conflict");
598 }
599
600 Ok(maybe_conflict)
601 }
602
603 #[cfg(test)]
604 pub async fn debug_process_path_change(&self, path: &Path) -> Result<()> {
605 self.process_path_change(path.to_path_buf())
606 }
607
608 fn watch_parent(&self, path: &Path) {
609 let Some(parent) = path.parent().map(Path::to_path_buf) else {
610 return;
611 };
612
613 let should_watch = {
614 let mut state = self.inner.state.lock();
615 state.watched_parents.insert(parent.clone())
616 };
617
618 if !should_watch {
619 return;
620 }
621
622 let Ok(mut watcher) = self.inner.watcher.lock() else {
623 return;
624 };
625 let Some(watcher) = watcher.as_mut() else {
626 return;
627 };
628
629 if let Err(err) = watcher.watch(&parent, RecursiveMode::NonRecursive) {
630 tracing::warn!(path = %parent.display(), error = %err, "Failed to watch edited-file parent directory");
631 }
632 }
633
634 fn record_conflict_audit(&self, path: &Path, snapshot: &FileSnapshot, reason: &str) {
635 let event = FileConflictAuditEvent {
636 timestamp: chrono::Local::now(),
637 path: path.to_path_buf(),
638 reason: reason.to_string(),
639 file_exists: snapshot.exists,
640 size_bytes: snapshot.exists.then_some(snapshot.size_bytes),
641 sha256: snapshot.exists.then_some(snapshot.sha256.clone()),
642 };
643
644 if let Some(log) = self.inner.audit_log.lock().as_mut()
645 && let Err(err) = log.record(&event)
646 {
647 tracing::warn!(error = %err, "Failed to record file conflict audit event");
648 }
649 }
650
651 fn process_path_change(&self, path: PathBuf) -> Result<()> {
652 let tracked_path = normalize_event_path(&path);
653 let snapshot = snapshot_path_sync(&tracked_path)
654 .with_context(|| format!("Failed to snapshot externally modified file {}", tracked_path.display()))?;
655
656 let mut should_audit = false;
657
658 {
659 let mut state = self.inner.state.lock();
660 let Some(entry) = state.tracked_files.get_mut(&tracked_path) else {
661 return Ok(());
662 };
663
664 if entry
665 .last_known_disk_snapshot
666 .as_ref()
667 .is_some_and(|known| known.same_contents(&snapshot))
668 {
669 return Ok(());
670 }
671
672 entry.last_known_disk_snapshot = Some(snapshot.clone());
673
674 if entry
675 .last_agent_write_snapshot
676 .as_ref()
677 .is_some_and(|known| known.same_contents(&snapshot))
678 {
679 entry.last_read_snapshot = Some(snapshot);
680 entry.last_agent_write_snapshot = None;
681 entry.stale_conflict = None;
682 return Ok(());
683 }
684
685 if entry
686 .last_read_snapshot
687 .as_ref()
688 .is_some_and(|known| known.same_contents(&snapshot))
689 {
690 entry.stale_conflict = None;
691 return Ok(());
692 }
693
694 if entry
695 .last_read_snapshot
696 .as_ref()
697 .is_some_and(|read_snapshot| !read_snapshot.same_contents(&snapshot))
698 {
699 should_audit = true;
700 match entry.stale_conflict.as_mut() {
701 Some(_) => {}
702 None => {
703 entry.stale_conflict = Some(StaleConflictState { notification_emitted: false });
704 }
705 }
706 }
707 }
708
709 if should_audit {
710 self.record_conflict_audit(&tracked_path, &snapshot, "watcher_detected_external_change");
711 }
712
713 Ok(())
714 }
715}
716
717impl Default for EditedFileMonitor {
718 fn default() -> Self {
719 Self::new()
720 }
721}
722
723fn spawn_event_loop(inner: Arc<EditedFileMonitorInner>, event_rx: Receiver<PathBuf>) {
724 thread::spawn(move || {
725 let mut pending = HashMap::<PathBuf, Instant>::new();
726
727 loop {
728 match event_rx.recv_timeout(Duration::from_millis(50)) {
729 Ok(path) => {
730 pending.insert(normalize_event_path(&path), Instant::now());
731 }
732 Err(RecvTimeoutError::Timeout) => {}
733 Err(RecvTimeoutError::Disconnected) => break,
734 }
735
736 let now = Instant::now();
737 let ready = pending
738 .iter()
739 .filter_map(|(path, observed_at)| {
740 (now.duration_since(*observed_at) >= inner.debounce_duration).then_some(path.clone())
741 })
742 .collect::<Vec<_>>();
743
744 for path in ready {
745 pending.remove(&path);
746 let monitor = EditedFileMonitor { inner: Arc::clone(&inner) };
747 if let Err(err) = monitor.process_path_change(path.clone()) {
748 tracing::debug!(path = %path.display(), error = %err, "Edited-file watcher refresh failed");
749 }
750 }
751 }
752 });
753}
754
755fn normalize_event_path(path: &Path) -> PathBuf {
756 canonicalize(path).unwrap_or_else(|_| {
757 path.parent()
758 .and_then(|parent| canonicalize(parent).ok())
759 .and_then(|parent| path.file_name().map(|name| parent.join(name)))
760 .unwrap_or_else(|| path.to_path_buf())
761 })
762}
763
764async fn snapshot_path_async(path: PathBuf) -> Result<FileSnapshot> {
765 tokio::task::spawn_blocking(move || snapshot_path_sync(&path))
766 .await
767 .context("Failed to join file snapshot task")?
768}
769
770fn snapshot_path_sync(path: &Path) -> Result<FileSnapshot> {
771 let metadata = match std::fs::metadata(path) {
772 Ok(metadata) => metadata,
773 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
774 return Ok(FileSnapshot {
775 exists: false,
776 size_bytes: 0,
777 modified_millis: None,
778 sha256: String::new(),
779 text_content: None,
780 });
781 }
782 Err(err) => {
783 return Err(err).with_context(|| format!("Failed to read metadata for {}", path.display()));
784 }
785 };
786
787 if !metadata.is_file() {
788 return Ok(FileSnapshot {
789 exists: false,
790 size_bytes: 0,
791 modified_millis: metadata.modified().ok().and_then(system_time_to_millis),
792 sha256: String::new(),
793 text_content: None,
794 });
795 }
796
797 let bytes = std::fs::read(path).with_context(|| format!("Failed to read file bytes for {}", path.display()))?;
798 let sha256 = calculate_sha256(&bytes);
799
800 Ok(FileSnapshot {
801 exists: true,
802 size_bytes: metadata.len(),
803 modified_millis: metadata.modified().ok().and_then(system_time_to_millis),
804 sha256,
805 text_content: String::from_utf8(bytes).ok(),
806 })
807}
808
809fn system_time_to_millis(time: SystemTime) -> Option<u128> {
810 time.duration_since(UNIX_EPOCH).ok().map(|duration| duration.as_millis())
811}
812
813pub fn conflict_override_snapshot(args: &Value) -> Option<FileSnapshot> {
814 args.get(FILE_CONFLICT_OVERRIDE_ARG).and_then(FileSnapshot::from_identity_value)
815}
816
817fn remove_pending_mutation(entry: &mut TrackedFileState, mutation_id: u64) -> bool {
818 let Some(index) = entry.pending_mutations.iter().position(|pending_id| *pending_id == mutation_id) else {
819 return false;
820 };
821
822 let _ = entry.pending_mutations.remove(index);
823 true
824}
825
826#[cfg(test)]
827mod tests {
828 use super::*;
829 use tempfile::TempDir;
830
831 #[tokio::test]
832 async fn detects_same_mtime_same_size_hash_mismatch() -> Result<()> {
833 let temp = TempDir::new()?;
834 let file = temp.path().join("sample.txt");
835 std::fs::write(&file, "before\n")?;
836
837 let first = snapshot_path_async(file.clone()).await?;
838 std::fs::write(&file, "after!\n")?;
839 let second = snapshot_path_async(file.clone()).await?;
840
841 assert_eq!(first.size_bytes, second.size_bytes);
842 assert_ne!(first.sha256, second.sha256);
843 Ok(())
844 }
845
846 #[tokio::test]
847 async fn suppresses_self_write_event() -> Result<()> {
848 let temp = TempDir::new()?;
849 let file = temp.path().join("sample.txt");
850 std::fs::write(&file, "before\n")?;
851 let monitor = EditedFileMonitor::new();
852
853 monitor.track_read(&file).await?;
854 std::fs::write(&file, "after\n")?;
855 monitor.record_agent_write_text(&file, "after\n")?;
856 monitor.debug_process_path_change(&file).await?;
857
858 assert!(
859 monitor
860 .detect_conflict(&file, Some("after\n".to_string()), None)
861 .await?
862 .is_none()
863 );
864 Ok(())
865 }
866
867 #[tokio::test]
868 async fn expected_agent_snapshot_does_not_hide_external_follow_up_change() -> Result<()> {
869 let temp = TempDir::new()?;
870 let file = temp.path().join("sample.txt");
871 std::fs::write(&file, "before\n")?;
872 let monitor = EditedFileMonitor::new();
873
874 monitor.track_read(&file).await?;
875 std::fs::write(&file, "after\n")?;
876 monitor.record_agent_write_text(&file, "after\n")?;
877
878 std::fs::write(&file, "after formatted\n")?;
879 monitor.debug_process_path_change(&file).await?;
880
881 let conflict = monitor.detect_conflict(&file, Some("agent\n".to_string()), None).await?;
882 assert!(conflict.is_some());
883 Ok(())
884 }
885
886 #[tokio::test]
887 async fn queues_mutations_in_order() -> Result<()> {
888 let temp = TempDir::new()?;
889 let file = temp.path().join("sample.txt");
890 std::fs::write(&file, "hello\n")?;
891 let monitor = Arc::new(EditedFileMonitor::new());
892 let normalized_file = normalize_event_path(&file);
893
894 let first = monitor.acquire_mutation(&file).await;
895 let monitor_clone = Arc::clone(&monitor);
896 let file_clone = file.clone();
897 let waiter = tokio::spawn(async move {
898 let lease = monitor_clone.acquire_mutation(&file_clone).await;
899 lease.path().to_path_buf()
900 });
901
902 tokio::time::sleep(Duration::from_millis(50)).await;
903 drop(first);
904 let acquired = waiter.await?;
905 assert_eq!(acquired, normalized_file);
906 Ok(())
907 }
908
909 #[tokio::test]
910 async fn concurrent_mutation_waiters_all_acquire_without_stall() -> Result<()> {
911 let temp = TempDir::new()?;
912 let file = temp.path().join("sample.txt");
913 std::fs::write(&file, "hello\n")?;
914 let monitor = Arc::new(EditedFileMonitor::new());
915 let normalized_file = normalize_event_path(&file);
916
917 let first = monitor.acquire_mutation(&file).await;
918 let mut waiters = Vec::new();
919 for _ in 0..8 {
920 let monitor_clone = Arc::clone(&monitor);
921 let file_clone = file.clone();
922 waiters.push(tokio::spawn(async move {
923 let lease = monitor_clone.acquire_mutation(&file_clone).await;
924 lease.path().to_path_buf()
925 }));
926 }
927
928 tokio::time::sleep(Duration::from_millis(100)).await;
931 drop(first);
932
933 let acquired = tokio::time::timeout(Duration::from_secs(10), async {
934 let mut paths = Vec::new();
935 for waiter in waiters {
936 paths.push(waiter.await?);
937 }
938 Ok::<_, anyhow::Error>(paths)
939 })
940 .await??;
941 assert_eq!(acquired.len(), 8);
942 for path in &acquired {
943 assert_eq!(*path, normalized_file);
944 }
945 Ok(())
946 }
947
948 #[tokio::test]
949 async fn detects_external_change_after_read() -> Result<()> {
950 let temp = TempDir::new()?;
951 let file = temp.path().join("sample.txt");
952 std::fs::write(&file, "before\n")?;
953 let monitor = EditedFileMonitor::new();
954
955 monitor.track_read(&file).await?;
956 std::fs::write(&file, "external\n")?;
957 monitor.debug_process_path_change(&file).await?;
958
959 let conflict = monitor.detect_conflict(&file, Some("agent\n".to_string()), None).await?;
960 assert!(conflict.is_some());
961 Ok(())
962 }
963
964 #[tokio::test]
965 async fn cancelled_waiter_does_not_block_following_mutation() -> Result<()> {
966 let temp = TempDir::new()?;
967 let file = temp.path().join("sample.txt");
968 std::fs::write(&file, "hello\n")?;
969 let monitor = Arc::new(EditedFileMonitor::new());
970 let normalized_file = normalize_event_path(&file);
971
972 let first = monitor.acquire_mutation(&file).await;
973
974 let pending_monitor = Arc::clone(&monitor);
975 let pending_file = file.clone();
976 let pending = tokio::spawn(async move {
977 let _lease = pending_monitor.acquire_mutation(&pending_file).await;
978 });
979 tokio::time::sleep(Duration::from_millis(50)).await;
980 pending.abort();
981
982 let next_monitor = Arc::clone(&monitor);
983 let next_file = file.clone();
984 let next = tokio::spawn(async move {
985 let lease = next_monitor.acquire_mutation(&next_file).await;
986 lease.path().to_path_buf()
987 });
988
989 drop(first);
990
991 let acquired = tokio::time::timeout(Duration::from_secs(1), next).await??;
992 assert_eq!(acquired, normalized_file);
993 Ok(())
994 }
995
996 #[tokio::test]
997 async fn clears_conflict_state_when_disk_returns_to_read_snapshot() -> Result<()> {
998 let temp = TempDir::new()?;
999 let file = temp.path().join("sample.txt");
1000 std::fs::write(&file, "before\n")?;
1001 let monitor = EditedFileMonitor::new();
1002
1003 monitor.track_read(&file).await?;
1004 std::fs::write(&file, "external one\n")?;
1005 monitor.debug_process_path_change(&file).await?;
1006
1007 let first_conflict = monitor
1008 .detect_conflict(&file, Some("agent\n".to_string()), None)
1009 .await?
1010 .expect("expected initial conflict");
1011 assert!(first_conflict.emit_hitl_notification);
1012
1013 std::fs::write(&file, "before\n")?;
1014 monitor.debug_process_path_change(&file).await?;
1015 assert!(
1016 monitor
1017 .detect_conflict(&file, Some("agent\n".to_string()), None)
1018 .await?
1019 .is_none()
1020 );
1021
1022 std::fs::write(&file, "external two\n")?;
1023 monitor.debug_process_path_change(&file).await?;
1024 let second_conflict = monitor
1025 .detect_conflict(&file, Some("agent\n".to_string()), None)
1026 .await?
1027 .expect("expected renewed conflict");
1028 assert!(second_conflict.emit_hitl_notification);
1029 Ok(())
1030 }
1031
1032 #[tokio::test]
1033 async fn stale_path_queries_expose_external_change_without_clearing_state() -> Result<()> {
1034 let temp = TempDir::new()?;
1035 let file = temp.path().join("sample.txt");
1036 std::fs::write(&file, "before\n")?;
1037 let monitor = EditedFileMonitor::new();
1038
1039 monitor.track_read(&file).await?;
1040 let tracked_paths = monitor.tracked_paths();
1041 assert_eq!(tracked_paths.len(), 1);
1042 let tracked_path = tracked_paths[0].clone();
1043 assert!(monitor.stale_tracked_paths().is_empty());
1044
1045 std::fs::write(&file, "external\n")?;
1046 monitor.debug_process_path_change(&file).await?;
1047
1048 let freshness = monitor.tracked_path_freshness();
1049 assert_eq!(freshness.len(), 1);
1050 assert_eq!(freshness[0].path, tracked_path);
1051 assert!(freshness[0].is_stale);
1052 assert!(freshness[0].fingerprint.is_some());
1053 assert_eq!(monitor.stale_tracked_paths(), vec![freshness[0].path.clone()]);
1054 assert_eq!(monitor.tracked_path_fingerprint(&freshness[0].path), freshness[0].fingerprint);
1055 Ok(())
1056 }
1057
1058 #[tokio::test]
1059 async fn override_requires_matching_approved_snapshot() -> Result<()> {
1060 let temp = TempDir::new()?;
1061 let file = temp.path().join("sample.txt");
1062 std::fs::write(&file, "before\n")?;
1063 let monitor = EditedFileMonitor::new();
1064
1065 monitor.track_read(&file).await?;
1066 std::fs::write(&file, "external one\n")?;
1067 let approved_snapshot = snapshot_path_async(file.clone()).await?;
1068
1069 std::fs::write(&file, "external two\n")?;
1070
1071 let conflict = monitor
1072 .detect_conflict(&file, Some("agent\n".to_string()), Some(approved_snapshot))
1073 .await?;
1074 assert!(conflict.is_some());
1075 Ok(())
1076 }
1077
1078 #[test]
1079 fn normalizes_missing_event_paths_via_canonical_parent() -> Result<()> {
1080 let temp = TempDir::new()?;
1081 let missing = temp.path().join("missing.txt");
1082 let canonical_parent = canonicalize(temp.path())?;
1083
1084 assert_eq!(normalize_event_path(&missing), canonical_parent.join("missing.txt"));
1085 Ok(())
1086 }
1087}