Skip to main content

a3s_code_core/workspace/
manifest.rs

1//! Manifest-backed local workspace services.
2//!
3//! The manifest is an in-memory index of workspace files. It is built
4//! asynchronously, refreshed from filesystem notifications, and used by the
5//! local search backend (`glob`/`grep`) to avoid walking the filesystem for
6//! every agent tool call. File I/O, command execution, and git operations still
7//! delegate to [`LocalWorkspaceBackend`].
8
9use super::retrieval::{
10    ChunkCatalogLimits, ChunkingConfig, LocalWorkspaceCatalogRuntime, WorkspaceChunkCatalog,
11    WorkspaceChunkingStrategy, WorkspaceIndexError, WorkspaceLexicalEngine,
12    WorkspacePersistentIndex,
13};
14use super::{
15    escape_control_chars_for_display, validate_relative_pattern, CommandOutput, CommandRequest,
16    LocalWorkspaceAccessPolicy, LocalWorkspaceBackend, WorkspaceCommandRunner, WorkspaceDirEntry,
17    WorkspaceFileSystem, WorkspaceGit, WorkspaceGitBranch, WorkspaceGitCheckoutOutput,
18    WorkspaceGitCheckoutRequest, WorkspaceGitCommit, WorkspaceGitCreateBranchRequest,
19    WorkspaceGitCreateWorktreeRequest, WorkspaceGitDiffRequest, WorkspaceGitRemote,
20    WorkspaceGitRemoveWorktreeRequest, WorkspaceGitStash, WorkspaceGitStashProvider,
21    WorkspaceGitStashRequest, WorkspaceGitStatus, WorkspaceGitWorktree,
22    WorkspaceGitWorktreeMutation, WorkspaceGitWorktreeProvider, WorkspaceGlobRequest,
23    WorkspaceGlobResult, WorkspaceGrepOutcome, WorkspaceGrepRequest, WorkspaceGrepResult,
24    WorkspacePath, WorkspacePathResolver, WorkspaceResult, WorkspaceSearch, WorkspaceTextRange,
25    WorkspaceTextReader, WorkspaceWriteOutcome,
26};
27use anyhow::{anyhow, Result};
28use async_trait::async_trait;
29use std::collections::{hash_map::DefaultHasher, HashMap, HashSet};
30use std::hash::{Hash, Hasher};
31use std::path::{Component, Path, PathBuf};
32use std::sync::{
33    atomic::{AtomicBool, Ordering},
34    Arc, OnceLock, RwLock,
35};
36#[cfg(test)]
37use std::time::Duration;
38use std::time::{SystemTime, UNIX_EPOCH};
39use tokio::sync::{broadcast, watch};
40
41mod file_kind;
42mod scanner;
43mod watcher;
44use scanner::is_relevant_event;
45pub use scanner::scan_workspace_files;
46use watcher::run_manifest_task;
47
48const SNAPSHOT_CHANNEL_CAPACITY: usize = 16;
49const FILE_CHANGE_CHANNEL_CAPACITY: usize = 256;
50const RECENT_FILE_LIMIT: usize = 128;
51const RECENT_DECAY_HALF_LIFE_MS: f32 = 10.0 * 60.0 * 1000.0;
52const RECENT_FREQUENCY_NORMALIZER: f32 = 16.0;
53const RECENT_RECENCY_WEIGHT: f32 = 0.75;
54
55/// Git/workspace status for a file in the manifest.
56#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
57pub enum LocalWorkspaceFileStatus {
58    Tracked,
59    Untracked,
60    Unknown,
61}
62
63/// One manifest entry.
64#[derive(Clone, Debug, Eq, PartialEq, Hash)]
65pub struct LocalWorkspaceFile {
66    pub path: String,
67    pub size: u64,
68    pub modified_ms: Option<u64>,
69    pub language: Option<String>,
70    pub status: LocalWorkspaceFileStatus,
71    pub binary: bool,
72    pub generated: bool,
73}
74
75/// Recency/usage score for a workspace file the user or agent touched.
76///
77/// Hosts should treat this as a ranking hint, not as an authoritative file
78/// list. The manifest filters deleted files when exposing recent entries.
79#[derive(Clone, Debug, PartialEq)]
80pub struct RecentWorkspaceFile {
81    pub path: String,
82    pub score: f32,
83    pub touched_at_ms: u64,
84    pub touch_count: u32,
85}
86
87/// Immutable manifest snapshot.
88#[derive(Clone, Debug, Eq, PartialEq)]
89pub struct LocalWorkspaceManifestSnapshot {
90    pub version: u64,
91    pub root: PathBuf,
92    pub files: Vec<LocalWorkspaceFile>,
93    pub scanned_at_ms: u64,
94}
95
96/// The normalized kind of a workspace file change.
97#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
98pub enum WorkspaceFileChangeKind {
99    Created,
100    Changed,
101    Deleted,
102}
103
104/// A filesystem change for one normalized workspace-relative file path.
105#[derive(Clone, Debug, Eq, PartialEq, Hash)]
106pub struct WorkspaceFileChange {
107    pub path: WorkspacePath,
108    pub kind: WorkspaceFileChangeKind,
109}
110
111impl LocalWorkspaceManifestSnapshot {
112    pub fn empty(root: PathBuf) -> Self {
113        Self {
114            version: 0,
115            root,
116            files: Vec::new(),
117            scanned_at_ms: now_ms(),
118        }
119    }
120
121    pub fn file_paths(&self) -> Vec<String> {
122        self.files.iter().map(|file| file.path.clone()).collect()
123    }
124}
125
126/// Shared in-memory workspace manifest.
127pub struct LocalWorkspaceManifest {
128    state: Arc<RwLock<ManifestState>>,
129    recent: Arc<RwLock<RecentFiles>>,
130    snapshots: broadcast::Sender<LocalWorkspaceManifestSnapshot>,
131    changes: broadcast::Sender<WorkspaceFileChange>,
132    activation: watch::Sender<bool>,
133    scan_cancelled: Arc<AtomicBool>,
134    task: tokio::task::JoinHandle<()>,
135}
136
137impl LocalWorkspaceManifest {
138    /// Start the manifest scanner/watcher for `root`.
139    pub fn start(root: impl Into<PathBuf>) -> Arc<Self> {
140        Self::start_with_activation(root, true)
141    }
142
143    /// Create an empty manifest whose scanner and watcher start only after
144    /// [`Self::activate`] is called.
145    ///
146    /// Search-capable hosts can use this during a latency-sensitive bootstrap:
147    /// manifest-backed operations retain their local filesystem fallback while
148    /// the initial snapshot is empty, so deferring discovery does not make the
149    /// workspace inaccessible.
150    pub fn start_deferred(root: impl Into<PathBuf>) -> Arc<Self> {
151        Self::start_with_activation(root, false)
152    }
153
154    fn start_with_activation(root: impl Into<PathBuf>, active: bool) -> Arc<Self> {
155        let root = root.into();
156        let root = root.canonicalize().unwrap_or_else(|_| root.clone());
157        let initial = LocalWorkspaceManifestSnapshot::empty(root.clone());
158        let state = Arc::new(RwLock::new(ManifestState {
159            fingerprint: fingerprint_files(&initial.files),
160            index: Arc::new(ManifestIndex::build(&initial.files)),
161            snapshot: Arc::new(initial),
162        }));
163        let recent = Arc::new(RwLock::new(RecentFiles::default()));
164        let (snapshots, _) = broadcast::channel(SNAPSHOT_CHANNEL_CAPACITY);
165        let (changes, _) = broadcast::channel(FILE_CHANGE_CHANNEL_CAPACITY);
166        let (activation, mut activation_rx) = watch::channel(active);
167        let scan_cancelled = Arc::new(AtomicBool::new(false));
168        let task_state = Arc::clone(&state);
169        let task_snapshots = snapshots.clone();
170        let task_changes = changes.clone();
171        let task_scan_cancelled = Arc::clone(&scan_cancelled);
172        let task = tokio::spawn(async move {
173            if !*activation_rx.borrow() && activation_rx.wait_for(|active| *active).await.is_err() {
174                return;
175            }
176            if task_scan_cancelled.load(Ordering::Acquire) {
177                return;
178            }
179            run_manifest_task(
180                root,
181                task_state,
182                task_snapshots,
183                task_changes,
184                task_scan_cancelled,
185            )
186            .await;
187        });
188        Arc::new(Self {
189            state,
190            recent,
191            snapshots,
192            changes,
193            activation,
194            scan_cancelled,
195            task,
196        })
197    }
198
199    /// Open a deferred manifest's one-way startup gate.
200    ///
201    /// Returns `true` only for the call that transitions the manifest from
202    /// deferred to active. Calling this on an eagerly started or already active
203    /// manifest is harmless and returns `false`.
204    pub fn activate(&self) -> bool {
205        self.activation.send_if_modified(|active| {
206            if *active {
207                false
208            } else {
209                *active = true;
210                true
211            }
212        })
213    }
214
215    /// Whether discovery has been activated for this manifest.
216    pub fn is_active(&self) -> bool {
217        *self.activation.borrow()
218    }
219
220    pub fn snapshot(&self) -> LocalWorkspaceManifestSnapshot {
221        self.state
222            .read()
223            .map(|state| (*state.snapshot).clone())
224            .unwrap_or_else(|_| LocalWorkspaceManifestSnapshot::empty(PathBuf::new()))
225    }
226
227    pub fn subscribe(&self) -> broadcast::Receiver<LocalWorkspaceManifestSnapshot> {
228        self.snapshots.subscribe()
229    }
230
231    /// Subscribe to debounced, workspace-relative filesystem changes.
232    pub fn subscribe_changes(&self) -> broadcast::Receiver<WorkspaceFileChange> {
233        self.changes.subscribe()
234    }
235
236    /// Stop background discovery without waiting for an in-flight synchronous scan.
237    ///
238    /// Hosts with an explicit lifecycle should call this before shutting down
239    /// their Tokio runtime. [`Drop`] is only a fallback because other background
240    /// services may retain an `Arc` to the manifest until runtime teardown.
241    pub fn shutdown(&self) {
242        self.scan_cancelled.store(true, Ordering::Release);
243        self.task.abort();
244    }
245
246    /// Record that a workspace-relative file was opened, read, or written.
247    ///
248    /// This intentionally does not require the initial manifest scan to have
249    /// completed. The public recent-file views filter against the current
250    /// manifest index, so early touches become visible after the file is indexed
251    /// and deleted files disappear automatically.
252    pub fn touch_file(&self, path: impl AsRef<str>) -> bool {
253        let Some(path) = normalize_recent_file_path(path.as_ref()) else {
254            return false;
255        };
256        let Ok(mut recent) = self.recent.write() else {
257            return false;
258        };
259        recent.touch(path, now_ms());
260        true
261    }
262
263    /// Return the hottest known files, newest/frequently used first.
264    pub fn recent_file_entries(&self, limit: usize) -> Vec<RecentWorkspaceFile> {
265        if limit == 0 {
266            return Vec::new();
267        }
268        let Some(index) = self.state.read().ok().map(|state| Arc::clone(&state.index)) else {
269            return Vec::new();
270        };
271        self.recent
272            .read()
273            .map(|recent| recent.entries(Some(&index), limit, now_ms()))
274            .unwrap_or_default()
275    }
276
277    /// Return recent file paths only, preserving hot-file order.
278    pub fn recent_file_paths(&self, limit: usize) -> Vec<String> {
279        self.recent_file_entries(limit)
280            .into_iter()
281            .map(|entry| entry.path)
282            .collect()
283    }
284}
285
286impl Drop for LocalWorkspaceManifest {
287    fn drop(&mut self) {
288        // Aborting the async owner does not stop synchronous discovery that has
289        // already begun. Signal the scanner first so a detached traversal stops
290        // consuming filesystem resources after its host has gone away.
291        self.shutdown();
292    }
293}
294
295struct ManifestState {
296    fingerprint: u64,
297    index: Arc<ManifestIndex>,
298    snapshot: Arc<LocalWorkspaceManifestSnapshot>,
299}
300
301#[derive(Debug, Default)]
302struct RecentFiles {
303    entries: HashMap<String, RecentFileState>,
304    next_sequence: u64,
305}
306
307impl RecentFiles {
308    fn touch(&mut self, path: String, now: u64) {
309        self.next_sequence = self.next_sequence.saturating_add(1);
310        let sequence = self.next_sequence;
311        self.entries
312            .entry(path.clone())
313            .and_modify(|entry| {
314                entry.touched_at_ms = now;
315                entry.touch_count = entry.touch_count.saturating_add(1);
316                entry.sequence = sequence;
317            })
318            .or_insert(RecentFileState {
319                path,
320                touched_at_ms: now,
321                touch_count: 1,
322                sequence,
323            });
324        self.prune(now);
325    }
326
327    fn entries(
328        &self,
329        index: Option<&ManifestIndex>,
330        limit: usize,
331        now: u64,
332    ) -> Vec<RecentWorkspaceFile> {
333        let mut entries = self
334            .entries
335            .values()
336            .filter(|entry| {
337                index
338                    .map(|index| index.by_path.contains_key(&entry.path))
339                    .unwrap_or(true)
340            })
341            .map(|entry| {
342                let score = recent_score(entry, now);
343                (
344                    entry.sequence,
345                    RecentWorkspaceFile {
346                        path: entry.path.clone(),
347                        score,
348                        touched_at_ms: entry.touched_at_ms,
349                        touch_count: entry.touch_count,
350                    },
351                )
352            })
353            .collect::<Vec<_>>();
354
355        entries.sort_by(|(left_sequence, left), (right_sequence, right)| {
356            right
357                .score
358                .total_cmp(&left.score)
359                .then_with(|| right.touched_at_ms.cmp(&left.touched_at_ms))
360                .then_with(|| right_sequence.cmp(left_sequence))
361                .then_with(|| left.path.cmp(&right.path))
362        });
363        entries
364            .into_iter()
365            .take(limit)
366            .map(|(_, entry)| entry)
367            .collect()
368    }
369
370    fn prune(&mut self, now: u64) {
371        if self.entries.len() <= RECENT_FILE_LIMIT {
372            return;
373        }
374
375        let keep = self
376            .entries
377            .values()
378            .map(|entry| (entry.path.clone(), recent_score(entry, now), entry.sequence))
379            .collect::<Vec<_>>();
380        let mut keep = keep;
381        keep.sort_by(|left, right| {
382            right
383                .1
384                .total_cmp(&left.1)
385                .then_with(|| right.2.cmp(&left.2))
386                .then_with(|| left.0.cmp(&right.0))
387        });
388        let keep = keep
389            .into_iter()
390            .take(RECENT_FILE_LIMIT)
391            .map(|(path, _, _)| path)
392            .collect::<HashSet<_>>();
393        self.entries.retain(|path, _| keep.contains(path));
394    }
395}
396
397#[derive(Debug)]
398struct RecentFileState {
399    path: String,
400    touched_at_ms: u64,
401    touch_count: u32,
402    sequence: u64,
403}
404
405#[derive(Debug, Default)]
406struct ManifestIndex {
407    all: Vec<usize>,
408    by_path: HashMap<String, usize>,
409    by_basename: HashMap<String, Vec<usize>>,
410    by_extension: HashMap<String, Vec<usize>>,
411}
412
413impl ManifestIndex {
414    fn build(files: &[LocalWorkspaceFile]) -> Self {
415        let mut index = Self {
416            all: Vec::with_capacity(files.len()),
417            by_path: HashMap::with_capacity(files.len()),
418            by_basename: HashMap::new(),
419            by_extension: HashMap::new(),
420        };
421
422        for (file_index, file) in files.iter().enumerate() {
423            index.all.push(file_index);
424            index.by_path.insert(file.path.clone(), file_index);
425            if let Some(name) = Path::new(&file.path)
426                .file_name()
427                .and_then(|name| name.to_str())
428            {
429                index
430                    .by_basename
431                    .entry(name.to_string())
432                    .or_default()
433                    .push(file_index);
434            }
435            if let Some(extension) = Path::new(&file.path)
436                .extension()
437                .and_then(|extension| extension.to_str())
438                .filter(|extension| !extension.is_empty())
439            {
440                index
441                    .by_extension
442                    .entry(extension.to_string())
443                    .or_default()
444                    .push(file_index);
445            }
446        }
447
448        index
449    }
450}
451
452struct ManifestSearchSnapshot {
453    snapshot: Arc<LocalWorkspaceManifestSnapshot>,
454    index: Arc<ManifestIndex>,
455}
456
457/// Local backend that uses an in-memory manifest for search.
458pub struct ManifestWorkspaceBackend {
459    local: Arc<LocalWorkspaceBackend>,
460    catalog_local: Arc<LocalWorkspaceBackend>,
461    manifest: Arc<LocalWorkspaceManifest>,
462    catalog_runtime: OnceLock<Arc<LocalWorkspaceCatalogRuntime>>,
463    persistent_index: OnceLock<Arc<WorkspacePersistentIndex>>,
464    owns_manifest: bool,
465}
466
467impl ManifestWorkspaceBackend {
468    pub fn new(root: impl Into<PathBuf>) -> Arc<Self> {
469        Self::new_with_access_policy(root, LocalWorkspaceAccessPolicy::Unrestricted)
470    }
471
472    pub fn new_with_access_policy(
473        root: impl Into<PathBuf>,
474        access_policy: LocalWorkspaceAccessPolicy,
475    ) -> Arc<Self> {
476        Self::new_with_access_policy_and_activation(root, access_policy, true)
477    }
478
479    /// Create a manifest-backed local workspace whose initial discovery is
480    /// explicitly activated through [`LocalWorkspaceManifest::activate`].
481    pub fn new_deferred(root: impl Into<PathBuf>) -> Arc<Self> {
482        Self::new_deferred_with_access_policy(root, LocalWorkspaceAccessPolicy::Unrestricted)
483    }
484
485    /// Create a policy-constrained manifest backend without starting its
486    /// scanner or platform watcher until the manifest is activated.
487    pub fn new_deferred_with_access_policy(
488        root: impl Into<PathBuf>,
489        access_policy: LocalWorkspaceAccessPolicy,
490    ) -> Arc<Self> {
491        Self::new_with_access_policy_and_activation(root, access_policy, false)
492    }
493
494    fn new_with_access_policy_and_activation(
495        root: impl Into<PathBuf>,
496        access_policy: LocalWorkspaceAccessPolicy,
497        active: bool,
498    ) -> Arc<Self> {
499        let root = root.into();
500        let local = Arc::new(LocalWorkspaceBackend::new_with_access_policy(
501            root,
502            access_policy,
503        ));
504        let catalog_local = Arc::new(LocalWorkspaceBackend::new_with_source_egress_policy(
505            local.root.clone(),
506        ));
507        let manifest = if active {
508            LocalWorkspaceManifest::start(local.root.clone())
509        } else {
510            LocalWorkspaceManifest::start_deferred(local.root.clone())
511        };
512        Arc::new(Self {
513            local,
514            catalog_local,
515            manifest,
516            catalog_runtime: OnceLock::new(),
517            persistent_index: OnceLock::new(),
518            owns_manifest: true,
519        })
520    }
521
522    pub fn from_manifest(
523        local: Arc<LocalWorkspaceBackend>,
524        manifest: Arc<LocalWorkspaceManifest>,
525    ) -> Arc<Self> {
526        let catalog_local = Arc::new(LocalWorkspaceBackend::new_with_source_egress_policy(
527            local.root.clone(),
528        ));
529        Arc::new(Self {
530            catalog_local,
531            local,
532            manifest,
533            catalog_runtime: OnceLock::new(),
534            persistent_index: OnceLock::new(),
535            owns_manifest: false,
536        })
537    }
538
539    pub fn manifest(&self) -> Arc<LocalWorkspaceManifest> {
540        Arc::clone(&self.manifest)
541    }
542
543    /// Enable and return a session-local catalog built from the shared manifest.
544    ///
545    /// Catalog reads always use the source-egress boundary independently from
546    /// the access policy selected for ordinary workspace tools. Unless
547    /// [`Self::configure_chunk_catalog`] ran first, this enables the compatible
548    /// default catalog configuration. Native builds also attach the durable
549    /// workspace FTS projection here; failure to create that optional local
550    /// cache degrades to the existing in-memory catalog without failing the
551    /// workspace.
552    pub fn chunk_catalog(&self) -> Arc<WorkspaceChunkCatalog> {
553        self.ensure_default_persistent_index(WorkspaceLexicalEngine::default());
554        self.catalog_runtime
555            .get_or_init(|| {
556                let file_system: Arc<dyn WorkspaceFileSystem> = self.catalog_local.clone();
557                let persistent = self.persistent_index.get().cloned();
558                if let Some(persistent) = persistent {
559                    // The durable corpus index owns native zvec FTS. The
560                    // catalog keeps reporting that selected engine to the
561                    // retrieval contract, but uses portable partitions for
562                    // cold admission instead of opening one native collection
563                    // per source file. The catalog remains a verified
564                    // fallback while the durable generation is rebuilding.
565                    let catalog = WorkspaceChunkCatalog::default_catalog_with_engines(
566                        WorkspaceLexicalEngine::ZvecRust,
567                        WorkspaceLexicalEngine::Portable,
568                    );
569                    LocalWorkspaceCatalogRuntime::start_with_catalog_and_persistent(
570                        Arc::clone(&self.manifest),
571                        file_system,
572                        catalog,
573                        Some(persistent),
574                    )
575                } else {
576                    LocalWorkspaceCatalogRuntime::start(Arc::clone(&self.manifest), file_system)
577                }
578            })
579            .catalog()
580    }
581
582    /// Enable a workspace-owned persistent zvec FTS index before the catalog
583    /// is initialized. The existing manifest watcher remains the sole source
584    /// of updates; this only adds a durable projection of each catalog
585    /// snapshot.
586    pub fn configure_persistent_index(
587        &self,
588        root: impl Into<PathBuf>,
589    ) -> Result<Arc<WorkspacePersistentIndex>, WorkspaceIndexError> {
590        if self.catalog_runtime.get().is_some() {
591            return Err(WorkspaceIndexError::InvalidConfig(
592                "persistent workspace indexing must be configured before the chunk catalog"
593                    .to_owned(),
594            ));
595        }
596        let index = WorkspacePersistentIndex::open(root, WorkspaceLexicalEngine::ZvecRust)?;
597        self.persistent_index.set(Arc::clone(&index)).map_err(|_| {
598            WorkspaceIndexError::InvalidConfig(
599                "persistent workspace index was already configured".to_owned(),
600            )
601        })?;
602        Ok(index)
603    }
604
605    pub fn persistent_index(&self) -> Option<Arc<WorkspacePersistentIndex>> {
606        self.persistent_index.get().cloned()
607    }
608
609    /// Configure and enable the catalog owned by this shared manifest backend.
610    ///
611    /// Hosts that supply [`super::WorkspaceServices`] to a session must call
612    /// this before [`Self::chunk_catalog`] or a retrieval service constructor
613    /// enables the default catalog. Configuration is one-shot so later
614    /// sessions cannot silently replace the strategy or budgets owned by the
615    /// host.
616    pub fn configure_chunk_catalog(
617        &self,
618        strategy: WorkspaceChunkingStrategy,
619        chunking: ChunkingConfig,
620        limits: ChunkCatalogLimits,
621    ) -> Result<Arc<WorkspaceChunkCatalog>, WorkspaceIndexError> {
622        self.configure_chunk_catalog_with_engine(
623            strategy,
624            chunking,
625            limits,
626            WorkspaceLexicalEngine::default(),
627        )
628    }
629
630    /// Configure the catalog with an explicit typed lexical engine.
631    pub fn configure_chunk_catalog_with_engine(
632        &self,
633        strategy: WorkspaceChunkingStrategy,
634        chunking: ChunkingConfig,
635        limits: ChunkCatalogLimits,
636        lexical_engine: WorkspaceLexicalEngine,
637    ) -> Result<Arc<WorkspaceChunkCatalog>, WorkspaceIndexError> {
638        let catalog = WorkspaceChunkCatalog::new_with_strategy_and_engine(
639            strategy,
640            chunking,
641            limits,
642            lexical_engine,
643        )?;
644        self.ensure_default_persistent_index(lexical_engine);
645        let file_system: Arc<dyn WorkspaceFileSystem> = self.catalog_local.clone();
646        let runtime = LocalWorkspaceCatalogRuntime::start_with_catalog_and_persistent(
647            Arc::clone(&self.manifest),
648            file_system,
649            Arc::clone(&catalog),
650            self.persistent_index.get().cloned(),
651        );
652        if let Err(runtime) = self.catalog_runtime.set(runtime) {
653            runtime.shutdown();
654            return Err(WorkspaceIndexError::InvalidConfig(
655                "workspace chunk catalog was already initialized".to_owned(),
656            ));
657        }
658        Ok(catalog)
659    }
660
661    /// Stop the local manifest and any lazily enabled catalog projection.
662    pub(crate) fn shutdown(&self) {
663        if let Some(runtime) = self.catalog_runtime.get() {
664            runtime.shutdown();
665        }
666        self.manifest.shutdown();
667        if let Some(index) = self.persistent_index.get() {
668            index.wait_for_idle();
669        }
670    }
671
672    pub fn local_root(&self) -> &Path {
673        &self.local.root
674    }
675
676    /// Attach the native workspace FTS projection when it is available. This
677    /// is deliberately best-effort: a read-only checkout, a disk quota, or a
678    /// minimal build must retain the portable catalog behavior. The operation
679    /// is idempotent and runs before the shared catalog runtime starts, so all
680    /// callers continue to use one manifest watcher and one catalog authority.
681    fn ensure_default_persistent_index(&self, engine: WorkspaceLexicalEngine) {
682        #[cfg(feature = "zvec-rust-fts")]
683        {
684            if engine != WorkspaceLexicalEngine::ZvecRust
685                || self.persistent_index.get().is_some()
686                || self.catalog_runtime.get().is_some()
687            {
688                return;
689            }
690            let root = self.local.root.join(".a3s-code").join("index");
691            if let Err(error) = self.configure_persistent_index(root) {
692                tracing::debug!(%error, "workspace persistent zvec index unavailable; using session-local lexical catalog");
693            }
694        }
695        #[cfg(not(feature = "zvec-rust-fts"))]
696        {
697            let _ = engine;
698        }
699    }
700
701    fn manifest_ready(&self) -> Option<ManifestSearchSnapshot> {
702        let state = self.manifest.state.read().ok()?;
703        (state.snapshot.version > 0).then(|| ManifestSearchSnapshot {
704            snapshot: Arc::clone(&state.snapshot),
705            index: Arc::clone(&state.index),
706        })
707    }
708
709    fn fallback_search(&self) -> Arc<LocalWorkspaceBackend> {
710        Arc::clone(&self.local)
711    }
712
713    fn recent_path_ranks(&self, index: &ManifestIndex) -> HashMap<String, usize> {
714        self.manifest
715            .recent
716            .read()
717            .map(|recent| {
718                recent
719                    .entries(Some(index), RECENT_FILE_LIMIT, now_ms())
720                    .into_iter()
721                    .enumerate()
722                    .map(|(rank, entry)| (entry.path, rank))
723                    .collect()
724            })
725            .unwrap_or_default()
726    }
727}
728
729impl Drop for ManifestWorkspaceBackend {
730    fn drop(&mut self) {
731        // Direct WorkspaceServices users do not have the AgentSession close
732        // boundary. Stop the shared manifest/catalog owner here so automatic
733        // persistent indexing cannot outlive a temporary workspace or keep a
734        // native zvec operation running during teardown.
735        if self.owns_manifest {
736            self.shutdown();
737        } else {
738            if let Some(runtime) = self.catalog_runtime.get() {
739                runtime.shutdown();
740            }
741            if let Some(index) = self.persistent_index.get() {
742                index.wait_for_idle();
743            }
744        }
745    }
746}
747
748impl WorkspacePathResolver for ManifestWorkspaceBackend {
749    fn normalize(&self, input: &str) -> Result<WorkspacePath> {
750        self.local.normalize(input)
751    }
752}
753
754#[async_trait]
755impl WorkspaceFileSystem for ManifestWorkspaceBackend {
756    async fn read_text(&self, path: &WorkspacePath) -> WorkspaceResult<String> {
757        let content = self.local.read_text(path).await?;
758        self.manifest.touch_file(path.as_str());
759        Ok(content)
760    }
761
762    async fn write_text(
763        &self,
764        path: &WorkspacePath,
765        content: &str,
766    ) -> WorkspaceResult<WorkspaceWriteOutcome> {
767        let outcome = self.local.write_text(path, content).await?;
768        self.manifest.touch_file(path.as_str());
769        Ok(outcome)
770    }
771
772    async fn list_dir(&self, path: &WorkspacePath) -> WorkspaceResult<Vec<WorkspaceDirEntry>> {
773        self.local.list_dir(path).await
774    }
775}
776
777#[async_trait]
778impl WorkspaceTextReader for ManifestWorkspaceBackend {
779    async fn read_text_range(
780        &self,
781        path: &WorkspacePath,
782        offset: usize,
783        limit: usize,
784    ) -> WorkspaceResult<WorkspaceTextRange> {
785        let range = self.local.read_text_range(path, offset, limit).await?;
786        self.manifest.touch_file(path.as_str());
787        Ok(range)
788    }
789}
790
791#[async_trait]
792impl WorkspaceCommandRunner for ManifestWorkspaceBackend {
793    async fn exec(&self, request: CommandRequest) -> Result<CommandOutput> {
794        self.local.exec(request).await
795    }
796}
797
798#[async_trait]
799impl WorkspaceSearch for ManifestWorkspaceBackend {
800    async fn glob(&self, request: WorkspaceGlobRequest) -> Result<WorkspaceGlobResult> {
801        validate_relative_pattern(&request.pattern, "glob pattern")?;
802        let Some(search_snapshot) = self.manifest_ready() else {
803            return self.fallback_search().glob(request).await;
804        };
805        let pattern = glob::Pattern::new(&request.pattern)
806            .map_err(|e| anyhow!("Invalid glob pattern '{}': {}", request.pattern, e))?;
807        let candidates =
808            candidate_indices_for_glob(&search_snapshot.index, &request.base, &request.pattern);
809        let recent_ranks = self.recent_path_ranks(&search_snapshot.index);
810
811        let mut matches = Vec::new();
812        for file_index in
813            recent_first_candidate_indices(&candidates, &search_snapshot.index, &recent_ranks)
814        {
815            let Some(file) = search_snapshot.snapshot.files.get(file_index) else {
816                continue;
817            };
818            let Some(relative_to_base) = relative_to_base(&file.path, &request.base) else {
819                continue;
820            };
821            if glob_matches(&pattern, relative_to_base) {
822                matches.push(WorkspacePath::from_normalized(file.path.clone()));
823            }
824        }
825
826        sort_paths_by_recent(&mut matches, &recent_ranks);
827        Ok(WorkspaceGlobResult { matches })
828    }
829
830    async fn grep(&self, request: WorkspaceGrepRequest) -> Result<WorkspaceGrepResult> {
831        Ok(self.grep_with_sources(request).await?.result)
832    }
833
834    async fn grep_with_sources(
835        &self,
836        request: WorkspaceGrepRequest,
837    ) -> Result<WorkspaceGrepOutcome> {
838        if let Some(ref glob) = request.glob {
839            validate_relative_pattern(glob, "grep glob filter")?;
840        }
841        self.local.ensure_search_base_allowed(&request.base)?;
842        let Some(search_snapshot) = self.manifest_ready() else {
843            return self.fallback_search().grep_with_sources(request).await;
844        };
845
846        let regex_pattern = if request.case_insensitive {
847            format!("(?i){}", request.pattern)
848        } else {
849            request.pattern.clone()
850        };
851        let regex = regex::Regex::new(&regex_pattern)
852            .map_err(|e| anyhow!("Invalid regex pattern '{}': {}", request.pattern, e))?;
853        let glob = request
854            .glob
855            .as_deref()
856            .map(glob::Pattern::new)
857            .transpose()
858            .map_err(|e| anyhow!("Invalid grep glob filter: {e}"))?;
859
860        let mut output = String::new();
861        let mut match_count = 0;
862        let mut file_count = 0;
863        let mut total_size = 0;
864        let mut matched_paths = Vec::new();
865        let metadata_only = request.max_output_size == 0;
866
867        let candidates = request
868            .glob
869            .as_deref()
870            .map(|glob| candidate_indices_for_glob(&search_snapshot.index, &request.base, glob))
871            .unwrap_or_else(|| CandidateIndices::Indexed(&search_snapshot.index.all));
872        let recent_ranks = self.recent_path_ranks(&search_snapshot.index);
873
874        for file_index in
875            recent_first_candidate_indices(&candidates, &search_snapshot.index, &recent_ranks)
876        {
877            let Some(file) = search_snapshot.snapshot.files.get(file_index) else {
878                continue;
879            };
880            if file.binary {
881                continue;
882            }
883            let Some(relative_to_base) = relative_to_base(&file.path, &request.base) else {
884                continue;
885            };
886            if let Some(glob) = &glob {
887                if !glob_matches(glob, relative_to_base) {
888                    continue;
889                }
890            }
891
892            let workspace_path = WorkspacePath::from_normalized(file.path.clone());
893            let Some(content) = self.local.read_search_file(&workspace_path) else {
894                continue;
895            };
896            let lines: Vec<&str> = content.lines().collect();
897            let file_matches = lines
898                .iter()
899                .enumerate()
900                .filter_map(|(line_idx, line)| regex.is_match(line).then_some(line_idx))
901                .collect::<Vec<_>>();
902
903            if file_matches.is_empty() {
904                continue;
905            }
906
907            file_count += 1;
908            let display_path = escape_control_chars_for_display(&file.path);
909            let mut path_recorded = false;
910            for &match_idx in &file_matches {
911                if !metadata_only && total_size > request.max_output_size {
912                    return Ok(WorkspaceGrepOutcome {
913                        result: WorkspaceGrepResult {
914                            output,
915                            match_count,
916                            file_count,
917                            truncated: true,
918                        },
919                        matched_paths: Some(matched_paths),
920                    });
921                }
922
923                if !path_recorded {
924                    matched_paths.push(workspace_path.clone());
925                    path_recorded = true;
926                }
927                match_count += 1;
928                if metadata_only {
929                    continue;
930                }
931                let start = match_idx.saturating_sub(request.context_lines);
932                let end = (match_idx + request.context_lines + 1).min(lines.len());
933
934                for (i, line) in lines[start..end].iter().enumerate() {
935                    let abs_i = start + i;
936                    let prefix = if abs_i == match_idx { ">" } else { " " };
937                    let line = format!("{}{}:{}: {}\n", prefix, display_path, abs_i + 1, line);
938                    total_size += line.len();
939                    output.push_str(&line);
940                }
941
942                if request.context_lines > 0 {
943                    output.push_str("--\n");
944                    total_size += 3;
945                }
946            }
947        }
948
949        Ok(WorkspaceGrepOutcome {
950            result: WorkspaceGrepResult {
951                output,
952                match_count,
953                file_count,
954                truncated: false,
955            },
956            matched_paths: Some(matched_paths),
957        })
958    }
959}
960
961#[async_trait]
962impl WorkspaceGit for ManifestWorkspaceBackend {
963    async fn is_repository(&self) -> Result<bool> {
964        self.local.is_repository().await
965    }
966
967    async fn status(&self) -> Result<WorkspaceGitStatus> {
968        self.local.status().await
969    }
970
971    async fn log(&self, max_count: usize) -> Result<Vec<WorkspaceGitCommit>> {
972        self.local.log(max_count).await
973    }
974
975    async fn list_branches(&self) -> Result<Vec<WorkspaceGitBranch>> {
976        self.local.list_branches().await
977    }
978
979    async fn create_branch(&self, request: WorkspaceGitCreateBranchRequest) -> Result<()> {
980        self.local.create_branch(request).await
981    }
982
983    async fn checkout(
984        &self,
985        request: WorkspaceGitCheckoutRequest,
986    ) -> Result<WorkspaceGitCheckoutOutput> {
987        self.local.checkout(request).await
988    }
989
990    async fn diff(&self, request: WorkspaceGitDiffRequest) -> Result<String> {
991        self.local.diff(request).await
992    }
993
994    async fn list_remotes(&self) -> Result<Vec<WorkspaceGitRemote>> {
995        self.local.list_remotes().await
996    }
997}
998
999#[async_trait]
1000impl WorkspaceGitStashProvider for ManifestWorkspaceBackend {
1001    async fn list_stashes(&self) -> Result<Vec<WorkspaceGitStash>> {
1002        self.local.list_stashes().await
1003    }
1004
1005    async fn stash(&self, request: WorkspaceGitStashRequest) -> Result<()> {
1006        self.local.stash(request).await
1007    }
1008}
1009
1010#[async_trait]
1011impl WorkspaceGitWorktreeProvider for ManifestWorkspaceBackend {
1012    async fn list_worktrees(&self) -> Result<Vec<WorkspaceGitWorktree>> {
1013        self.local.list_worktrees().await
1014    }
1015
1016    async fn create_worktree(
1017        &self,
1018        request: WorkspaceGitCreateWorktreeRequest,
1019    ) -> Result<WorkspaceGitWorktreeMutation> {
1020        self.local.create_worktree(request).await
1021    }
1022
1023    async fn remove_worktree(
1024        &self,
1025        request: WorkspaceGitRemoveWorktreeRequest,
1026    ) -> Result<WorkspaceGitWorktreeMutation> {
1027        self.local.remove_worktree(request).await
1028    }
1029}
1030
1031fn update_state(
1032    state: &Arc<RwLock<ManifestState>>,
1033    files: Vec<LocalWorkspaceFile>,
1034) -> Option<LocalWorkspaceManifestSnapshot> {
1035    let fingerprint = fingerprint_files(&files);
1036    let index = Arc::new(ManifestIndex::build(&files));
1037    let Ok(mut state) = state.write() else {
1038        return None;
1039    };
1040    if state.snapshot.version > 0 && state.fingerprint == fingerprint {
1041        return None;
1042    }
1043    state.fingerprint = fingerprint;
1044    state.index = index;
1045    state.snapshot = Arc::new(LocalWorkspaceManifestSnapshot {
1046        version: state.snapshot.version + 1,
1047        root: state.snapshot.root.clone(),
1048        files,
1049        scanned_at_ms: now_ms(),
1050    });
1051    Some((*state.snapshot).clone())
1052}
1053
1054enum CandidateIndices<'a> {
1055    Indexed(&'a [usize]),
1056    Single(Option<usize>),
1057}
1058
1059impl<'a> CandidateIndices<'a> {
1060    fn iter(&self) -> Box<dyn Iterator<Item = usize> + '_> {
1061        match self {
1062            Self::Indexed(indices) => Box::new(indices.iter().copied()),
1063            Self::Single(Some(index)) => Box::new(std::iter::once(*index)),
1064            Self::Single(None) => Box::new(std::iter::empty()),
1065        }
1066    }
1067
1068    fn len(&self) -> usize {
1069        match self {
1070            Self::Indexed(indices) => indices.len(),
1071            Self::Single(Some(_)) => 1,
1072            Self::Single(None) => 0,
1073        }
1074    }
1075
1076    fn contains(&self, index: usize) -> bool {
1077        match self {
1078            Self::Indexed(indices) => indices.contains(&index),
1079            Self::Single(Some(candidate)) => *candidate == index,
1080            Self::Single(None) => false,
1081        }
1082    }
1083}
1084
1085fn recent_first_candidate_indices(
1086    candidates: &CandidateIndices<'_>,
1087    index: &ManifestIndex,
1088    recent_ranks: &HashMap<String, usize>,
1089) -> Vec<usize> {
1090    if recent_ranks.is_empty() {
1091        return candidates.iter().collect();
1092    }
1093
1094    let mut hot = recent_ranks
1095        .iter()
1096        .filter_map(|(path, rank)| {
1097            let file_index = *index.by_path.get(path)?;
1098            candidates
1099                .contains(file_index)
1100                .then_some((*rank, file_index))
1101        })
1102        .collect::<Vec<_>>();
1103    hot.sort_unstable_by_key(|(rank, _)| *rank);
1104
1105    let mut out = Vec::with_capacity(candidates.len());
1106    let mut seen = HashSet::with_capacity(hot.len());
1107    for (_, file_index) in hot {
1108        if seen.insert(file_index) {
1109            out.push(file_index);
1110        }
1111    }
1112    out.extend(
1113        candidates
1114            .iter()
1115            .filter(|file_index| !seen.contains(file_index)),
1116    );
1117    out
1118}
1119
1120fn sort_paths_by_recent(paths: &mut [WorkspacePath], recent_ranks: &HashMap<String, usize>) {
1121    paths.sort_by(|left, right| {
1122        recent_ranks
1123            .get(left.as_str())
1124            .copied()
1125            .unwrap_or(usize::MAX)
1126            .cmp(
1127                &recent_ranks
1128                    .get(right.as_str())
1129                    .copied()
1130                    .unwrap_or(usize::MAX),
1131            )
1132            .then_with(|| left.as_str().cmp(right.as_str()))
1133    });
1134}
1135
1136fn candidate_indices_for_glob<'a>(
1137    index: &'a ManifestIndex,
1138    base: &WorkspacePath,
1139    pattern: &str,
1140) -> CandidateIndices<'a> {
1141    if !has_glob_meta(pattern) && pattern.contains('/') {
1142        return CandidateIndices::Single(
1143            literal_workspace_path(base, pattern)
1144                .and_then(|path| index.by_path.get(&path).copied()),
1145        );
1146    }
1147
1148    if let Some(name) = literal_terminal_segment(pattern) {
1149        return index
1150            .by_basename
1151            .get(name)
1152            .map(|indices| CandidateIndices::Indexed(indices))
1153            .unwrap_or(CandidateIndices::Single(None));
1154    }
1155
1156    if let Some(extension) = simple_extension_terminal(pattern) {
1157        return index
1158            .by_extension
1159            .get(extension)
1160            .map(|indices| CandidateIndices::Indexed(indices))
1161            .unwrap_or(CandidateIndices::Single(None));
1162    }
1163
1164    CandidateIndices::Indexed(&index.all)
1165}
1166
1167fn literal_workspace_path(base: &WorkspacePath, pattern: &str) -> Option<String> {
1168    let pattern = normalize_relative_path_lossy(Path::new(pattern))?;
1169    if pattern.is_empty() {
1170        return None;
1171    }
1172    if base.is_root() {
1173        Some(pattern)
1174    } else {
1175        Some(format!(
1176            "{}/{}",
1177            base.as_str().trim_end_matches('/'),
1178            pattern
1179        ))
1180    }
1181}
1182
1183fn literal_terminal_segment(pattern: &str) -> Option<&str> {
1184    let terminal = pattern
1185        .trim_end_matches('/')
1186        .rsplit('/')
1187        .next()
1188        .filter(|segment| !segment.is_empty())?;
1189    (!has_glob_meta(terminal)).then_some(terminal)
1190}
1191
1192fn simple_extension_terminal(pattern: &str) -> Option<&str> {
1193    let terminal = pattern.trim_end_matches('/').rsplit('/').next()?;
1194    let extension = terminal.strip_prefix("*.")?;
1195    (!extension.is_empty() && !has_glob_meta(extension)).then_some(extension)
1196}
1197
1198fn has_glob_meta(pattern: &str) -> bool {
1199    pattern
1200        .bytes()
1201        .any(|byte| matches!(byte, b'*' | b'?' | b'[' | b']' | b'{' | b'}'))
1202}
1203
1204fn fingerprint_files(files: &[LocalWorkspaceFile]) -> u64 {
1205    let mut hasher = DefaultHasher::new();
1206    files.hash(&mut hasher);
1207    hasher.finish()
1208}
1209
1210fn recent_score(entry: &RecentFileState, now: u64) -> f32 {
1211    let age_ms = now.saturating_sub(entry.touched_at_ms) as f32;
1212    let recency = (-age_ms / RECENT_DECAY_HALF_LIFE_MS).exp();
1213    let frequency =
1214        ((entry.touch_count as f32) + 1.0).ln() / (RECENT_FREQUENCY_NORMALIZER + 1.0).ln();
1215    RECENT_RECENCY_WEIGHT * recency + (1.0 - RECENT_RECENCY_WEIGHT) * frequency.min(1.0)
1216}
1217
1218fn normalize_recent_file_path(path: &str) -> Option<String> {
1219    let path = path.trim();
1220    if path.is_empty() {
1221        return None;
1222    }
1223    let normalized = normalize_relative_path_lossy(Path::new(path))?;
1224    (!normalized.is_empty()).then_some(normalized)
1225}
1226
1227fn normalize_relative_path_lossy(path: &Path) -> Option<String> {
1228    let mut parts = Vec::new();
1229    for component in path.components() {
1230        match component {
1231            Component::Normal(part) => parts.push(part.to_string_lossy().into_owned()),
1232            Component::CurDir => {}
1233            _ => return None,
1234        }
1235    }
1236    Some(parts.join("/"))
1237}
1238
1239fn relative_to_base<'a>(path: &'a str, base: &WorkspacePath) -> Option<&'a str> {
1240    if base.is_root() {
1241        return Some(path);
1242    }
1243    let base = base.as_str().trim_end_matches('/');
1244    if path == base {
1245        Some("")
1246    } else {
1247        path.strip_prefix(base)
1248            .and_then(|tail| tail.strip_prefix('/'))
1249            .filter(|tail| !tail.is_empty())
1250    }
1251}
1252
1253fn glob_matches(pattern: &glob::Pattern, path: &str) -> bool {
1254    let path = Path::new(path);
1255    pattern.matches_path(path)
1256        || path
1257            .file_name()
1258            .and_then(|name| name.to_str())
1259            .is_some_and(|name| pattern.matches(name))
1260}
1261
1262fn now_ms() -> u64 {
1263    system_time_ms(SystemTime::now())
1264}
1265
1266fn system_time_ms(time: SystemTime) -> u64 {
1267    time.duration_since(UNIX_EPOCH)
1268        .map(|duration| duration.as_millis() as u64)
1269        .unwrap_or_default()
1270}
1271
1272#[cfg(test)]
1273#[path = "manifest/tests.rs"]
1274mod tests;