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.
549    ///
550    /// First principles: enabling the catalog must not open durable native FTS.
551    /// Wire that projection with [`Self::configure_persistent_index`] before
552    /// the catalog starts, or open it on Grep/BM25 demand via
553    /// [`Self::ensure_persistent_index`]. Missing durable cache degrades to the
554    /// portable in-memory catalog without failing the workspace.
555    pub fn chunk_catalog(&self) -> Arc<WorkspaceChunkCatalog> {
556        self.catalog_runtime
557            .get_or_init(|| {
558                let file_system: Arc<dyn WorkspaceFileSystem> = self.catalog_local.clone();
559                let persistent = self.persistent_index.get().cloned();
560                if let Some(persistent) = persistent {
561                    // The durable corpus index owns native zvec FTS. The
562                    // catalog keeps reporting that selected engine to the
563                    // retrieval contract, but uses portable partitions for
564                    // cold admission instead of opening one native collection
565                    // per source file. The catalog remains a verified
566                    // fallback while the durable generation is rebuilding.
567                    let catalog = WorkspaceChunkCatalog::default_catalog_with_engines(
568                        WorkspaceLexicalEngine::ZvecRust,
569                        WorkspaceLexicalEngine::Portable,
570                    );
571                    LocalWorkspaceCatalogRuntime::start_with_catalog_and_persistent(
572                        Arc::clone(&self.manifest),
573                        file_system,
574                        catalog,
575                        Some(persistent),
576                    )
577                } else {
578                    LocalWorkspaceCatalogRuntime::start(Arc::clone(&self.manifest), file_system)
579                }
580            })
581            .catalog()
582    }
583
584    /// Enable a workspace-owned persistent zvec FTS index before the catalog
585    /// is initialized. The existing manifest watcher remains the sole source
586    /// of updates; this only adds a durable projection of each catalog
587    /// snapshot.
588    pub fn configure_persistent_index(
589        &self,
590        root: impl Into<PathBuf>,
591    ) -> Result<Arc<WorkspacePersistentIndex>, WorkspaceIndexError> {
592        if self.catalog_runtime.get().is_some() {
593            return Err(WorkspaceIndexError::InvalidConfig(
594                "persistent workspace indexing must be configured before the chunk catalog"
595                    .to_owned(),
596            ));
597        }
598        let index = WorkspacePersistentIndex::open(root, WorkspaceLexicalEngine::ZvecRust)?;
599        self.persistent_index.set(Arc::clone(&index)).map_err(|_| {
600            WorkspaceIndexError::InvalidConfig(
601                "persistent workspace index was already configured".to_owned(),
602            )
603        })?;
604        Ok(index)
605    }
606
607    /// Already-attached durable FTS handle, if any. Does not open native files.
608    pub fn persistent_index(&self) -> Option<Arc<WorkspacePersistentIndex>> {
609        self.persistent_index.get().cloned()
610    }
611
612    /// Open the workspace durable zvec FTS projection on demand (Grep/BM25).
613    ///
614    /// Safe after the chunk catalog has already started. When the catalog was
615    /// started without a wired coordinator, Grep may still read a warm on-disk
616    /// generation while the portable catalog remains the live admission
617    /// authority and update path.
618    pub fn ensure_persistent_index(&self) -> Option<Arc<WorkspacePersistentIndex>> {
619        if let Some(index) = self.persistent_index.get() {
620            return Some(Arc::clone(index));
621        }
622        self.open_default_persistent_index()
623    }
624
625    /// True after [`Self::configure_chunk_catalog`] or the first
626    /// [`Self::chunk_catalog`] call has started the shared catalog runtime.
627    pub(crate) fn catalog_is_configured(&self) -> bool {
628        self.catalog_runtime.get().is_some()
629    }
630
631    /// Configure and enable the catalog owned by this shared manifest backend.
632    ///
633    /// Hosts that supply [`super::WorkspaceServices`] to a session must call
634    /// this before [`Self::chunk_catalog`] or a retrieval service constructor
635    /// enables the default catalog. Configuration is one-shot so later
636    /// sessions cannot silently replace the strategy or budgets owned by the
637    /// host.
638    pub fn configure_chunk_catalog(
639        &self,
640        strategy: WorkspaceChunkingStrategy,
641        chunking: ChunkingConfig,
642        limits: ChunkCatalogLimits,
643    ) -> Result<Arc<WorkspaceChunkCatalog>, WorkspaceIndexError> {
644        self.configure_chunk_catalog_with_engine(
645            strategy,
646            chunking,
647            limits,
648            WorkspaceLexicalEngine::default(),
649        )
650    }
651
652    /// Configure the catalog with an explicit typed lexical engine.
653    pub fn configure_chunk_catalog_with_engine(
654        &self,
655        strategy: WorkspaceChunkingStrategy,
656        chunking: ChunkingConfig,
657        limits: ChunkCatalogLimits,
658        lexical_engine: WorkspaceLexicalEngine,
659    ) -> Result<Arc<WorkspaceChunkCatalog>, WorkspaceIndexError> {
660        let catalog = WorkspaceChunkCatalog::new_with_strategy_and_engine(
661            strategy,
662            chunking,
663            limits,
664            lexical_engine,
665        )?;
666        // Do not open durable zvec here. Catalog enable is independent of the
667        // optional native FTS projection; see [`Self::ensure_persistent_index`].
668        let file_system: Arc<dyn WorkspaceFileSystem> = self.catalog_local.clone();
669        let runtime = LocalWorkspaceCatalogRuntime::start_with_catalog_and_persistent(
670            Arc::clone(&self.manifest),
671            file_system,
672            Arc::clone(&catalog),
673            self.persistent_index.get().cloned(),
674        );
675        if let Err(runtime) = self.catalog_runtime.set(runtime) {
676            runtime.shutdown();
677            return Err(WorkspaceIndexError::InvalidConfig(
678                "workspace chunk catalog was already initialized".to_owned(),
679            ));
680        }
681        Ok(catalog)
682    }
683
684    /// Stop the local manifest and any lazily enabled catalog projection.
685    pub(crate) fn shutdown(&self) {
686        if let Some(runtime) = self.catalog_runtime.get() {
687            runtime.shutdown();
688        }
689        self.manifest.shutdown();
690        if let Some(index) = self.persistent_index.get() {
691            index.wait_for_idle();
692        }
693    }
694
695    pub fn local_root(&self) -> &Path {
696        &self.local.root
697    }
698
699    /// Best-effort open of the default on-disk durable FTS root. Unlike
700    /// [`Self::configure_persistent_index`], this may run after the catalog
701    /// runtime has started and does not fail the workspace when unavailable.
702    fn open_default_persistent_index(&self) -> Option<Arc<WorkspacePersistentIndex>> {
703        #[cfg(feature = "zvec-rust-fts")]
704        {
705            if let Some(index) = self.persistent_index.get() {
706                return Some(Arc::clone(index));
707            }
708            let root = self.local.root.join(".a3s-code").join("index");
709            match WorkspacePersistentIndex::open(root, WorkspaceLexicalEngine::ZvecRust) {
710                Ok(index) => match self.persistent_index.set(Arc::clone(&index)) {
711                    Ok(()) => Some(index),
712                    Err(_) => self.persistent_index.get().cloned(),
713                },
714                Err(error) => {
715                    tracing::debug!(
716                        %error,
717                        "workspace persistent zvec index unavailable; using session-local lexical catalog"
718                    );
719                    None
720                }
721            }
722        }
723        #[cfg(not(feature = "zvec-rust-fts"))]
724        {
725            None
726        }
727    }
728
729    fn manifest_ready(&self) -> Option<ManifestSearchSnapshot> {
730        let state = self.manifest.state.read().ok()?;
731        (state.snapshot.version > 0).then(|| ManifestSearchSnapshot {
732            snapshot: Arc::clone(&state.snapshot),
733            index: Arc::clone(&state.index),
734        })
735    }
736
737    fn fallback_search(&self) -> Arc<LocalWorkspaceBackend> {
738        Arc::clone(&self.local)
739    }
740
741    fn recent_path_ranks(&self, index: &ManifestIndex) -> HashMap<String, usize> {
742        self.manifest
743            .recent
744            .read()
745            .map(|recent| {
746                recent
747                    .entries(Some(index), RECENT_FILE_LIMIT, now_ms())
748                    .into_iter()
749                    .enumerate()
750                    .map(|(rank, entry)| (entry.path, rank))
751                    .collect()
752            })
753            .unwrap_or_default()
754    }
755}
756
757impl Drop for ManifestWorkspaceBackend {
758    fn drop(&mut self) {
759        // Direct WorkspaceServices users do not have the AgentSession close
760        // boundary. Stop the shared manifest/catalog owner here so automatic
761        // persistent indexing cannot outlive a temporary workspace or keep a
762        // native zvec operation running during teardown.
763        if self.owns_manifest {
764            self.shutdown();
765        } else {
766            if let Some(runtime) = self.catalog_runtime.get() {
767                runtime.shutdown();
768            }
769            if let Some(index) = self.persistent_index.get() {
770                index.wait_for_idle();
771            }
772        }
773    }
774}
775
776impl WorkspacePathResolver for ManifestWorkspaceBackend {
777    fn normalize(&self, input: &str) -> Result<WorkspacePath> {
778        self.local.normalize(input)
779    }
780}
781
782#[async_trait]
783impl WorkspaceFileSystem for ManifestWorkspaceBackend {
784    async fn read_text(&self, path: &WorkspacePath) -> WorkspaceResult<String> {
785        let content = self.local.read_text(path).await?;
786        self.manifest.touch_file(path.as_str());
787        Ok(content)
788    }
789
790    async fn write_text(
791        &self,
792        path: &WorkspacePath,
793        content: &str,
794    ) -> WorkspaceResult<WorkspaceWriteOutcome> {
795        let outcome = self.local.write_text(path, content).await?;
796        self.manifest.touch_file(path.as_str());
797        Ok(outcome)
798    }
799
800    async fn list_dir(&self, path: &WorkspacePath) -> WorkspaceResult<Vec<WorkspaceDirEntry>> {
801        self.local.list_dir(path).await
802    }
803}
804
805#[async_trait]
806impl WorkspaceTextReader for ManifestWorkspaceBackend {
807    async fn read_text_range(
808        &self,
809        path: &WorkspacePath,
810        offset: usize,
811        limit: usize,
812    ) -> WorkspaceResult<WorkspaceTextRange> {
813        let range = self.local.read_text_range(path, offset, limit).await?;
814        self.manifest.touch_file(path.as_str());
815        Ok(range)
816    }
817}
818
819#[async_trait]
820impl WorkspaceCommandRunner for ManifestWorkspaceBackend {
821    async fn exec(&self, request: CommandRequest) -> Result<CommandOutput> {
822        self.local.exec(request).await
823    }
824}
825
826#[async_trait]
827impl WorkspaceSearch for ManifestWorkspaceBackend {
828    async fn glob(&self, request: WorkspaceGlobRequest) -> Result<WorkspaceGlobResult> {
829        validate_relative_pattern(&request.pattern, "glob pattern")?;
830        let Some(search_snapshot) = self.manifest_ready() else {
831            return self.fallback_search().glob(request).await;
832        };
833        let pattern = glob::Pattern::new(&request.pattern)
834            .map_err(|e| anyhow!("Invalid glob pattern '{}': {}", request.pattern, e))?;
835        let candidates =
836            candidate_indices_for_glob(&search_snapshot.index, &request.base, &request.pattern);
837        let recent_ranks = self.recent_path_ranks(&search_snapshot.index);
838
839        let mut matches = Vec::new();
840        for file_index in
841            recent_first_candidate_indices(&candidates, &search_snapshot.index, &recent_ranks)
842        {
843            let Some(file) = search_snapshot.snapshot.files.get(file_index) else {
844                continue;
845            };
846            let Some(relative_to_base) = relative_to_base(&file.path, &request.base) else {
847                continue;
848            };
849            if glob_matches(&pattern, relative_to_base) {
850                matches.push(WorkspacePath::from_normalized(file.path.clone()));
851            }
852        }
853
854        sort_paths_by_recent(&mut matches, &recent_ranks);
855        Ok(WorkspaceGlobResult { matches })
856    }
857
858    async fn grep(&self, request: WorkspaceGrepRequest) -> Result<WorkspaceGrepResult> {
859        Ok(self.grep_with_sources(request).await?.result)
860    }
861
862    async fn grep_with_sources(
863        &self,
864        request: WorkspaceGrepRequest,
865    ) -> Result<WorkspaceGrepOutcome> {
866        if let Some(ref glob) = request.glob {
867            validate_relative_pattern(glob, "grep glob filter")?;
868        }
869        self.local.ensure_search_base_allowed(&request.base)?;
870        let Some(search_snapshot) = self.manifest_ready() else {
871            return self.fallback_search().grep_with_sources(request).await;
872        };
873
874        let regex_pattern = if request.case_insensitive {
875            format!("(?i){}", request.pattern)
876        } else {
877            request.pattern.clone()
878        };
879        let regex = regex::Regex::new(&regex_pattern)
880            .map_err(|e| anyhow!("Invalid regex pattern '{}': {}", request.pattern, e))?;
881        let glob = request
882            .glob
883            .as_deref()
884            .map(glob::Pattern::new)
885            .transpose()
886            .map_err(|e| anyhow!("Invalid grep glob filter: {e}"))?;
887
888        let mut output = String::new();
889        let mut match_count = 0;
890        let mut file_count = 0;
891        let mut total_size = 0;
892        let mut matched_paths = Vec::new();
893        let metadata_only = request.max_output_size == 0;
894
895        let candidates = request
896            .glob
897            .as_deref()
898            .map(|glob| candidate_indices_for_glob(&search_snapshot.index, &request.base, glob))
899            .unwrap_or_else(|| CandidateIndices::Indexed(&search_snapshot.index.all));
900        let recent_ranks = self.recent_path_ranks(&search_snapshot.index);
901
902        for file_index in
903            recent_first_candidate_indices(&candidates, &search_snapshot.index, &recent_ranks)
904        {
905            let Some(file) = search_snapshot.snapshot.files.get(file_index) else {
906                continue;
907            };
908            if file.binary {
909                continue;
910            }
911            let Some(relative_to_base) = relative_to_base(&file.path, &request.base) else {
912                continue;
913            };
914            if let Some(glob) = &glob {
915                if !glob_matches(glob, relative_to_base) {
916                    continue;
917                }
918            }
919
920            let workspace_path = WorkspacePath::from_normalized(file.path.clone());
921            let Some(content) = self.local.read_search_file(&workspace_path) else {
922                continue;
923            };
924            let lines: Vec<&str> = content.lines().collect();
925            let file_matches = lines
926                .iter()
927                .enumerate()
928                .filter_map(|(line_idx, line)| regex.is_match(line).then_some(line_idx))
929                .collect::<Vec<_>>();
930
931            if file_matches.is_empty() {
932                continue;
933            }
934
935            file_count += 1;
936            let display_path = escape_control_chars_for_display(&file.path);
937            let mut path_recorded = false;
938            for &match_idx in &file_matches {
939                if !metadata_only && total_size > request.max_output_size {
940                    return Ok(WorkspaceGrepOutcome {
941                        result: WorkspaceGrepResult {
942                            output,
943                            match_count,
944                            file_count,
945                            truncated: true,
946                        },
947                        matched_paths: Some(matched_paths),
948                    });
949                }
950
951                if !path_recorded {
952                    matched_paths.push(workspace_path.clone());
953                    path_recorded = true;
954                }
955                match_count += 1;
956                if metadata_only {
957                    continue;
958                }
959                let start = match_idx.saturating_sub(request.context_lines);
960                let end = (match_idx + request.context_lines + 1).min(lines.len());
961
962                for (i, line) in lines[start..end].iter().enumerate() {
963                    let abs_i = start + i;
964                    let prefix = if abs_i == match_idx { ">" } else { " " };
965                    let line = format!("{}{}:{}: {}\n", prefix, display_path, abs_i + 1, line);
966                    total_size += line.len();
967                    output.push_str(&line);
968                }
969
970                if request.context_lines > 0 {
971                    output.push_str("--\n");
972                    total_size += 3;
973                }
974            }
975        }
976
977        Ok(WorkspaceGrepOutcome {
978            result: WorkspaceGrepResult {
979                output,
980                match_count,
981                file_count,
982                truncated: false,
983            },
984            matched_paths: Some(matched_paths),
985        })
986    }
987}
988
989#[async_trait]
990impl WorkspaceGit for ManifestWorkspaceBackend {
991    async fn is_repository(&self) -> Result<bool> {
992        self.local.is_repository().await
993    }
994
995    async fn status(&self) -> Result<WorkspaceGitStatus> {
996        self.local.status().await
997    }
998
999    async fn log(&self, max_count: usize) -> Result<Vec<WorkspaceGitCommit>> {
1000        self.local.log(max_count).await
1001    }
1002
1003    async fn list_branches(&self) -> Result<Vec<WorkspaceGitBranch>> {
1004        self.local.list_branches().await
1005    }
1006
1007    async fn create_branch(&self, request: WorkspaceGitCreateBranchRequest) -> Result<()> {
1008        self.local.create_branch(request).await
1009    }
1010
1011    async fn checkout(
1012        &self,
1013        request: WorkspaceGitCheckoutRequest,
1014    ) -> Result<WorkspaceGitCheckoutOutput> {
1015        self.local.checkout(request).await
1016    }
1017
1018    async fn diff(&self, request: WorkspaceGitDiffRequest) -> Result<String> {
1019        self.local.diff(request).await
1020    }
1021
1022    async fn list_remotes(&self) -> Result<Vec<WorkspaceGitRemote>> {
1023        self.local.list_remotes().await
1024    }
1025}
1026
1027#[async_trait]
1028impl WorkspaceGitStashProvider for ManifestWorkspaceBackend {
1029    async fn list_stashes(&self) -> Result<Vec<WorkspaceGitStash>> {
1030        self.local.list_stashes().await
1031    }
1032
1033    async fn stash(&self, request: WorkspaceGitStashRequest) -> Result<()> {
1034        self.local.stash(request).await
1035    }
1036}
1037
1038#[async_trait]
1039impl WorkspaceGitWorktreeProvider for ManifestWorkspaceBackend {
1040    async fn list_worktrees(&self) -> Result<Vec<WorkspaceGitWorktree>> {
1041        self.local.list_worktrees().await
1042    }
1043
1044    async fn create_worktree(
1045        &self,
1046        request: WorkspaceGitCreateWorktreeRequest,
1047    ) -> Result<WorkspaceGitWorktreeMutation> {
1048        self.local.create_worktree(request).await
1049    }
1050
1051    async fn remove_worktree(
1052        &self,
1053        request: WorkspaceGitRemoveWorktreeRequest,
1054    ) -> Result<WorkspaceGitWorktreeMutation> {
1055        self.local.remove_worktree(request).await
1056    }
1057}
1058
1059fn update_state(
1060    state: &Arc<RwLock<ManifestState>>,
1061    files: Vec<LocalWorkspaceFile>,
1062) -> Option<LocalWorkspaceManifestSnapshot> {
1063    let fingerprint = fingerprint_files(&files);
1064    let index = Arc::new(ManifestIndex::build(&files));
1065    let Ok(mut state) = state.write() else {
1066        return None;
1067    };
1068    if state.snapshot.version > 0 && state.fingerprint == fingerprint {
1069        return None;
1070    }
1071    state.fingerprint = fingerprint;
1072    state.index = index;
1073    state.snapshot = Arc::new(LocalWorkspaceManifestSnapshot {
1074        version: state.snapshot.version + 1,
1075        root: state.snapshot.root.clone(),
1076        files,
1077        scanned_at_ms: now_ms(),
1078    });
1079    Some((*state.snapshot).clone())
1080}
1081
1082enum CandidateIndices<'a> {
1083    Indexed(&'a [usize]),
1084    Single(Option<usize>),
1085}
1086
1087impl<'a> CandidateIndices<'a> {
1088    fn iter(&self) -> Box<dyn Iterator<Item = usize> + '_> {
1089        match self {
1090            Self::Indexed(indices) => Box::new(indices.iter().copied()),
1091            Self::Single(Some(index)) => Box::new(std::iter::once(*index)),
1092            Self::Single(None) => Box::new(std::iter::empty()),
1093        }
1094    }
1095
1096    fn len(&self) -> usize {
1097        match self {
1098            Self::Indexed(indices) => indices.len(),
1099            Self::Single(Some(_)) => 1,
1100            Self::Single(None) => 0,
1101        }
1102    }
1103
1104    fn contains(&self, index: usize) -> bool {
1105        match self {
1106            Self::Indexed(indices) => indices.contains(&index),
1107            Self::Single(Some(candidate)) => *candidate == index,
1108            Self::Single(None) => false,
1109        }
1110    }
1111}
1112
1113fn recent_first_candidate_indices(
1114    candidates: &CandidateIndices<'_>,
1115    index: &ManifestIndex,
1116    recent_ranks: &HashMap<String, usize>,
1117) -> Vec<usize> {
1118    if recent_ranks.is_empty() {
1119        return candidates.iter().collect();
1120    }
1121
1122    let mut hot = recent_ranks
1123        .iter()
1124        .filter_map(|(path, rank)| {
1125            let file_index = *index.by_path.get(path)?;
1126            candidates
1127                .contains(file_index)
1128                .then_some((*rank, file_index))
1129        })
1130        .collect::<Vec<_>>();
1131    hot.sort_unstable_by_key(|(rank, _)| *rank);
1132
1133    let mut out = Vec::with_capacity(candidates.len());
1134    let mut seen = HashSet::with_capacity(hot.len());
1135    for (_, file_index) in hot {
1136        if seen.insert(file_index) {
1137            out.push(file_index);
1138        }
1139    }
1140    out.extend(
1141        candidates
1142            .iter()
1143            .filter(|file_index| !seen.contains(file_index)),
1144    );
1145    out
1146}
1147
1148fn sort_paths_by_recent(paths: &mut [WorkspacePath], recent_ranks: &HashMap<String, usize>) {
1149    paths.sort_by(|left, right| {
1150        recent_ranks
1151            .get(left.as_str())
1152            .copied()
1153            .unwrap_or(usize::MAX)
1154            .cmp(
1155                &recent_ranks
1156                    .get(right.as_str())
1157                    .copied()
1158                    .unwrap_or(usize::MAX),
1159            )
1160            .then_with(|| left.as_str().cmp(right.as_str()))
1161    });
1162}
1163
1164fn candidate_indices_for_glob<'a>(
1165    index: &'a ManifestIndex,
1166    base: &WorkspacePath,
1167    pattern: &str,
1168) -> CandidateIndices<'a> {
1169    if !has_glob_meta(pattern) && pattern.contains('/') {
1170        return CandidateIndices::Single(
1171            literal_workspace_path(base, pattern)
1172                .and_then(|path| index.by_path.get(&path).copied()),
1173        );
1174    }
1175
1176    if let Some(name) = literal_terminal_segment(pattern) {
1177        return index
1178            .by_basename
1179            .get(name)
1180            .map(|indices| CandidateIndices::Indexed(indices))
1181            .unwrap_or(CandidateIndices::Single(None));
1182    }
1183
1184    if let Some(extension) = simple_extension_terminal(pattern) {
1185        return index
1186            .by_extension
1187            .get(extension)
1188            .map(|indices| CandidateIndices::Indexed(indices))
1189            .unwrap_or(CandidateIndices::Single(None));
1190    }
1191
1192    CandidateIndices::Indexed(&index.all)
1193}
1194
1195fn literal_workspace_path(base: &WorkspacePath, pattern: &str) -> Option<String> {
1196    let pattern = normalize_relative_path_lossy(Path::new(pattern))?;
1197    if pattern.is_empty() {
1198        return None;
1199    }
1200    if base.is_root() {
1201        Some(pattern)
1202    } else {
1203        Some(format!(
1204            "{}/{}",
1205            base.as_str().trim_end_matches('/'),
1206            pattern
1207        ))
1208    }
1209}
1210
1211fn literal_terminal_segment(pattern: &str) -> Option<&str> {
1212    let terminal = pattern
1213        .trim_end_matches('/')
1214        .rsplit('/')
1215        .next()
1216        .filter(|segment| !segment.is_empty())?;
1217    (!has_glob_meta(terminal)).then_some(terminal)
1218}
1219
1220fn simple_extension_terminal(pattern: &str) -> Option<&str> {
1221    let terminal = pattern.trim_end_matches('/').rsplit('/').next()?;
1222    let extension = terminal.strip_prefix("*.")?;
1223    (!extension.is_empty() && !has_glob_meta(extension)).then_some(extension)
1224}
1225
1226fn has_glob_meta(pattern: &str) -> bool {
1227    pattern
1228        .bytes()
1229        .any(|byte| matches!(byte, b'*' | b'?' | b'[' | b']' | b'{' | b'}'))
1230}
1231
1232fn fingerprint_files(files: &[LocalWorkspaceFile]) -> u64 {
1233    let mut hasher = DefaultHasher::new();
1234    files.hash(&mut hasher);
1235    hasher.finish()
1236}
1237
1238fn recent_score(entry: &RecentFileState, now: u64) -> f32 {
1239    let age_ms = now.saturating_sub(entry.touched_at_ms) as f32;
1240    let recency = (-age_ms / RECENT_DECAY_HALF_LIFE_MS).exp();
1241    let frequency =
1242        ((entry.touch_count as f32) + 1.0).ln() / (RECENT_FREQUENCY_NORMALIZER + 1.0).ln();
1243    RECENT_RECENCY_WEIGHT * recency + (1.0 - RECENT_RECENCY_WEIGHT) * frequency.min(1.0)
1244}
1245
1246fn normalize_recent_file_path(path: &str) -> Option<String> {
1247    let path = path.trim();
1248    if path.is_empty() {
1249        return None;
1250    }
1251    let normalized = normalize_relative_path_lossy(Path::new(path))?;
1252    (!normalized.is_empty()).then_some(normalized)
1253}
1254
1255fn normalize_relative_path_lossy(path: &Path) -> Option<String> {
1256    let mut parts = Vec::new();
1257    for component in path.components() {
1258        match component {
1259            Component::Normal(part) => parts.push(part.to_string_lossy().into_owned()),
1260            Component::CurDir => {}
1261            _ => return None,
1262        }
1263    }
1264    Some(parts.join("/"))
1265}
1266
1267fn relative_to_base<'a>(path: &'a str, base: &WorkspacePath) -> Option<&'a str> {
1268    if base.is_root() {
1269        return Some(path);
1270    }
1271    let base = base.as_str().trim_end_matches('/');
1272    if path == base {
1273        Some("")
1274    } else {
1275        path.strip_prefix(base)
1276            .and_then(|tail| tail.strip_prefix('/'))
1277            .filter(|tail| !tail.is_empty())
1278    }
1279}
1280
1281fn glob_matches(pattern: &glob::Pattern, path: &str) -> bool {
1282    let path = Path::new(path);
1283    pattern.matches_path(path)
1284        || path
1285            .file_name()
1286            .and_then(|name| name.to_str())
1287            .is_some_and(|name| pattern.matches(name))
1288}
1289
1290fn now_ms() -> u64 {
1291    system_time_ms(SystemTime::now())
1292}
1293
1294fn system_time_ms(time: SystemTime) -> u64 {
1295    time.duration_since(UNIX_EPOCH)
1296        .map(|duration| duration.as_millis() as u64)
1297        .unwrap_or_default()
1298}
1299
1300#[cfg(test)]
1301#[path = "manifest/tests.rs"]
1302mod tests;