Skip to main content

a3s_code_core/workspace/
services.rs

1//! Workspace service aggregation and builder.
2
3#[allow(unused_imports)]
4use super::{CommandRequest, WorkspaceError, WorkspaceVersionConflict};
5use super::{
6    DirectWriteGuard, LocalWorkspaceBackend, ManifestWorkspaceBackend, VirtualPathResolver,
7    WorkspaceCapabilities, WorkspaceChunkCatalog, WorkspaceCommandRunner, WorkspaceFileSystem,
8    WorkspaceFileSystemExt, WorkspaceGit, WorkspaceGitStashProvider, WorkspaceGitWorktreeProvider,
9    WorkspacePath, WorkspacePathResolver, WorkspacePersistentIndex, WorkspaceRef, WorkspaceResult,
10    WorkspaceRetrievalRuntime, WorkspaceSearch, WorkspaceTextReader, WorkspaceWriteOutcome,
11};
12use crate::code_intelligence::{LocalCodeIntelligence, WorkspaceCodeIntelligence};
13use anyhow::{anyhow, Result};
14use std::path::{Path, PathBuf};
15use std::sync::{Arc, OnceLock};
16
17/// Lazy lexical handles attached on first catalog/index access.
18type LazyLexicalHandles = (
19    Arc<WorkspaceChunkCatalog>,
20    Option<Arc<WorkspacePersistentIndex>>,
21);
22
23/// The host-provided workspace capability bundle used by tool execution.
24pub struct WorkspaceServices {
25    workspace_ref: WorkspaceRef,
26    capabilities: WorkspaceCapabilities,
27    path_resolver: Arc<dyn WorkspacePathResolver>,
28    file_system: Arc<dyn WorkspaceFileSystem>,
29    file_system_ext: Option<Arc<dyn WorkspaceFileSystemExt>>,
30    text_reader: Option<Arc<dyn WorkspaceTextReader>>,
31    command_runner: Option<Arc<dyn WorkspaceCommandRunner>>,
32    search: Option<Arc<dyn WorkspaceSearch>>,
33    code_intelligence: Option<Arc<dyn WorkspaceCodeIntelligence>>,
34    chunk_catalog: Option<Arc<WorkspaceChunkCatalog>>,
35    persistent_index: Option<Arc<WorkspacePersistentIndex>>,
36    /// When set, first [`Self::chunk_catalog`] / [`Self::persistent_index`] call
37    /// attaches the default lexical catalog and best-effort persistent a3s-vec FTS
38    /// without paying that cost at session construction.
39    lazy_lexical_backend: Option<Arc<ManifestWorkspaceBackend>>,
40    lazy_lexical: Arc<OnceLock<LazyLexicalHandles>>,
41    workspace_retrieval: Option<Arc<WorkspaceRetrievalRuntime>>,
42    git: Option<Arc<dyn WorkspaceGit>>,
43    git_stash: Option<Arc<dyn WorkspaceGitStashProvider>>,
44    git_worktree: Option<Arc<dyn WorkspaceGitWorktreeProvider>>,
45    /// Default timeout applied to non-bash workspace operations (file system,
46    /// search, git). Bash uses its own per-call timeout in [`CommandRequest`].
47    /// `None` means no enforced timeout — appropriate for the local backend.
48    operation_timeout: Option<std::time::Duration>,
49    local_root: Option<PathBuf>,
50    direct_write_guard: Option<Arc<dyn DirectWriteGuard>>,
51}
52
53impl std::fmt::Debug for WorkspaceServices {
54    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
55        f.debug_struct("WorkspaceServices")
56            .field("workspace_ref", &self.workspace_ref)
57            .field("capabilities", &self.capabilities)
58            .field("file_system_ext", &self.file_system_ext.is_some())
59            .field("text_reader", &self.text_reader.is_some())
60            .field("command_runner", &self.command_runner.is_some())
61            .field("search", &self.search.is_some())
62            .field("code_intelligence", &self.code_intelligence.is_some())
63            .field("chunk_catalog", &self.chunk_catalog.is_some())
64            .field("persistent_index", &self.persistent_index.is_some())
65            .field("workspace_retrieval", &self.workspace_retrieval.is_some())
66            .field("git", &self.git.is_some())
67            .field("git_stash", &self.git_stash.is_some())
68            .field("git_worktree", &self.git_worktree.is_some())
69            .field("local_root", &self.local_root)
70            .finish()
71    }
72}
73
74impl WorkspaceServices {
75    pub(crate) fn new_with_git(
76        workspace_ref: WorkspaceRef,
77        mut capabilities: WorkspaceCapabilities,
78        path_resolver: Arc<dyn WorkspacePathResolver>,
79        file_system: Arc<dyn WorkspaceFileSystem>,
80        command_runner: Option<Arc<dyn WorkspaceCommandRunner>>,
81        search: Option<Arc<dyn WorkspaceSearch>>,
82        git: Option<Arc<dyn WorkspaceGit>>,
83    ) -> Self {
84        if command_runner.is_none() {
85            capabilities.exec = false;
86        }
87        if search.is_none() {
88            capabilities.search = false;
89        }
90        if git.is_none() {
91            capabilities.git = false;
92        }
93        capabilities.code_intelligence = false;
94        Self {
95            workspace_ref,
96            capabilities,
97            path_resolver,
98            file_system,
99            file_system_ext: None,
100            text_reader: None,
101            command_runner,
102            search,
103            code_intelligence: None,
104            chunk_catalog: None,
105            persistent_index: None,
106            lazy_lexical_backend: None,
107            lazy_lexical: Arc::new(OnceLock::new()),
108            workspace_retrieval: None,
109            git,
110            git_stash: None,
111            git_worktree: None,
112            operation_timeout: None,
113            local_root: None,
114            direct_write_guard: None,
115        }
116    }
117
118    pub fn builder(
119        workspace_ref: WorkspaceRef,
120        file_system: Arc<dyn WorkspaceFileSystem>,
121    ) -> WorkspaceServicesBuilder {
122        WorkspaceServicesBuilder::new(workspace_ref, file_system)
123    }
124
125    pub fn local(root: impl Into<PathBuf>) -> Arc<Self> {
126        let backend = Arc::new(LocalWorkspaceBackend::new(root.into()));
127        let workspace_ref = WorkspaceRef::new(
128            backend.root.display().to_string(),
129            backend.root.display().to_string(),
130        );
131        let path_resolver: Arc<dyn WorkspacePathResolver> = backend.clone();
132        let file_system: Arc<dyn WorkspaceFileSystem> = backend.clone();
133        let text_reader: Arc<dyn WorkspaceTextReader> = backend.clone();
134        let command_runner: Arc<dyn WorkspaceCommandRunner> = backend.clone();
135        let search: Arc<dyn WorkspaceSearch> = backend.clone();
136        let git: Arc<dyn WorkspaceGit> = backend.clone();
137        let git_stash: Arc<dyn WorkspaceGitStashProvider> = backend.clone();
138        let git_worktree: Arc<dyn WorkspaceGitWorktreeProvider> = backend.clone();
139        Arc::new(Self {
140            workspace_ref,
141            capabilities: WorkspaceCapabilities::local_default(),
142            path_resolver,
143            file_system,
144            file_system_ext: None,
145            text_reader: Some(text_reader),
146            command_runner: Some(command_runner),
147            search: Some(search),
148            code_intelligence: None,
149            chunk_catalog: None,
150            persistent_index: None,
151            lazy_lexical_backend: None,
152            lazy_lexical: Arc::new(OnceLock::new()),
153            workspace_retrieval: None,
154            git: Some(git),
155            git_stash: Some(git_stash),
156            git_worktree: Some(git_worktree),
157            operation_timeout: None,
158            local_root: Some(backend.root.clone()),
159            direct_write_guard: Some(backend),
160        })
161    }
162
163    /// Local workspace services backed by an in-memory file manifest for
164    /// search. `read`/`write`/`ls`/`bash`/`git` preserve local backend
165    /// behavior; `glob` and `grep` use the manifest once the initial scan has
166    /// completed and fall back to filesystem search before that.
167    pub fn local_with_manifest(root: impl Into<PathBuf>) -> Arc<Self> {
168        let backend = ManifestWorkspaceBackend::new(root);
169        Self::local_with_manifest_backend(backend)
170    }
171
172    /// Build local manifest services with an asynchronous catalog and the
173    /// best-effort workspace-owned persistent a3s-vec projection.
174    pub fn local_with_retrieval(root: impl Into<PathBuf>) -> Arc<Self> {
175        let backend = ManifestWorkspaceBackend::new(root);
176        Self::local_with_retrieval_backend(backend)
177    }
178
179    /// Build local manifest-backed services with native Code Intelligence.
180    ///
181    /// The provider subscribes to the manifest's existing change stream and
182    /// therefore does not create a second filesystem watcher or file index.
183    pub async fn local_with_code_intelligence(
184        root: impl Into<PathBuf>,
185        isolation_scope: impl Into<String>,
186    ) -> Result<Arc<Self>> {
187        let backend = ManifestWorkspaceBackend::new(root);
188        Self::local_with_code_intelligence_backend(backend, isolation_scope).await
189    }
190
191    /// Build one local session with retrieval and Code Intelligence sharing
192    /// the same manifest scanner and filesystem change stream.
193    pub async fn local_with_retrieval_and_code_intelligence(
194        root: impl Into<PathBuf>,
195        isolation_scope: impl Into<String>,
196    ) -> Result<Arc<Self>> {
197        let backend = ManifestWorkspaceBackend::new(root);
198        Self::local_with_retrieval_and_code_intelligence_backend(backend, isolation_scope).await
199    }
200
201    /// Attach native Code Intelligence to one shared manifest backend.
202    pub async fn local_with_code_intelligence_backend(
203        backend: Arc<ManifestWorkspaceBackend>,
204        isolation_scope: impl Into<String>,
205    ) -> Result<Arc<Self>> {
206        let manifest = backend.manifest();
207        let file_system: Arc<dyn WorkspaceFileSystem> = backend.clone();
208        let services = Self::local_with_manifest_backend(backend);
209        let provider = LocalCodeIntelligence::start(isolation_scope, manifest, file_system)
210            .await
211            .map_err(|error| anyhow!("failed to start Code Intelligence: {error}"))?;
212        Ok(services.with_code_intelligence(provider))
213    }
214
215    /// Attach retrieval and Code Intelligence to one shared manifest backend.
216    pub async fn local_with_retrieval_and_code_intelligence_backend(
217        backend: Arc<ManifestWorkspaceBackend>,
218        isolation_scope: impl Into<String>,
219    ) -> Result<Arc<Self>> {
220        let manifest = backend.manifest();
221        let file_system: Arc<dyn WorkspaceFileSystem> = backend.clone();
222        let services = Self::local_with_retrieval_backend(backend);
223        let provider = LocalCodeIntelligence::start(isolation_scope, manifest, file_system)
224            .await
225            .map_err(|error| anyhow!("failed to start Code Intelligence: {error}"))?;
226        Ok(services.with_code_intelligence(provider))
227    }
228
229    /// Build local workspace services from a shared manifest backend. Hosts
230    /// can keep the same manifest for UI file pickers and agent tools.
231    pub fn local_with_manifest_backend(backend: Arc<ManifestWorkspaceBackend>) -> Arc<Self> {
232        Self::local_with_manifest_backend_and_catalog(backend, None, None, None)
233    }
234
235    /// Enable retrieval on a shared manifest backend.
236    ///
237    /// Lexical catalog attaches on first [`Self::chunk_catalog`] access (or when
238    /// the host already configured it). Durable a3s-vec FTS opens only on
239    /// [`Self::persistent_index`] demand so session construction / Loading
240    /// never pays native index open cost. The backend is always retained for
241    /// that demand path.
242    pub fn local_with_retrieval_backend(backend: Arc<ManifestWorkspaceBackend>) -> Arc<Self> {
243        if backend.catalog_is_configured() {
244            let catalog = backend.chunk_catalog();
245            let persistent = backend.persistent_index();
246            Self::local_with_manifest_backend_and_catalog(
247                backend.clone(),
248                Some(catalog),
249                persistent,
250                Some(backend),
251            )
252        } else {
253            Self::local_with_manifest_backend_and_catalog(
254                backend.clone(),
255                None,
256                None,
257                Some(backend),
258            )
259        }
260    }
261
262    /// Build local manifest-backed services with the workspace-owned persistent
263    /// a3s-vec FTS projection. This compatibility constructor is equivalent to
264    /// the default local retrieval path when the native feature is available.
265    pub fn local_with_indexed_retrieval(root: impl Into<PathBuf>) -> Result<Arc<Self>> {
266        let backend = ManifestWorkspaceBackend::new(root);
267        let index_root = backend.local_root().join(".a3s-code").join("index");
268        let persistent = backend
269            .configure_persistent_index(index_root)
270            .map_err(|error| anyhow!("failed to configure persistent workspace index: {error}"))?;
271        let catalog = backend.chunk_catalog();
272        Ok(Self::local_with_manifest_backend_and_catalog(
273            backend,
274            Some(catalog),
275            Some(persistent),
276            None,
277        ))
278    }
279
280    fn local_with_manifest_backend_and_catalog(
281        backend: Arc<ManifestWorkspaceBackend>,
282        chunk_catalog: Option<Arc<WorkspaceChunkCatalog>>,
283        persistent_index: Option<Arc<WorkspacePersistentIndex>>,
284        lazy_lexical_backend: Option<Arc<ManifestWorkspaceBackend>>,
285    ) -> Arc<Self> {
286        let workspace_ref = WorkspaceRef::new(
287            backend.local_root().display().to_string(),
288            backend.local_root().display().to_string(),
289        );
290        let path_resolver: Arc<dyn WorkspacePathResolver> = backend.clone();
291        let file_system: Arc<dyn WorkspaceFileSystem> = backend.clone();
292        let text_reader: Arc<dyn WorkspaceTextReader> = backend.clone();
293        let command_runner: Arc<dyn WorkspaceCommandRunner> = backend.clone();
294        let search: Arc<dyn WorkspaceSearch> = backend.clone();
295        let git: Arc<dyn WorkspaceGit> = backend.clone();
296        let git_stash: Arc<dyn WorkspaceGitStashProvider> = backend.clone();
297        let git_worktree: Arc<dyn WorkspaceGitWorktreeProvider> = backend.clone();
298        Arc::new(Self {
299            workspace_ref,
300            capabilities: WorkspaceCapabilities::local_default(),
301            path_resolver,
302            file_system,
303            file_system_ext: None,
304            text_reader: Some(text_reader),
305            command_runner: Some(command_runner),
306            search: Some(search),
307            code_intelligence: None,
308            chunk_catalog,
309            persistent_index,
310            lazy_lexical_backend,
311            lazy_lexical: Arc::new(OnceLock::new()),
312            workspace_retrieval: None,
313            git: Some(git),
314            git_stash: Some(git_stash),
315            git_worktree: Some(git_worktree),
316            operation_timeout: None,
317            local_root: Some(backend.local_root().to_path_buf()),
318            direct_write_guard: Some(backend),
319        })
320    }
321
322    pub fn workspace_ref(&self) -> &WorkspaceRef {
323        &self.workspace_ref
324    }
325
326    pub fn capabilities(&self) -> WorkspaceCapabilities {
327        self.capabilities
328    }
329
330    pub fn normalize_path(&self, input: &str) -> Result<WorkspacePath> {
331        self.path_resolver.normalize(input)
332    }
333
334    pub fn fs(&self) -> Arc<dyn WorkspaceFileSystem> {
335        Arc::clone(&self.file_system)
336    }
337
338    /// Optional compare-and-swap file system extensions.
339    ///
340    /// Returns `Some` when the backend supports version-aware writes (e.g.
341    /// S3 via ETag). Tools that perform read-modify-write cycles should
342    /// route through [`Self::read_for_edit`] and [`Self::write_for_edit`]
343    /// rather than touching this directly.
344    pub fn fs_ext(&self) -> Option<Arc<dyn WorkspaceFileSystemExt>> {
345        self.file_system_ext.clone()
346    }
347
348    pub fn text_reader(&self) -> Option<Arc<dyn WorkspaceTextReader>> {
349        self.text_reader.clone()
350    }
351
352    pub fn command_runner(&self) -> Option<Arc<dyn WorkspaceCommandRunner>> {
353        self.command_runner.clone()
354    }
355
356    pub fn search(&self) -> Option<Arc<dyn WorkspaceSearch>> {
357        self.search.clone()
358    }
359
360    /// Optional workspace-scoped semantic code query provider.
361    pub fn code_intelligence(&self) -> Option<Arc<dyn WorkspaceCodeIntelligence>> {
362        self.code_intelligence.clone()
363    }
364
365    /// Optional session-local catalog shared by lexical and semantic retrieval.
366    pub fn chunk_catalog(&self) -> Option<Arc<WorkspaceChunkCatalog>> {
367        if let Some(catalog) = &self.chunk_catalog {
368            return Some(Arc::clone(catalog));
369        }
370        self.ensure_lazy_lexical()
371            .map(|(catalog, _)| Arc::clone(catalog))
372    }
373
374    pub fn persistent_index(&self) -> Option<Arc<WorkspacePersistentIndex>> {
375        if let Some(index) = &self.persistent_index {
376            return Some(Arc::clone(index));
377        }
378        // Demand-driven durable FTS must open before the lazy catalog starts.
379        // `chunk_catalog()` wires the coordinator only when a persistent handle
380        // already exists; opening afterward leaves an orphan generation that
381        // never becomes ready (BM25 would stick on the portable catalog). Grep
382        // never opens this path.
383        if let Some(index) = self
384            .lazy_lexical_backend
385            .as_ref()
386            .and_then(|backend| backend.ensure_persistent_index())
387        {
388            let _ = self.ensure_lazy_lexical();
389            return Some(index);
390        }
391        if let Some(handles) = self.ensure_lazy_lexical() {
392            if let Some(index) = &handles.1 {
393                return Some(Arc::clone(index));
394            }
395        }
396        None
397    }
398
399    /// Open the durable a3s-vec projection without starting the whole-workspace
400    /// chunk catalog.
401    ///
402    /// Hosts that only need mind-wiki / titlebar FTS warmup must use this path.
403    /// [`Self::persistent_index`] also starts catalog reconciliation, which
404    /// times out or locks the generation on large monorepos.
405    pub fn open_persistent_index_without_catalog(&self) -> Option<Arc<WorkspacePersistentIndex>> {
406        if let Some(index) = &self.persistent_index {
407            return Some(Arc::clone(index));
408        }
409        if let Some(handles) = self.lazy_lexical.get() {
410            if let Some(index) = &handles.1 {
411                return Some(Arc::clone(index));
412            }
413        }
414        self.lazy_lexical_backend
415            .as_ref()
416            .and_then(|backend| backend.ensure_persistent_index())
417    }
418
419    /// True after the lazy whole-workspace chunk catalog has been started.
420    pub fn chunk_catalog_started(&self) -> bool {
421        self.chunk_catalog.is_some() || self.lazy_lexical.get().is_some()
422    }
423
424    fn ensure_lazy_lexical(&self) -> Option<&LazyLexicalHandles> {
425        let backend = self.lazy_lexical_backend.as_ref()?;
426        Some(self.lazy_lexical.get_or_init(|| {
427            let catalog = backend.chunk_catalog();
428            // Do not open durable FTS merely because the catalog was touched.
429            let persistent = backend.persistent_index();
430            (catalog, persistent)
431        }))
432    }
433
434    /// Optional semantic retrieval runtime bound to this workspace session.
435    pub fn workspace_retrieval(&self) -> Option<Arc<WorkspaceRetrievalRuntime>> {
436        self.workspace_retrieval.clone()
437    }
438
439    /// Search the session-owned semantic index and verify every returned
440    /// source chunk against the workspace backend before exposing its text.
441    pub async fn semantic_search(
442        &self,
443        mut request: super::WorkspaceSemanticSearchRequest,
444        cancellation: tokio_util::sync::CancellationToken,
445    ) -> super::WorkspaceRetrievalResult<super::WorkspaceSemanticSearchResult> {
446        if !self.capabilities.read {
447            return Err(super::WorkspaceRetrievalError::Unavailable);
448        }
449        if let Some(path) = request.path.take() {
450            request.path = Some(
451                self.path_resolver
452                    .normalize(&path)
453                    .map_err(|_| {
454                        super::WorkspaceRetrievalError::InvalidQuery(
455                            "path was rejected by the workspace resolver".to_owned(),
456                        )
457                    })?
458                    .as_str()
459                    .to_owned(),
460            );
461        }
462        let runtime = self
463            .workspace_retrieval
464            .as_ref()
465            .ok_or(super::WorkspaceRetrievalError::Unavailable)?;
466        runtime
467            .search(
468                request,
469                Arc::clone(&self.file_system),
470                self.operation_timeout,
471                cancellation,
472            )
473            .await
474    }
475
476    /// Run exact, lexical, structural, and semantic retrieval against one
477    /// catalog revision, fuse channel ranks, and verify returned source text.
478    pub async fn hybrid_search(
479        &self,
480        mut request: super::WorkspaceHybridSearchRequest,
481        cancellation: tokio_util::sync::CancellationToken,
482    ) -> super::WorkspaceRetrievalResult<super::WorkspaceHybridSearchResult> {
483        if !self.capabilities.read {
484            return Err(super::WorkspaceRetrievalError::Unavailable);
485        }
486        if let Some(path) = request.path.take() {
487            request.path = Some(
488                self.path_resolver
489                    .normalize(&path)
490                    .map_err(|_| {
491                        super::WorkspaceRetrievalError::InvalidQuery(
492                            "path was rejected by the workspace resolver".to_owned(),
493                        )
494                    })?
495                    .as_str()
496                    .to_owned(),
497            );
498        }
499        let runtime = self
500            .workspace_retrieval
501            .as_ref()
502            .ok_or(super::WorkspaceRetrievalError::Unavailable)?;
503        runtime
504            .hybrid_search(
505                request,
506                Arc::clone(&self.file_system),
507                self.code_intelligence.clone(),
508                self.operation_timeout,
509                cancellation,
510            )
511            .await
512    }
513
514    pub(crate) fn with_workspace_retrieval(
515        &self,
516        runtime: Arc<WorkspaceRetrievalRuntime>,
517    ) -> Option<Arc<Self>> {
518        if self.workspace_retrieval.is_some() {
519            return None;
520        }
521        Some(Arc::new(Self {
522            workspace_ref: self.workspace_ref.clone(),
523            capabilities: self.capabilities,
524            path_resolver: Arc::clone(&self.path_resolver),
525            file_system: Arc::clone(&self.file_system),
526            file_system_ext: self.file_system_ext.clone(),
527            text_reader: self.text_reader.clone(),
528            command_runner: self.command_runner.clone(),
529            search: self.search.clone(),
530            code_intelligence: self.code_intelligence.clone(),
531            chunk_catalog: self.chunk_catalog.clone(),
532            persistent_index: self.persistent_index.clone(),
533            lazy_lexical_backend: self.lazy_lexical_backend.clone(),
534            lazy_lexical: Arc::clone(&self.lazy_lexical),
535            workspace_retrieval: Some(runtime),
536            git: self.git.clone(),
537            git_stash: self.git_stash.clone(),
538            git_worktree: self.git_worktree.clone(),
539            operation_timeout: self.operation_timeout,
540            local_root: self.local_root.clone(),
541            direct_write_guard: self.direct_write_guard.clone(),
542        }))
543    }
544
545    /// Attach a semantic code query provider while preserving every existing
546    /// workspace capability and backend.
547    pub fn with_code_intelligence(
548        &self,
549        provider: Arc<dyn WorkspaceCodeIntelligence>,
550    ) -> Arc<Self> {
551        let mut capabilities = self.capabilities;
552        capabilities.code_intelligence = true;
553        Arc::new(Self {
554            workspace_ref: self.workspace_ref.clone(),
555            capabilities,
556            path_resolver: Arc::clone(&self.path_resolver),
557            file_system: Arc::clone(&self.file_system),
558            file_system_ext: self.file_system_ext.clone(),
559            text_reader: self.text_reader.clone(),
560            command_runner: self.command_runner.clone(),
561            search: self.search.clone(),
562            code_intelligence: Some(provider),
563            chunk_catalog: self.chunk_catalog.clone(),
564            persistent_index: self.persistent_index.clone(),
565            lazy_lexical_backend: self.lazy_lexical_backend.clone(),
566            lazy_lexical: Arc::clone(&self.lazy_lexical),
567            workspace_retrieval: self.workspace_retrieval.clone(),
568            git: self.git.clone(),
569            git_stash: self.git_stash.clone(),
570            git_worktree: self.git_worktree.clone(),
571            operation_timeout: self.operation_timeout,
572            local_root: self.local_root.clone(),
573            direct_write_guard: self.direct_write_guard.clone(),
574        })
575    }
576
577    pub fn git(&self) -> Option<Arc<dyn WorkspaceGit>> {
578        self.git.clone()
579    }
580
581    pub fn git_stash(&self) -> Option<Arc<dyn WorkspaceGitStashProvider>> {
582        self.git_stash.clone()
583    }
584
585    pub fn git_worktree(&self) -> Option<Arc<dyn WorkspaceGitWorktreeProvider>> {
586        self.git_worktree.clone()
587    }
588
589    /// Internal helper used by decorators (`with_remote_git` and any
590    /// future git-provider override) to swap the git layer of an existing
591    /// `WorkspaceServices` without losing unrelated fields.
592    ///
593    /// Every field is **explicitly listed** in the returned struct
594    /// literal. This is the point of the helper — adding a new field to
595    /// `WorkspaceServices` will trip a compile error here, and the author
596    /// of that new field has to decide whether a git-provider swap
597    /// preserves it. Previously the decorator went through
598    /// `WorkspaceServicesBuilder`, which silently dropped any field the
599    /// builder did not know about (notably `local_root`).
600    ///
601    /// `git_worktree` is reset to `None` because worktree operations are
602    /// part of the same domain as the git provider — keeping the local
603    /// worktree provider while routing `status`/`log`/`diff` to a remote
604    /// server would surface inconsistent state to the model.
605    pub(crate) fn with_git_provider(
606        &self,
607        git: Arc<dyn WorkspaceGit>,
608        git_stash: Option<Arc<dyn WorkspaceGitStashProvider>>,
609    ) -> Arc<Self> {
610        let mut capabilities = self.capabilities;
611        capabilities.git = true;
612        Arc::new(Self {
613            workspace_ref: self.workspace_ref.clone(),
614            capabilities,
615            path_resolver: Arc::clone(&self.path_resolver),
616            file_system: Arc::clone(&self.file_system),
617            file_system_ext: self.file_system_ext.clone(),
618            text_reader: self.text_reader.clone(),
619            command_runner: self.command_runner.clone(),
620            search: self.search.clone(),
621            code_intelligence: self.code_intelligence.clone(),
622            chunk_catalog: self.chunk_catalog.clone(),
623            persistent_index: self.persistent_index.clone(),
624            lazy_lexical_backend: self.lazy_lexical_backend.clone(),
625            lazy_lexical: Arc::clone(&self.lazy_lexical),
626            workspace_retrieval: self.workspace_retrieval.clone(),
627            git: Some(git),
628            git_stash,
629            git_worktree: None,
630            operation_timeout: self.operation_timeout,
631            local_root: self.local_root.clone(),
632            direct_write_guard: self.direct_write_guard.clone(),
633        })
634    }
635
636    /// Same write boundary as [`WorkspaceFileSystem::write_text`].
637    ///
638    /// Callers that promote bytes without `write_text` use this so a credential
639    /// boundary is not bypassed. A backend without a local guard does not invent
640    /// a second policy.
641    pub fn refuse_direct_write(&self, path: &WorkspacePath) -> Result<()> {
642        match &self.direct_write_guard {
643            Some(guard) => guard.refuse_direct_write(path),
644            None => Ok(()),
645        }
646    }
647
648    /// Default timeout applied to non-bash workspace operations.
649    ///
650    /// `None` means no enforced timeout. Backends that may stall (remote,
651    /// browser, DFS) should set this so tools using [`Self::run_with_timeout`]
652    /// surface a timeout error instead of letting the agent loop hang.
653    pub fn operation_timeout(&self) -> Option<std::time::Duration> {
654        self.operation_timeout
655    }
656
657    /// Run a workspace future under the configured operation timeout.
658    ///
659    /// Tools that route through file system / search / git providers should
660    /// wrap their calls with this helper so non-local backends never stall
661    /// the agent loop indefinitely.
662    ///
663    /// Polymorphic in the error type so the helper works equally well for
664    /// futures returning `anyhow::Result<T>` (the legacy callers — search,
665    /// git, etc.) and for futures returning [`WorkspaceResult<T>`] (the
666    /// migrated `WorkspaceFileSystem` callers). The `E: From<anyhow::Error>`
667    /// bound is satisfied by both `anyhow::Error` (trivially) and
668    /// [`WorkspaceError`] (via its `#[from]` `Backend` variant); a timeout
669    /// surfaces as that From conversion of an `anyhow!(...)` message.
670    pub async fn run_with_timeout<F, T, E>(
671        &self,
672        op: &'static str,
673        fut: F,
674    ) -> std::result::Result<T, E>
675    where
676        F: std::future::Future<Output = std::result::Result<T, E>>,
677        E: From<anyhow::Error>,
678    {
679        match self.operation_timeout {
680            Some(d) => tokio::time::timeout(d, fut).await.map_err(|_| {
681                E::from(anyhow!(
682                    "workspace operation '{}' timed out after {:?}",
683                    op,
684                    d
685                ))
686            })?,
687            None => fut.await,
688        }
689    }
690
691    /// Read a file for a subsequent modify-write cycle, requesting a version
692    /// token when the backend supports compare-and-swap writes.
693    ///
694    /// Returns `(content, Some(version))` when [`Self::fs_ext`] is available
695    /// (e.g. on S3, where the version is the object ETag); `(content, None)`
696    /// otherwise. Pair with [`Self::write_for_edit`].
697    pub async fn read_for_edit(
698        &self,
699        path: &WorkspacePath,
700    ) -> WorkspaceResult<(String, Option<String>)> {
701        if let Some(ext) = self.fs_ext() {
702            let path = path.clone();
703            return self
704                .run_with_timeout("read_text_with_version", async move {
705                    let (content, version) = ext.read_text_with_version(&path).await?;
706                    Ok((content, Some(version)))
707                })
708                .await;
709        }
710        let fs = self.fs();
711        let path_owned = path.clone();
712        let content = self
713            .run_with_timeout("read_text", async move { fs.read_text(&path_owned).await })
714            .await?;
715        Ok((content, None))
716    }
717
718    /// Companion to [`Self::read_for_edit`]. Performs a compare-and-swap
719    /// write when both [`Self::fs_ext`] is available *and* a version token
720    /// was returned by the prior read; falls back to a plain write
721    /// otherwise. On version mismatch the returned error is the typed
722    /// [`WorkspaceError::VersionConflict`] variant; callers can also still
723    /// downcast `anyhow::Error::downcast_ref::<WorkspaceVersionConflict>()`
724    /// when the value has been lifted into an `anyhow::Result`.
725    pub async fn write_for_edit(
726        &self,
727        path: &WorkspacePath,
728        content: &str,
729        expected_version: Option<&str>,
730    ) -> WorkspaceResult<WorkspaceWriteOutcome> {
731        if let (Some(ext), Some(version)) = (self.fs_ext(), expected_version) {
732            let path = path.clone();
733            let content = content.to_string();
734            let expected = version.to_string();
735            return self
736                .run_with_timeout("write_text_if_version", async move {
737                    ext.write_text_if_version(&path, &content, &expected).await
738                })
739                .await;
740        }
741        let fs = self.fs();
742        let path = path.clone();
743        let content = content.to_string();
744        self.run_with_timeout(
745            "write_text",
746            async move { fs.write_text(&path, &content).await },
747        )
748        .await
749    }
750
751    pub fn local_root(&self) -> Option<&Path> {
752        self.local_root.as_deref()
753    }
754
755    pub fn display_path(&self, path: &WorkspacePath) -> String {
756        if path.is_root() {
757            return self.workspace_ref.display_root.clone();
758        }
759
760        let root = self.workspace_ref.display_root.trim_end_matches('/');
761        if root.is_empty() {
762            path.as_str().to_string()
763        } else {
764            format!("{root}/{}", path.as_str())
765        }
766    }
767}
768
769/// Builder for assembling workspace services without constructor arity churn.
770pub struct WorkspaceServicesBuilder {
771    workspace_ref: WorkspaceRef,
772    capabilities: WorkspaceCapabilities,
773    path_resolver: Arc<dyn WorkspacePathResolver>,
774    file_system: Arc<dyn WorkspaceFileSystem>,
775    file_system_ext: Option<Arc<dyn WorkspaceFileSystemExt>>,
776    text_reader: Option<Arc<dyn WorkspaceTextReader>>,
777    command_runner: Option<Arc<dyn WorkspaceCommandRunner>>,
778    search: Option<Arc<dyn WorkspaceSearch>>,
779    code_intelligence: Option<Arc<dyn WorkspaceCodeIntelligence>>,
780    chunk_catalog: Option<Arc<WorkspaceChunkCatalog>>,
781    persistent_index: Option<Arc<WorkspacePersistentIndex>>,
782    git: Option<Arc<dyn WorkspaceGit>>,
783    git_stash: Option<Arc<dyn WorkspaceGitStashProvider>>,
784    git_worktree: Option<Arc<dyn WorkspaceGitWorktreeProvider>>,
785    operation_timeout: Option<std::time::Duration>,
786}
787
788impl WorkspaceServicesBuilder {
789    pub fn new(workspace_ref: WorkspaceRef, file_system: Arc<dyn WorkspaceFileSystem>) -> Self {
790        Self {
791            workspace_ref,
792            capabilities: WorkspaceCapabilities::read_write(),
793            path_resolver: Arc::new(VirtualPathResolver),
794            file_system,
795            file_system_ext: None,
796            text_reader: None,
797            command_runner: None,
798            search: None,
799            code_intelligence: None,
800            chunk_catalog: None,
801            persistent_index: None,
802            git: None,
803            git_stash: None,
804            git_worktree: None,
805            operation_timeout: None,
806        }
807    }
808
809    pub fn capabilities(mut self, capabilities: WorkspaceCapabilities) -> Self {
810        self.capabilities = capabilities;
811        self
812    }
813
814    pub fn command_runner(mut self, command_runner: Arc<dyn WorkspaceCommandRunner>) -> Self {
815        self.capabilities.exec = true;
816        self.command_runner = Some(command_runner);
817        self
818    }
819
820    pub fn search(mut self, search: Arc<dyn WorkspaceSearch>) -> Self {
821        self.capabilities.search = true;
822        self.search = Some(search);
823        self
824    }
825
826    pub fn code_intelligence(mut self, provider: Arc<dyn WorkspaceCodeIntelligence>) -> Self {
827        self.capabilities.code_intelligence = true;
828        self.code_intelligence = Some(provider);
829        self
830    }
831
832    /// Attach a typed, session-local workspace retrieval catalog.
833    pub fn chunk_catalog(mut self, catalog: Arc<WorkspaceChunkCatalog>) -> Self {
834        self.chunk_catalog = Some(catalog);
835        self
836    }
837
838    pub fn persistent_index(mut self, index: Arc<WorkspacePersistentIndex>) -> Self {
839        self.persistent_index = Some(index);
840        self
841    }
842
843    pub fn git(mut self, git: Arc<dyn WorkspaceGit>) -> Self {
844        self.capabilities.git = true;
845        self.git = Some(git);
846        self
847    }
848
849    pub fn git_stash(mut self, git_stash: Arc<dyn WorkspaceGitStashProvider>) -> Self {
850        self.git_stash = Some(git_stash);
851        self
852    }
853
854    pub fn git_worktree(mut self, git_worktree: Arc<dyn WorkspaceGitWorktreeProvider>) -> Self {
855        self.git_worktree = Some(git_worktree);
856        self
857    }
858
859    /// Attach optional compare-and-swap file system extensions
860    /// ([`WorkspaceFileSystemExt`]). Tools that perform read-modify-write
861    /// cycles will pick this up via [`WorkspaceServices::read_for_edit`]
862    /// and [`WorkspaceServices::write_for_edit`].
863    pub fn file_system_ext(mut self, ext: Arc<dyn WorkspaceFileSystemExt>) -> Self {
864        self.file_system_ext = Some(ext);
865        self
866    }
867
868    pub fn text_reader(mut self, reader: Arc<dyn WorkspaceTextReader>) -> Self {
869        self.text_reader = Some(reader);
870        self
871    }
872
873    /// Apply a default timeout to non-bash workspace operations (file system,
874    /// search, git). Backends that may stall — remote, browser, DFS — should
875    /// set this so tools surface a timeout error rather than hanging.
876    pub fn operation_timeout(mut self, timeout: std::time::Duration) -> Self {
877        self.operation_timeout = Some(timeout);
878        self
879    }
880
881    pub fn build(self) -> Arc<WorkspaceServices> {
882        let mut services = WorkspaceServices::new_with_git(
883            self.workspace_ref,
884            self.capabilities,
885            self.path_resolver,
886            self.file_system,
887            self.command_runner,
888            self.search,
889            self.git,
890        );
891        services.file_system_ext = self.file_system_ext;
892        services.text_reader = self.text_reader;
893        services.capabilities.code_intelligence = self.code_intelligence.is_some();
894        services.code_intelligence = self.code_intelligence;
895        services.chunk_catalog = self.chunk_catalog;
896        services.persistent_index = self.persistent_index;
897        services.workspace_retrieval = None;
898        services.git_stash = self.git_stash;
899        services.git_worktree = self.git_worktree;
900        services.operation_timeout = self.operation_timeout;
901        Arc::new(services)
902    }
903}