Skip to main content

synwire_daemon/
manager.rs

1//! Multi-repo/worktree manager for the Synwire daemon.
2//!
3//! [`RepoManager`] is the central coordinator that tracks active worktrees,
4//! registers new projects by computing their [`WorktreeId`], and evicts idle
5//! entries via an LRU policy when the active set exceeds the configured limit.
6//!
7//! # Thread safety
8//!
9//! All public types are `Send + Sync`.  Interior state is protected by
10//! [`tokio::sync::RwLock`] so that concurrent tasks can safely register,
11//! access, and evict worktrees without blocking one another for reads.
12
13use std::collections::HashMap;
14use std::path::{Path, PathBuf};
15use std::sync::Arc;
16
17use synwire_storage::{ProjectRegistry, StorageError, StorageLayout, WorktreeId};
18use tokio::sync::RwLock;
19use tokio::time::Instant;
20use tracing::{debug, info, warn};
21
22// ---------------------------------------------------------------------------
23// Error
24// ---------------------------------------------------------------------------
25
26/// Errors produced by the [`RepoManager`].
27#[derive(Debug, thiserror::Error)]
28#[non_exhaustive]
29pub enum ManagerError {
30    /// An error from the underlying storage layer.
31    #[error("storage error: {0}")]
32    Storage(#[from] StorageError),
33
34    /// The requested worktree was not found in the active set.
35    #[error("worktree not found: {0}")]
36    NotFound(String),
37
38    /// A worktree with this identity is already registered.
39    #[error("worktree already registered: {0}")]
40    AlreadyRegistered(String),
41}
42
43// ---------------------------------------------------------------------------
44// WorktreeStatus
45// ---------------------------------------------------------------------------
46
47/// Runtime status of a managed worktree.
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49#[non_exhaustive]
50pub enum WorktreeStatus {
51    /// The worktree is registered but not currently being indexed.
52    Idle,
53    /// An indexing pipeline is running for this worktree.
54    Indexing,
55    /// Indexing has completed and the worktree is ready for queries.
56    Ready,
57}
58
59// ---------------------------------------------------------------------------
60// WorktreeHandle
61// ---------------------------------------------------------------------------
62
63/// Per-worktree state tracked by the [`RepoManager`].
64#[derive(Debug, Clone)]
65pub struct WorktreeHandle {
66    /// Stable identity for this worktree.
67    pub worktree_id: WorktreeId,
68    /// Canonical root path of the worktree on disk.
69    pub root_path: PathBuf,
70    /// Monotonic timestamp of the last access (used for LRU eviction).
71    pub last_accessed: Instant,
72    /// Current operational status.
73    pub status: WorktreeStatus,
74}
75
76// ---------------------------------------------------------------------------
77// RepoManager
78// ---------------------------------------------------------------------------
79
80/// Manages the set of active repositories and worktrees for the daemon.
81///
82/// The manager keeps at most `max_active` worktrees in the active set.  When
83/// this limit is exceeded, [`evict_idle`](Self::evict_idle) removes the
84/// least-recently-used entries.
85pub struct RepoManager {
86    /// Storage layout for computing paths and persisting the registry.
87    layout: StorageLayout,
88    /// Persistent global project registry (shared across daemon restarts).
89    registry: Arc<RwLock<ProjectRegistry>>,
90    /// Currently active worktrees, keyed by [`WorktreeId::key`].
91    active_worktrees: Arc<RwLock<HashMap<String, WorktreeHandle>>>,
92    /// Maximum number of worktrees to keep active simultaneously.
93    max_active: usize,
94}
95
96impl RepoManager {
97    /// Create a new `RepoManager`.
98    ///
99    /// `max_active` controls the upper bound on concurrently tracked worktrees.
100    /// The persistent [`ProjectRegistry`] is loaded from the storage layout; if
101    /// the registry file does not yet exist an empty registry is used.
102    pub fn new(layout: StorageLayout, max_active: usize) -> Result<Self, ManagerError> {
103        let registry = ProjectRegistry::load(&layout)?;
104        Ok(Self {
105            layout,
106            registry: Arc::new(RwLock::new(registry)),
107            active_worktrees: Arc::new(RwLock::new(HashMap::new())),
108            max_active,
109        })
110    }
111
112    /// Register a worktree by its root path.
113    ///
114    /// Computes the [`WorktreeId`] from the path, adds the worktree to the
115    /// persistent registry, and inserts it into the active set with
116    /// [`WorktreeStatus::Idle`].
117    ///
118    /// Returns the computed `WorktreeId` on success.
119    ///
120    /// # Errors
121    ///
122    /// - [`ManagerError::AlreadyRegistered`] if the worktree is already active.
123    /// - [`ManagerError::Storage`] if the `WorktreeId` cannot be computed or the
124    ///   registry cannot be persisted.
125    pub async fn register(&self, root_path: &Path) -> Result<WorktreeId, ManagerError> {
126        let wid = WorktreeId::for_path(root_path)?;
127        let key = wid.key();
128
129        // Check the active set first (read lock).
130        {
131            let active = self.active_worktrees.read().await;
132            if active.contains_key(&key) {
133                return Err(ManagerError::AlreadyRegistered(key));
134            }
135        }
136
137        // Persist to the global registry.
138        {
139            let mut reg = self.registry.write().await;
140            reg.upsert(&wid, root_path);
141            if let Err(e) = reg.save(&self.layout) {
142                warn!(key = %key, "failed to persist registry after upsert: {e}");
143                // Non-fatal: the in-memory state is still consistent.
144            }
145        }
146
147        // Insert into the active set.
148        let canonical = root_path.canonicalize().map_err(StorageError::from)?;
149        let handle = WorktreeHandle {
150            worktree_id: wid.clone(),
151            root_path: canonical,
152            last_accessed: Instant::now(),
153            status: WorktreeStatus::Idle,
154        };
155
156        {
157            let mut active = self.active_worktrees.write().await;
158            let _ = active.insert(key.clone(), handle);
159        }
160
161        info!(key = %key, "worktree registered");
162        Ok(wid)
163    }
164
165    /// Retrieve a clone of the [`WorktreeHandle`] for the given identity.
166    ///
167    /// Returns `None` if the worktree is not in the active set.
168    pub async fn get(&self, worktree_id: &WorktreeId) -> Option<WorktreeHandle> {
169        let active = self.active_worktrees.read().await;
170        active.get(&worktree_id.key()).cloned()
171    }
172
173    /// Update the `last_accessed` timestamp for an active worktree, keeping it
174    /// alive during LRU eviction.
175    pub async fn touch(&self, worktree_id: &WorktreeId) {
176        {
177            let mut active = self.active_worktrees.write().await;
178            if let Some(handle) = active.get_mut(&worktree_id.key()) {
179                handle.last_accessed = Instant::now();
180                debug!(key = %worktree_id.key(), "worktree touched");
181            }
182        }
183
184        // Also update the persistent registry timestamp.
185        let mut reg = self.registry.write().await;
186        reg.touch(worktree_id);
187        // Best-effort persist — failure is non-fatal.
188        if let Err(e) = reg.save(&self.layout) {
189            warn!(key = %worktree_id.key(), "failed to persist registry after touch: {e}");
190        }
191        drop(reg);
192    }
193
194    /// List all active worktree handles.
195    ///
196    /// The returned vector is in no particular order.
197    pub async fn list_active(&self) -> Vec<WorktreeHandle> {
198        let active = self.active_worktrees.read().await;
199        active.values().cloned().collect()
200    }
201
202    /// Evict the least-recently-used worktrees when the active set exceeds
203    /// `max_active`.
204    ///
205    /// Only worktrees with [`WorktreeStatus::Idle`] or [`WorktreeStatus::Ready`]
206    /// are eligible for eviction; actively indexing worktrees are skipped.
207    ///
208    /// Returns the list of evicted [`WorktreeId`]s.
209    pub async fn evict_idle(&self) -> Vec<WorktreeId> {
210        let mut active = self.active_worktrees.write().await;
211
212        if active.len() <= self.max_active {
213            return Vec::new();
214        }
215
216        let to_evict = active.len() - self.max_active;
217
218        // Collect eligible entries and sort by last_accessed ascending (oldest first).
219        let mut candidates: Vec<(String, Instant)> = active
220            .iter()
221            .filter(|(_, h)| h.status != WorktreeStatus::Indexing)
222            .map(|(k, h)| (k.clone(), h.last_accessed))
223            .collect();
224        candidates.sort_by_key(|(_k, t)| *t);
225
226        let mut evicted = Vec::with_capacity(to_evict);
227        for (key, _) in candidates.into_iter().take(to_evict) {
228            if let Some(handle) = active.remove(&key) {
229                info!(key = %key, "evicting idle worktree");
230                evicted.push(handle.worktree_id);
231            }
232        }
233
234        evicted
235    }
236
237    /// Remove a worktree from the active set and the persistent registry.
238    ///
239    /// Returns `true` if the worktree was present and removed, `false`
240    /// otherwise.
241    pub async fn unregister(&self, worktree_id: &WorktreeId) -> bool {
242        let key = worktree_id.key();
243        let removed = {
244            let mut active = self.active_worktrees.write().await;
245            active.remove(&key).is_some()
246        };
247
248        if removed {
249            let mut reg = self.registry.write().await;
250            reg.remove(worktree_id);
251            if let Err(e) = reg.save(&self.layout) {
252                warn!(key = %key, "failed to persist registry after unregister: {e}");
253            }
254            drop(reg);
255            info!(key = %key, "worktree unregistered");
256        }
257
258        removed
259    }
260
261    /// Update the [`WorktreeStatus`] for an active worktree.
262    ///
263    /// # Errors
264    ///
265    /// Returns [`ManagerError::NotFound`] if the worktree is not in the active
266    /// set.
267    pub async fn set_status(
268        &self,
269        worktree_id: &WorktreeId,
270        status: WorktreeStatus,
271    ) -> Result<(), ManagerError> {
272        let key = worktree_id.key();
273        self.active_worktrees
274            .write()
275            .await
276            .get_mut(&key)
277            .ok_or_else(|| ManagerError::NotFound(key.clone()))?
278            .status = status;
279        debug!(key = %key, ?status, "worktree status updated");
280        Ok(())
281    }
282
283    /// Return the number of currently active worktrees.
284    pub async fn active_count(&self) -> usize {
285        self.active_worktrees.read().await.len()
286    }
287
288    /// Return the configured maximum number of active worktrees.
289    #[must_use]
290    pub const fn max_active(&self) -> usize {
291        self.max_active
292    }
293}
294
295#[cfg(test)]
296#[allow(clippy::unwrap_used, clippy::expect_used)]
297mod tests {
298    use super::*;
299    use synwire_storage::identity::RepoId;
300    use tempfile::tempdir;
301
302    fn test_layout() -> (StorageLayout, tempfile::TempDir) {
303        let dir = tempdir().expect("tempdir");
304        let layout = StorageLayout::with_root(dir.path(), "synwire");
305        (layout, dir)
306    }
307
308    fn dummy_worktree(name: &str) -> WorktreeId {
309        WorktreeId::from_parts(
310            RepoId::from_string(format!("repo-{name}")),
311            format!("{name}hash000000"),
312            format!("{name}@main"),
313        )
314    }
315
316    #[tokio::test]
317    async fn new_manager_starts_empty() {
318        let (layout, _dir) = test_layout();
319        let mgr = RepoManager::new(layout, 10).expect("new");
320        assert_eq!(mgr.active_count().await, 0);
321        assert_eq!(mgr.max_active(), 10);
322    }
323
324    #[tokio::test]
325    async fn register_and_get_with_real_path() {
326        let (layout, _dir) = test_layout();
327        let mgr = RepoManager::new(layout, 10).expect("new");
328
329        // Use a real temporary directory so canonicalize works.
330        let worktree_dir = tempdir().expect("worktree_dir");
331        let wid = mgr.register(worktree_dir.path()).await.expect("register");
332
333        let handle = mgr.get(&wid).await.expect("get returned None");
334        assert_eq!(handle.worktree_id, wid);
335        assert_eq!(handle.status, WorktreeStatus::Idle);
336    }
337
338    #[tokio::test]
339    async fn double_register_is_error() {
340        let (layout, _dir) = test_layout();
341        let mgr = RepoManager::new(layout, 10).expect("new");
342
343        let worktree_dir = tempdir().expect("worktree_dir");
344        let _wid = mgr.register(worktree_dir.path()).await.expect("register");
345        let err = mgr.register(worktree_dir.path()).await.unwrap_err();
346        assert!(matches!(err, ManagerError::AlreadyRegistered(_)));
347    }
348
349    #[tokio::test]
350    async fn unregister_removes_worktree() {
351        let (layout, _dir) = test_layout();
352        let mgr = RepoManager::new(layout, 10).expect("new");
353
354        let worktree_dir = tempdir().expect("worktree_dir");
355        let wid = mgr.register(worktree_dir.path()).await.expect("register");
356        assert!(mgr.unregister(&wid).await);
357        assert!(mgr.get(&wid).await.is_none());
358        // Unregistering again returns false.
359        assert!(!mgr.unregister(&wid).await);
360    }
361
362    #[tokio::test]
363    async fn evict_idle_respects_max_active() {
364        let (layout, _dir) = test_layout();
365        let mgr = RepoManager::new(layout, 2).expect("new");
366
367        // Insert three worktrees directly into the active set.
368        let ids: Vec<WorktreeId> = (0..3).map(|i| dummy_worktree(&format!("w{i}"))).collect();
369        {
370            let mut active = mgr.active_worktrees.write().await;
371            for (i, wid) in ids.iter().enumerate() {
372                let _ = active.insert(
373                    wid.key(),
374                    WorktreeHandle {
375                        worktree_id: wid.clone(),
376                        root_path: PathBuf::from(format!("/tmp/w{i}")),
377                        last_accessed: Instant::now()
378                            - std::time::Duration::from_secs((3 - i as u64) * 10),
379                        status: WorktreeStatus::Idle,
380                    },
381                );
382            }
383            drop(active);
384        }
385
386        assert_eq!(mgr.active_count().await, 3);
387        let evicted = mgr.evict_idle().await;
388        assert_eq!(evicted.len(), 1);
389        assert_eq!(mgr.active_count().await, 2);
390        // The oldest entry should have been evicted.
391        assert_eq!(evicted[0].key(), ids[0].key());
392    }
393
394    #[tokio::test]
395    async fn evict_skips_indexing_worktrees() {
396        let (layout, _dir) = test_layout();
397        let mgr = RepoManager::new(layout, 1).expect("new");
398
399        let idle_wid = dummy_worktree("idle");
400        let indexing_wid = dummy_worktree("indexing");
401
402        {
403            let mut active = mgr.active_worktrees.write().await;
404            let _ = active.insert(
405                idle_wid.key(),
406                WorktreeHandle {
407                    worktree_id: idle_wid.clone(),
408                    root_path: PathBuf::from("/tmp/idle"),
409                    // The idle entry is newer (more recently accessed).
410                    last_accessed: Instant::now(),
411                    status: WorktreeStatus::Idle,
412                },
413            );
414            let _ = active.insert(
415                indexing_wid.key(),
416                WorktreeHandle {
417                    worktree_id: indexing_wid.clone(),
418                    root_path: PathBuf::from("/tmp/indexing"),
419                    // The indexing entry is oldest but should be skipped.
420                    last_accessed: Instant::now() - std::time::Duration::from_secs(100),
421                    status: WorktreeStatus::Indexing,
422                },
423            );
424        }
425
426        let evicted = mgr.evict_idle().await;
427        // Only the idle entry can be evicted.
428        assert_eq!(evicted.len(), 1);
429        assert_eq!(evicted[0].key(), idle_wid.key());
430    }
431
432    #[tokio::test]
433    async fn list_active_returns_all() {
434        let (layout, _dir) = test_layout();
435        let mgr = RepoManager::new(layout, 10).expect("new");
436
437        let wid_a = dummy_worktree("a");
438        let wid_b = dummy_worktree("b");
439        {
440            let mut active = mgr.active_worktrees.write().await;
441            for wid in [&wid_a, &wid_b] {
442                let _ = active.insert(
443                    wid.key(),
444                    WorktreeHandle {
445                        worktree_id: wid.clone(),
446                        root_path: PathBuf::from("/tmp"),
447                        last_accessed: Instant::now(),
448                        status: WorktreeStatus::Ready,
449                    },
450                );
451            }
452        }
453
454        let listed = mgr.list_active().await;
455        assert_eq!(listed.len(), 2);
456    }
457
458    #[tokio::test]
459    async fn set_status_updates_handle() {
460        let (layout, _dir) = test_layout();
461        let mgr = RepoManager::new(layout, 10).expect("new");
462
463        let wid = dummy_worktree("s");
464        {
465            let mut active = mgr.active_worktrees.write().await;
466            let _ = active.insert(
467                wid.key(),
468                WorktreeHandle {
469                    worktree_id: wid.clone(),
470                    root_path: PathBuf::from("/tmp"),
471                    last_accessed: Instant::now(),
472                    status: WorktreeStatus::Idle,
473                },
474            );
475        }
476
477        mgr.set_status(&wid, WorktreeStatus::Indexing)
478            .await
479            .expect("set_status");
480        let handle = mgr.get(&wid).await.expect("get");
481        assert_eq!(handle.status, WorktreeStatus::Indexing);
482    }
483
484    #[tokio::test]
485    async fn set_status_not_found() {
486        let (layout, _dir) = test_layout();
487        let mgr = RepoManager::new(layout, 10).expect("new");
488
489        let wid = dummy_worktree("missing");
490        let err = mgr
491            .set_status(&wid, WorktreeStatus::Ready)
492            .await
493            .unwrap_err();
494        assert!(matches!(err, ManagerError::NotFound(_)));
495    }
496
497    #[tokio::test]
498    async fn no_eviction_when_under_limit() {
499        let (layout, _dir) = test_layout();
500        let mgr = RepoManager::new(layout, 10).expect("new");
501
502        let wid = dummy_worktree("only");
503        {
504            let mut active = mgr.active_worktrees.write().await;
505            let _ = active.insert(
506                wid.key(),
507                WorktreeHandle {
508                    worktree_id: wid.clone(),
509                    root_path: PathBuf::from("/tmp"),
510                    last_accessed: Instant::now(),
511                    status: WorktreeStatus::Idle,
512                },
513            );
514        }
515
516        let evicted = mgr.evict_idle().await;
517        assert!(evicted.is_empty());
518        assert_eq!(mgr.active_count().await, 1);
519    }
520}