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