From e5d9f51df7cef9857366bf09daa9e8d3490fccfd Mon Sep 17 00:00:00 2001 From: Hibryda Date: Wed, 11 Mar 2026 00:56:27 +0100 Subject: [PATCH] feat(s1p2): add inotify-based filesystem write detection with external conflict tracking --- v2/src-tauri/src/fs_watcher.rs | 216 ++++++++++++++++++ v2/src-tauri/src/lib.rs | 24 ++ v2/src/lib/adapters/fs-watcher-bridge.ts | 28 +++ .../components/Workspace/ProjectBox.svelte | 33 +++ .../components/Workspace/ProjectHeader.svelte | 27 ++- v2/src/lib/stores/conflicts.svelte.ts | 76 +++++- v2/src/lib/stores/conflicts.test.ts | 97 ++++++++ v2/src/lib/stores/health.svelte.ts | 7 +- 8 files changed, 501 insertions(+), 7 deletions(-) create mode 100644 v2/src-tauri/src/fs_watcher.rs create mode 100644 v2/src/lib/adapters/fs-watcher-bridge.ts diff --git a/v2/src-tauri/src/fs_watcher.rs b/v2/src-tauri/src/fs_watcher.rs new file mode 100644 index 0000000..763751f --- /dev/null +++ b/v2/src-tauri/src/fs_watcher.rs @@ -0,0 +1,216 @@ +// Filesystem write detection for project directories +// Uses notify crate (inotify on Linux) to detect file modifications. +// Emits Tauri events so frontend can detect external writes vs agent-managed writes. + +use notify::{Config, Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher}; +use serde::Serialize; +use std::collections::HashMap; +use std::path::Path; +use std::sync::Mutex; +use std::time::{Duration, Instant}; +use tauri::Emitter; + +/// Payload emitted on fs-write-detected events +#[derive(Clone, Serialize)] +pub struct FsWritePayload { + pub project_id: String, + pub file_path: String, + pub timestamp_ms: u64, +} + +/// Directories to skip when watching recursively +const IGNORED_DIRS: &[&str] = &[ + ".git", + "node_modules", + "target", + ".svelte-kit", + "dist", + "__pycache__", + ".next", + ".nuxt", + ".cache", + "build", +]; + +struct ProjectWatch { + _watcher: RecommendedWatcher, + _cwd: String, +} + +pub struct ProjectFsWatcher { + watches: Mutex>, +} + +impl ProjectFsWatcher { + pub fn new() -> Self { + Self { + watches: Mutex::new(HashMap::new()), + } + } + + /// Start watching a project's CWD for file writes (Create, Modify, Rename). + /// Debounces events per-file (100ms) to avoid flooding on rapid writes. + pub fn watch_project( + &self, + app: &tauri::AppHandle, + project_id: &str, + cwd: &str, + ) -> Result<(), String> { + let cwd_path = Path::new(cwd); + if !cwd_path.is_dir() { + return Err(format!("Not a directory: {cwd}")); + } + + let mut watches = self.watches.lock().unwrap(); + + // Don't duplicate — unwatch first if already watching + if watches.contains_key(project_id) { + drop(watches); + self.unwatch_project(project_id); + watches = self.watches.lock().unwrap(); + } + + let app_handle = app.clone(); + let project_id_owned = project_id.to_string(); + let cwd_owned = cwd.to_string(); + + // Per-file debounce state + let debounce: std::sync::Arc>> = + std::sync::Arc::new(Mutex::new(HashMap::new())); + let debounce_duration = Duration::from_millis(100); + + let mut watcher = RecommendedWatcher::new( + move |res: Result| { + let event = match res { + Ok(e) => e, + Err(_) => return, + }; + + // Only care about file writes (create, modify, rename-to) + let is_write = matches!( + event.kind, + EventKind::Create(_) | EventKind::Modify(_) + ); + if !is_write { + return; + } + + for path in &event.paths { + // Skip directories + if path.is_dir() { + continue; + } + + let path_str = path.to_string_lossy().to_string(); + + // Skip ignored directories + if should_ignore_path(&path_str) { + continue; + } + + // Debounce: skip if same file was emitted within debounce window + let now = Instant::now(); + let mut db = debounce.lock().unwrap(); + if let Some(last) = db.get(&path_str) { + if now.duration_since(*last) < debounce_duration { + continue; + } + } + db.insert(path_str.clone(), now); + // Prune old debounce entries (keep map from growing unbounded) + if db.len() > 1000 { + let max_age = debounce_duration * 10; + db.retain(|_, v| now.duration_since(*v) < max_age); + } + drop(db); + + let timestamp_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64; + + let _ = app_handle.emit( + "fs-write-detected", + FsWritePayload { + project_id: project_id_owned.clone(), + file_path: path_str, + timestamp_ms, + }, + ); + } + }, + Config::default(), + ) + .map_err(|e| format!("Failed to create fs watcher: {e}"))?; + + watcher + .watch(cwd_path, RecursiveMode::Recursive) + .map_err(|e| format!("Failed to watch directory: {e}"))?; + + log::info!("Started fs watcher for project {project_id} at {cwd}"); + + watches.insert( + project_id.to_string(), + ProjectWatch { + _watcher: watcher, + _cwd: cwd_owned, + }, + ); + + Ok(()) + } + + /// Stop watching a project's CWD + pub fn unwatch_project(&self, project_id: &str) { + let mut watches = self.watches.lock().unwrap(); + if watches.remove(project_id).is_some() { + log::info!("Stopped fs watcher for project {project_id}"); + } + } + +} + +/// Check if a path contains any ignored directory component +fn should_ignore_path(path: &str) -> bool { + for component in Path::new(path).components() { + if let std::path::Component::Normal(name) = component { + let name_str = name.to_string_lossy(); + if IGNORED_DIRS.contains(&name_str.as_ref()) { + return true; + } + } + } + false +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_should_ignore_git() { + assert!(should_ignore_path("/home/user/project/.git/objects/abc")); + assert!(should_ignore_path("/home/user/project/.git/HEAD")); + } + + #[test] + fn test_should_ignore_node_modules() { + assert!(should_ignore_path("/project/node_modules/pkg/index.js")); + } + + #[test] + fn test_should_ignore_target() { + assert!(should_ignore_path("/project/target/debug/build/foo")); + } + + #[test] + fn test_should_not_ignore_src() { + assert!(!should_ignore_path("/project/src/main.rs")); + assert!(!should_ignore_path("/project/src/lib/stores/health.svelte.ts")); + } + + #[test] + fn test_should_not_ignore_root_file() { + assert!(!should_ignore_path("/project/Cargo.toml")); + } +} diff --git a/v2/src-tauri/src/lib.rs b/v2/src-tauri/src/lib.rs index b4417d5..39c2ec7 100644 --- a/v2/src-tauri/src/lib.rs +++ b/v2/src-tauri/src/lib.rs @@ -1,5 +1,6 @@ mod ctx; mod event_sink; +mod fs_watcher; mod groups; mod pty; mod remote; @@ -15,6 +16,7 @@ use pty::{PtyManager, PtyOptions}; use remote::{RemoteManager, RemoteMachineConfig}; use session::{Session, SessionDb, LayoutState, SshSession, AgentMessageRecord, ProjectAgentState}; use sidecar::{AgentQueryOptions, SidecarConfig, SidecarManager}; +use fs_watcher::ProjectFsWatcher; use watcher::FileWatcherManager; use std::sync::Arc; use tauri::{Manager, State}; @@ -24,6 +26,7 @@ struct AppState { sidecar_manager: Arc, session_db: Arc, file_watcher: Arc, + fs_watcher: Arc, ctx_db: Arc, remote_manager: Arc, _telemetry: telemetry::TelemetryGuard, @@ -111,6 +114,23 @@ fn file_read(state: State<'_, AppState>, path: String) -> Result state.file_watcher.read_file(&path) } +// --- Project filesystem watcher commands (S-1 Phase 2) --- + +#[tauri::command] +fn fs_watch_project( + app: tauri::AppHandle, + state: State<'_, AppState>, + project_id: String, + cwd: String, +) -> Result<(), String> { + state.fs_watcher.watch_project(&app, &project_id, &cwd) +} + +#[tauri::command] +fn fs_unwatch_project(state: State<'_, AppState>, project_id: String) { + state.fs_watcher.unwatch_project(&project_id); +} + // --- Session persistence commands --- #[tauri::command] @@ -736,6 +756,8 @@ pub fn run() { file_watch, file_unwatch, file_read, + fs_watch_project, + fs_unwatch_project, session_list, session_save, session_delete, @@ -831,6 +853,7 @@ pub fn run() { ); let file_watcher = Arc::new(FileWatcherManager::new()); + let fs_watcher = Arc::new(ProjectFsWatcher::new()); let ctx_db = Arc::new(CtxDb::new()); let remote_manager = Arc::new(RemoteManager::new()); @@ -845,6 +868,7 @@ pub fn run() { sidecar_manager, session_db, file_watcher, + fs_watcher, ctx_db, remote_manager, _telemetry: telemetry_guard, diff --git a/v2/src/lib/adapters/fs-watcher-bridge.ts b/v2/src/lib/adapters/fs-watcher-bridge.ts new file mode 100644 index 0000000..cccae59 --- /dev/null +++ b/v2/src/lib/adapters/fs-watcher-bridge.ts @@ -0,0 +1,28 @@ +// Filesystem watcher bridge — listens for inotify-based write events from Rust +// Part of S-1 Phase 2: real-time filesystem write detection + +import { invoke } from '@tauri-apps/api/core'; +import { listen, type UnlistenFn } from '@tauri-apps/api/event'; + +export interface FsWriteEvent { + project_id: string; + file_path: string; + timestamp_ms: number; +} + +/** Start watching a project's CWD for filesystem writes */ +export function fsWatchProject(projectId: string, cwd: string): Promise { + return invoke('fs_watch_project', { projectId, cwd }); +} + +/** Stop watching a project's CWD */ +export function fsUnwatchProject(projectId: string): Promise { + return invoke('fs_unwatch_project', { projectId }); +} + +/** Listen for filesystem write events from all watched projects */ +export function onFsWriteDetected( + callback: (event: FsWriteEvent) => void, +): Promise { + return listen('fs-write-detected', (e) => callback(e.payload)); +} diff --git a/v2/src/lib/components/Workspace/ProjectBox.svelte b/v2/src/lib/components/Workspace/ProjectBox.svelte index 2002cb5..57904eb 100644 --- a/v2/src/lib/components/Workspace/ProjectBox.svelte +++ b/v2/src/lib/components/Workspace/ProjectBox.svelte @@ -1,4 +1,5 @@
({project.identifier})
- {#if health && health.fileConflictCount > 0} + {#if health && health.externalConflictCount > 0} + · + {/if} + {#if health && health.fileConflictCount - (health.externalConflictCount ?? 0) > 0} + · {/if} @@ -253,6 +263,15 @@ background: color-mix(in srgb, var(--ctp-red) 25%, transparent); } + .info-conflict-external { + color: var(--ctp-peach); + background: color-mix(in srgb, var(--ctp-peach) 12%, transparent); + } + + .info-conflict-external:hover { + background: color-mix(in srgb, var(--ctp-peach) 25%, transparent); + } + .info-profile { font-size: 0.65rem; color: var(--ctp-blue); diff --git a/v2/src/lib/stores/conflicts.svelte.ts b/v2/src/lib/stores/conflicts.svelte.ts index b0d2fe1..f1cadd7 100644 --- a/v2/src/lib/stores/conflicts.svelte.ts +++ b/v2/src/lib/stores/conflicts.svelte.ts @@ -1,6 +1,10 @@ // File overlap conflict detection — Svelte 5 runes // Tracks which files each agent session writes to per project. // Detects when two or more sessions write to the same file (file overlap conflict). +// Also detects external filesystem writes (S-1 Phase 2) via inotify events. + +/** Sentinel session ID for external (non-agent) writes */ +export const EXTERNAL_SESSION_ID = '__external__'; export interface FileConflict { /** Absolute file path */ @@ -11,6 +15,8 @@ export interface FileConflict { sessionIds: string[]; /** Timestamp of most recent write */ lastWriteTs: number; + /** True if this conflict involves an external (non-agent) writer */ + isExternal: boolean; } export interface ProjectConflicts { @@ -19,6 +25,8 @@ export interface ProjectConflicts { conflicts: FileConflict[]; /** Total conflicting files */ conflictCount: number; + /** Number of files with external write conflicts */ + externalConflictCount: number; } // --- State --- @@ -37,6 +45,13 @@ let acknowledgedFiles = $state>>(new Map()); // sessionId -> worktree path (null = main working tree) let sessionWorktrees = $state>(new Map()); +// projectId -> filePath -> timestamp of most recent agent write (for external write heuristic) +let agentWriteTimestamps = $state>>(new Map()); + +// Time window: if an fs event arrives within this window after an agent tool_call write, +// it's attributed to the agent (suppressed). Otherwise it's external. +const AGENT_WRITE_GRACE_MS = 2000; + // --- Public API --- /** Register the worktree path for a session (null = main working tree) */ @@ -62,6 +77,16 @@ export function recordFileWrite(projectId: string, sessionId: string, filePath: projectFileWrites.set(projectId, projectMap); } + // Track agent write timestamp for external write heuristic + if (sessionId !== EXTERNAL_SESSION_ID) { + let tsMap = agentWriteTimestamps.get(projectId); + if (!tsMap) { + tsMap = new Map(); + agentWriteTimestamps.set(projectId, tsMap); + } + tsMap.set(filePath, Date.now()); + } + let entry = projectMap.get(filePath); const hadConflict = entry ? countRealConflictSessions(entry, sessionId) >= 2 : false; @@ -88,6 +113,47 @@ export function recordFileWrite(projectId: string, sessionId: string, filePath: return isNewConflict; } +/** + * Record an external filesystem write detected via inotify. + * Uses timing heuristic: if an agent wrote this file within AGENT_WRITE_GRACE_MS, + * the write is attributed to the agent and suppressed. + * Returns true if this creates a new external write conflict. + */ +export function recordExternalWrite(projectId: string, filePath: string, timestampMs: number): boolean { + // Timing heuristic: check if any agent recently wrote this file + const tsMap = agentWriteTimestamps.get(projectId); + if (tsMap) { + const lastAgentWrite = tsMap.get(filePath); + if (lastAgentWrite && (timestampMs - lastAgentWrite) < AGENT_WRITE_GRACE_MS) { + // This is likely our agent's write — suppress + return false; + } + } + + // Check if any agent session has written this file (for conflict to be meaningful) + const projectMap = projectFileWrites.get(projectId); + if (!projectMap) return false; // No agent writes at all — not a conflict + const entry = projectMap.get(filePath); + if (!entry || entry.sessionIds.size === 0) return false; // No agent wrote this file + + // Record external write as a conflict + return recordFileWrite(projectId, EXTERNAL_SESSION_ID, filePath); +} + +/** Get the count of external write conflicts for a project */ +export function getExternalConflictCount(projectId: string): number { + const projectMap = projectFileWrites.get(projectId); + if (!projectMap) return 0; + const ackSet = acknowledgedFiles.get(projectId); + let count = 0; + for (const [filePath, entry] of projectMap) { + if (entry.sessionIds.has(EXTERNAL_SESSION_ID) && !(ackSet?.has(filePath))) { + count++; + } + } + return count; +} + /** * Count sessions that are in a real conflict with the given session * (same worktree or both in main tree). Returns total including the session itself. @@ -105,24 +171,28 @@ function countRealConflictSessions(entry: FileWriteEntry, forSessionId: string): /** Get all conflicts for a project (excludes acknowledged and worktree-suppressed) */ export function getProjectConflicts(projectId: string): ProjectConflicts { const projectMap = projectFileWrites.get(projectId); - if (!projectMap) return { projectId, conflicts: [], conflictCount: 0 }; + if (!projectMap) return { projectId, conflicts: [], conflictCount: 0, externalConflictCount: 0 }; const ackSet = acknowledgedFiles.get(projectId); const conflicts: FileConflict[] = []; + let externalConflictCount = 0; for (const [filePath, entry] of projectMap) { if (hasRealConflict(entry) && !(ackSet?.has(filePath))) { + const isExternal = entry.sessionIds.has(EXTERNAL_SESSION_ID); + if (isExternal) externalConflictCount++; conflicts.push({ filePath, shortName: filePath.split('/').pop() ?? filePath, sessionIds: Array.from(entry.sessionIds), lastWriteTs: entry.lastWriteTs, + isExternal, }); } } // Most recent conflicts first conflicts.sort((a, b) => b.lastWriteTs - a.lastWriteTs); - return { projectId, conflicts, conflictCount: conflicts.length }; + return { projectId, conflicts, conflictCount: conflicts.length, externalConflictCount }; } /** Check if a project has any unacknowledged real conflicts */ @@ -200,6 +270,7 @@ export function clearSessionWrites(projectId: string, sessionId: string): void { export function clearProjectConflicts(projectId: string): void { projectFileWrites.delete(projectId); acknowledgedFiles.delete(projectId); + agentWriteTimestamps.delete(projectId); } /** Clear all conflict state */ @@ -207,4 +278,5 @@ export function clearAllConflicts(): void { projectFileWrites = new Map(); acknowledgedFiles = new Map(); sessionWorktrees = new Map(); + agentWriteTimestamps = new Map(); } diff --git a/v2/src/lib/stores/conflicts.test.ts b/v2/src/lib/stores/conflicts.test.ts index ba684cd..9dcb12f 100644 --- a/v2/src/lib/stores/conflicts.test.ts +++ b/v2/src/lib/stores/conflicts.test.ts @@ -1,7 +1,9 @@ import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; import { recordFileWrite, + recordExternalWrite, getProjectConflicts, + getExternalConflictCount, hasConflicts, getTotalConflictCount, clearSessionWrites, @@ -9,6 +11,7 @@ import { clearAllConflicts, acknowledgeConflicts, setSessionWorktree, + EXTERNAL_SESSION_ID, } from './conflicts.svelte'; beforeEach(() => { @@ -235,4 +238,98 @@ describe('conflicts store', () => { expect(hasConflicts('proj-1')).toBe(true); }); }); + + describe('external write detection (S-1 Phase 2)', () => { + it('suppresses external write within grace period after agent write', () => { + vi.useFakeTimers(); + vi.setSystemTime(1000); + recordFileWrite('proj-1', 'sess-a', '/src/main.ts'); + // External write arrives 500ms later — within 2s grace period + vi.setSystemTime(1500); + const result = recordExternalWrite('proj-1', '/src/main.ts', 1500); + expect(result).toBe(false); + expect(getExternalConflictCount('proj-1')).toBe(0); + vi.useRealTimers(); + }); + + it('detects external write outside grace period', () => { + vi.useFakeTimers(); + vi.setSystemTime(1000); + recordFileWrite('proj-1', 'sess-a', '/src/main.ts'); + // External write arrives 3s later — outside 2s grace period + vi.setSystemTime(4000); + const result = recordExternalWrite('proj-1', '/src/main.ts', 4000); + expect(result).toBe(true); + expect(getExternalConflictCount('proj-1')).toBe(1); + vi.useRealTimers(); + }); + + it('ignores external write to file no agent has written', () => { + recordFileWrite('proj-1', 'sess-a', '/src/other.ts'); + const result = recordExternalWrite('proj-1', '/src/unrelated.ts', Date.now()); + expect(result).toBe(false); + }); + + it('ignores external write for project with no agent writes', () => { + const result = recordExternalWrite('proj-1', '/src/main.ts', Date.now()); + expect(result).toBe(false); + }); + + it('marks conflict as external in getProjectConflicts', () => { + vi.useFakeTimers(); + vi.setSystemTime(1000); + recordFileWrite('proj-1', 'sess-a', '/src/main.ts'); + vi.setSystemTime(4000); + recordExternalWrite('proj-1', '/src/main.ts', 4000); + const result = getProjectConflicts('proj-1'); + expect(result.conflictCount).toBe(1); + expect(result.externalConflictCount).toBe(1); + expect(result.conflicts[0].isExternal).toBe(true); + expect(result.conflicts[0].sessionIds).toContain(EXTERNAL_SESSION_ID); + vi.useRealTimers(); + }); + + it('external conflicts can be acknowledged', () => { + vi.useFakeTimers(); + vi.setSystemTime(1000); + recordFileWrite('proj-1', 'sess-a', '/src/main.ts'); + vi.setSystemTime(4000); + recordExternalWrite('proj-1', '/src/main.ts', 4000); + expect(hasConflicts('proj-1')).toBe(true); + acknowledgeConflicts('proj-1'); + expect(hasConflicts('proj-1')).toBe(false); + expect(getExternalConflictCount('proj-1')).toBe(0); + vi.useRealTimers(); + }); + + it('clearAllConflicts clears external write timestamps', () => { + vi.useFakeTimers(); + vi.setSystemTime(1000); + recordFileWrite('proj-1', 'sess-a', '/src/main.ts'); + clearAllConflicts(); + // After clearing, external writes should not create conflicts (no agent writes tracked) + vi.setSystemTime(4000); + const result = recordExternalWrite('proj-1', '/src/main.ts', 4000); + expect(result).toBe(false); + vi.useRealTimers(); + }); + + it('external conflict coexists with agent-agent conflict', () => { + vi.useFakeTimers(); + vi.setSystemTime(1000); + recordFileWrite('proj-1', 'sess-a', '/src/agent.ts'); + recordFileWrite('proj-1', 'sess-b', '/src/agent.ts'); + recordFileWrite('proj-1', 'sess-a', '/src/ext.ts'); + vi.setSystemTime(4000); + recordExternalWrite('proj-1', '/src/ext.ts', 4000); + const result = getProjectConflicts('proj-1'); + expect(result.conflictCount).toBe(2); + expect(result.externalConflictCount).toBe(1); + const extConflict = result.conflicts.find(c => c.isExternal); + const agentConflict = result.conflicts.find(c => !c.isExternal); + expect(extConflict?.filePath).toBe('/src/ext.ts'); + expect(agentConflict?.filePath).toBe('/src/agent.ts'); + vi.useRealTimers(); + }); + }); }); diff --git a/v2/src/lib/stores/health.svelte.ts b/v2/src/lib/stores/health.svelte.ts index e097171..a8f0f65 100644 --- a/v2/src/lib/stores/health.svelte.ts +++ b/v2/src/lib/stores/health.svelte.ts @@ -23,6 +23,8 @@ export interface ProjectHealth { contextPressure: number | null; /** Number of file conflicts (2+ agents writing same file) */ fileConflictCount: number; + /** Number of external write conflicts (filesystem writes by non-agent processes) */ + externalConflictCount: number; /** Attention urgency score (higher = more urgent, 0 = no attention needed) */ attentionScore: number; /** Human-readable attention reason */ @@ -235,6 +237,7 @@ function computeHealth(tracker: ProjectTracker, now: number): ProjectHealth { // File conflicts const conflicts = getProjectConflicts(tracker.projectId); const fileConflictCount = conflicts.conflictCount; + const externalConflictCount = conflicts.externalConflictCount; // Attention scoring — highest-priority signal wins let attentionScore = 0; @@ -252,7 +255,8 @@ function computeHealth(tracker: ProjectTracker, now: number): ProjectHealth { attentionReason = `Context ${Math.round(contextPressure * 100)}% — near limit`; } else if (fileConflictCount > 0) { attentionScore = SCORE_FILE_CONFLICT; - attentionReason = `${fileConflictCount} file conflict${fileConflictCount > 1 ? 's' : ''} — agents writing same file`; + const extNote = externalConflictCount > 0 ? ` (${externalConflictCount} external)` : ''; + attentionReason = `${fileConflictCount} file conflict${fileConflictCount > 1 ? 's' : ''}${extNote}`; } else if (contextPressure !== null && contextPressure > 0.75) { attentionScore = SCORE_CONTEXT_HIGH; attentionReason = `Context ${Math.round(contextPressure * 100)}%`; @@ -267,6 +271,7 @@ function computeHealth(tracker: ProjectTracker, now: number): ProjectHealth { burnRatePerHour, contextPressure, fileConflictCount, + externalConflictCount, attentionScore, attentionReason, };