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 (BM25 would stick on the portable catalog). Grep
378        // never opens this path.
379        if let Some(index) = self
380            .lazy_lexical_backend
381            .as_ref()
382            .and_then(|backend| backend.ensure_persistent_index())
383        {
384            let _ = self.ensure_lazy_lexical();
385            return Some(index);
386        }
387        if let Some(handles) = self.ensure_lazy_lexical() {
388            if let Some(index) = &handles.1 {
389                return Some(Arc::clone(index));
390            }
391        }
392        None
393    }
394
395    fn ensure_lazy_lexical(&self) -> Option<&LazyLexicalHandles> {
396        let backend = self.lazy_lexical_backend.as_ref()?;
397        Some(self.lazy_lexical.get_or_init(|| {
398            let catalog = backend.chunk_catalog();
399            // Do not open durable FTS merely because the catalog was touched.
400            let persistent = backend.persistent_index();
401            (catalog, persistent)
402        }))
403    }
404
405    /// Optional semantic retrieval runtime bound to this workspace session.
406    pub fn workspace_retrieval(&self) -> Option<Arc<WorkspaceRetrievalRuntime>> {
407        self.workspace_retrieval.clone()
408    }
409
410    /// Search the session-owned semantic index and verify every returned
411    /// source chunk against the workspace backend before exposing its text.
412    pub async fn semantic_search(
413        &self,
414        mut request: super::WorkspaceSemanticSearchRequest,
415        cancellation: tokio_util::sync::CancellationToken,
416    ) -> super::WorkspaceRetrievalResult<super::WorkspaceSemanticSearchResult> {
417        if !self.capabilities.read {
418            return Err(super::WorkspaceRetrievalError::Unavailable);
419        }
420        if let Some(path) = request.path.take() {
421            request.path = Some(
422                self.path_resolver
423                    .normalize(&path)
424                    .map_err(|_| {
425                        super::WorkspaceRetrievalError::InvalidQuery(
426                            "path was rejected by the workspace resolver".to_owned(),
427                        )
428                    })?
429                    .as_str()
430                    .to_owned(),
431            );
432        }
433        let runtime = self
434            .workspace_retrieval
435            .as_ref()
436            .ok_or(super::WorkspaceRetrievalError::Unavailable)?;
437        runtime
438            .search(
439                request,
440                Arc::clone(&self.file_system),
441                self.operation_timeout,
442                cancellation,
443            )
444            .await
445    }
446
447    /// Run exact, lexical, structural, and semantic retrieval against one
448    /// catalog revision, fuse channel ranks, and verify returned source text.
449    pub async fn hybrid_search(
450        &self,
451        mut request: super::WorkspaceHybridSearchRequest,
452        cancellation: tokio_util::sync::CancellationToken,
453    ) -> super::WorkspaceRetrievalResult<super::WorkspaceHybridSearchResult> {
454        if !self.capabilities.read {
455            return Err(super::WorkspaceRetrievalError::Unavailable);
456        }
457        if let Some(path) = request.path.take() {
458            request.path = Some(
459                self.path_resolver
460                    .normalize(&path)
461                    .map_err(|_| {
462                        super::WorkspaceRetrievalError::InvalidQuery(
463                            "path was rejected by the workspace resolver".to_owned(),
464                        )
465                    })?
466                    .as_str()
467                    .to_owned(),
468            );
469        }
470        let runtime = self
471            .workspace_retrieval
472            .as_ref()
473            .ok_or(super::WorkspaceRetrievalError::Unavailable)?;
474        runtime
475            .hybrid_search(
476                request,
477                Arc::clone(&self.file_system),
478                self.code_intelligence.clone(),
479                self.operation_timeout,
480                cancellation,
481            )
482            .await
483    }
484
485    pub(crate) fn with_workspace_retrieval(
486        &self,
487        runtime: Arc<WorkspaceRetrievalRuntime>,
488    ) -> Option<Arc<Self>> {
489        if self.workspace_retrieval.is_some() {
490            return None;
491        }
492        Some(Arc::new(Self {
493            workspace_ref: self.workspace_ref.clone(),
494            capabilities: self.capabilities,
495            path_resolver: Arc::clone(&self.path_resolver),
496            file_system: Arc::clone(&self.file_system),
497            file_system_ext: self.file_system_ext.clone(),
498            text_reader: self.text_reader.clone(),
499            command_runner: self.command_runner.clone(),
500            search: self.search.clone(),
501            code_intelligence: self.code_intelligence.clone(),
502            chunk_catalog: self.chunk_catalog.clone(),
503            persistent_index: self.persistent_index.clone(),
504            lazy_lexical_backend: self.lazy_lexical_backend.clone(),
505            lazy_lexical: Arc::clone(&self.lazy_lexical),
506            workspace_retrieval: Some(runtime),
507            git: self.git.clone(),
508            git_stash: self.git_stash.clone(),
509            git_worktree: self.git_worktree.clone(),
510            operation_timeout: self.operation_timeout,
511            local_root: self.local_root.clone(),
512        }))
513    }
514
515    /// Attach a semantic code query provider while preserving every existing
516    /// workspace capability and backend.
517    pub fn with_code_intelligence(
518        &self,
519        provider: Arc<dyn WorkspaceCodeIntelligence>,
520    ) -> Arc<Self> {
521        let mut capabilities = self.capabilities;
522        capabilities.code_intelligence = true;
523        Arc::new(Self {
524            workspace_ref: self.workspace_ref.clone(),
525            capabilities,
526            path_resolver: Arc::clone(&self.path_resolver),
527            file_system: Arc::clone(&self.file_system),
528            file_system_ext: self.file_system_ext.clone(),
529            text_reader: self.text_reader.clone(),
530            command_runner: self.command_runner.clone(),
531            search: self.search.clone(),
532            code_intelligence: Some(provider),
533            chunk_catalog: self.chunk_catalog.clone(),
534            persistent_index: self.persistent_index.clone(),
535            lazy_lexical_backend: self.lazy_lexical_backend.clone(),
536            lazy_lexical: Arc::clone(&self.lazy_lexical),
537            workspace_retrieval: self.workspace_retrieval.clone(),
538            git: self.git.clone(),
539            git_stash: self.git_stash.clone(),
540            git_worktree: self.git_worktree.clone(),
541            operation_timeout: self.operation_timeout,
542            local_root: self.local_root.clone(),
543        })
544    }
545
546    pub fn git(&self) -> Option<Arc<dyn WorkspaceGit>> {
547        self.git.clone()
548    }
549
550    pub fn git_stash(&self) -> Option<Arc<dyn WorkspaceGitStashProvider>> {
551        self.git_stash.clone()
552    }
553
554    pub fn git_worktree(&self) -> Option<Arc<dyn WorkspaceGitWorktreeProvider>> {
555        self.git_worktree.clone()
556    }
557
558    /// Internal helper used by decorators (`with_remote_git` and any
559    /// future git-provider override) to swap the git layer of an existing
560    /// `WorkspaceServices` without losing unrelated fields.
561    ///
562    /// Every field is **explicitly listed** in the returned struct
563    /// literal. This is the point of the helper — adding a new field to
564    /// `WorkspaceServices` will trip a compile error here, and the author
565    /// of that new field has to decide whether a git-provider swap
566    /// preserves it. Previously the decorator went through
567    /// `WorkspaceServicesBuilder`, which silently dropped any field the
568    /// builder did not know about (notably `local_root`).
569    ///
570    /// `git_worktree` is reset to `None` because worktree operations are
571    /// part of the same domain as the git provider — keeping the local
572    /// worktree provider while routing `status`/`log`/`diff` to a remote
573    /// server would surface inconsistent state to the model.
574    pub(crate) fn with_git_provider(
575        &self,
576        git: Arc<dyn WorkspaceGit>,
577        git_stash: Option<Arc<dyn WorkspaceGitStashProvider>>,
578    ) -> Arc<Self> {
579        let mut capabilities = self.capabilities;
580        capabilities.git = true;
581        Arc::new(Self {
582            workspace_ref: self.workspace_ref.clone(),
583            capabilities,
584            path_resolver: Arc::clone(&self.path_resolver),
585            file_system: Arc::clone(&self.file_system),
586            file_system_ext: self.file_system_ext.clone(),
587            text_reader: self.text_reader.clone(),
588            command_runner: self.command_runner.clone(),
589            search: self.search.clone(),
590            code_intelligence: self.code_intelligence.clone(),
591            chunk_catalog: self.chunk_catalog.clone(),
592            persistent_index: self.persistent_index.clone(),
593            lazy_lexical_backend: self.lazy_lexical_backend.clone(),
594            lazy_lexical: Arc::clone(&self.lazy_lexical),
595            workspace_retrieval: self.workspace_retrieval.clone(),
596            git: Some(git),
597            git_stash,
598            git_worktree: None,
599            operation_timeout: self.operation_timeout,
600            local_root: self.local_root.clone(),
601        })
602    }
603
604    /// Default timeout applied to non-bash workspace operations.
605    ///
606    /// `None` means no enforced timeout. Backends that may stall (remote,
607    /// browser, DFS) should set this so tools using [`Self::run_with_timeout`]
608    /// surface a timeout error instead of letting the agent loop hang.
609    pub fn operation_timeout(&self) -> Option<std::time::Duration> {
610        self.operation_timeout
611    }
612
613    /// Run a workspace future under the configured operation timeout.
614    ///
615    /// Tools that route through file system / search / git providers should
616    /// wrap their calls with this helper so non-local backends never stall
617    /// the agent loop indefinitely.
618    ///
619    /// Polymorphic in the error type so the helper works equally well for
620    /// futures returning `anyhow::Result<T>` (the legacy callers — search,
621    /// git, etc.) and for futures returning [`WorkspaceResult<T>`] (the
622    /// migrated `WorkspaceFileSystem` callers). The `E: From<anyhow::Error>`
623    /// bound is satisfied by both `anyhow::Error` (trivially) and
624    /// [`WorkspaceError`] (via its `#[from]` `Backend` variant); a timeout
625    /// surfaces as that From conversion of an `anyhow!(...)` message.
626    pub async fn run_with_timeout<F, T, E>(
627        &self,
628        op: &'static str,
629        fut: F,
630    ) -> std::result::Result<T, E>
631    where
632        F: std::future::Future<Output = std::result::Result<T, E>>,
633        E: From<anyhow::Error>,
634    {
635        match self.operation_timeout {
636            Some(d) => tokio::time::timeout(d, fut).await.map_err(|_| {
637                E::from(anyhow!(
638                    "workspace operation '{}' timed out after {:?}",
639                    op,
640                    d
641                ))
642            })?,
643            None => fut.await,
644        }
645    }
646
647    /// Read a file for a subsequent modify-write cycle, requesting a version
648    /// token when the backend supports compare-and-swap writes.
649    ///
650    /// Returns `(content, Some(version))` when [`Self::fs_ext`] is available
651    /// (e.g. on S3, where the version is the object ETag); `(content, None)`
652    /// otherwise. Pair with [`Self::write_for_edit`].
653    pub async fn read_for_edit(
654        &self,
655        path: &WorkspacePath,
656    ) -> WorkspaceResult<(String, Option<String>)> {
657        if let Some(ext) = self.fs_ext() {
658            let path = path.clone();
659            return self
660                .run_with_timeout("read_text_with_version", async move {
661                    let (content, version) = ext.read_text_with_version(&path).await?;
662                    Ok((content, Some(version)))
663                })
664                .await;
665        }
666        let fs = self.fs();
667        let path_owned = path.clone();
668        let content = self
669            .run_with_timeout("read_text", async move { fs.read_text(&path_owned).await })
670            .await?;
671        Ok((content, None))
672    }
673
674    /// Companion to [`Self::read_for_edit`]. Performs a compare-and-swap
675    /// write when both [`Self::fs_ext`] is available *and* a version token
676    /// was returned by the prior read; falls back to a plain write
677    /// otherwise. On version mismatch the returned error is the typed
678    /// [`WorkspaceError::VersionConflict`] variant; callers can also still
679    /// downcast `anyhow::Error::downcast_ref::<WorkspaceVersionConflict>()`
680    /// when the value has been lifted into an `anyhow::Result`.
681    pub async fn write_for_edit(
682        &self,
683        path: &WorkspacePath,
684        content: &str,
685        expected_version: Option<&str>,
686    ) -> WorkspaceResult<WorkspaceWriteOutcome> {
687        if let (Some(ext), Some(version)) = (self.fs_ext(), expected_version) {
688            let path = path.clone();
689            let content = content.to_string();
690            let expected = version.to_string();
691            return self
692                .run_with_timeout("write_text_if_version", async move {
693                    ext.write_text_if_version(&path, &content, &expected).await
694                })
695                .await;
696        }
697        let fs = self.fs();
698        let path = path.clone();
699        let content = content.to_string();
700        self.run_with_timeout(
701            "write_text",
702            async move { fs.write_text(&path, &content).await },
703        )
704        .await
705    }
706
707    pub fn local_root(&self) -> Option<&Path> {
708        self.local_root.as_deref()
709    }
710
711    pub fn display_path(&self, path: &WorkspacePath) -> String {
712        if path.is_root() {
713            return self.workspace_ref.display_root.clone();
714        }
715
716        let root = self.workspace_ref.display_root.trim_end_matches('/');
717        if root.is_empty() {
718            path.as_str().to_string()
719        } else {
720            format!("{root}/{}", path.as_str())
721        }
722    }
723}
724
725/// Builder for assembling workspace services without constructor arity churn.
726pub struct WorkspaceServicesBuilder {
727    workspace_ref: WorkspaceRef,
728    capabilities: WorkspaceCapabilities,
729    path_resolver: Arc<dyn WorkspacePathResolver>,
730    file_system: Arc<dyn WorkspaceFileSystem>,
731    file_system_ext: Option<Arc<dyn WorkspaceFileSystemExt>>,
732    text_reader: Option<Arc<dyn WorkspaceTextReader>>,
733    command_runner: Option<Arc<dyn WorkspaceCommandRunner>>,
734    search: Option<Arc<dyn WorkspaceSearch>>,
735    code_intelligence: Option<Arc<dyn WorkspaceCodeIntelligence>>,
736    chunk_catalog: Option<Arc<WorkspaceChunkCatalog>>,
737    persistent_index: Option<Arc<WorkspacePersistentIndex>>,
738    git: Option<Arc<dyn WorkspaceGit>>,
739    git_stash: Option<Arc<dyn WorkspaceGitStashProvider>>,
740    git_worktree: Option<Arc<dyn WorkspaceGitWorktreeProvider>>,
741    operation_timeout: Option<std::time::Duration>,
742}
743
744impl WorkspaceServicesBuilder {
745    pub fn new(workspace_ref: WorkspaceRef, file_system: Arc<dyn WorkspaceFileSystem>) -> Self {
746        Self {
747            workspace_ref,
748            capabilities: WorkspaceCapabilities::read_write(),
749            path_resolver: Arc::new(VirtualPathResolver),
750            file_system,
751            file_system_ext: None,
752            text_reader: None,
753            command_runner: None,
754            search: None,
755            code_intelligence: None,
756            chunk_catalog: None,
757            persistent_index: None,
758            git: None,
759            git_stash: None,
760            git_worktree: None,
761            operation_timeout: None,
762        }
763    }
764
765    pub fn capabilities(mut self, capabilities: WorkspaceCapabilities) -> Self {
766        self.capabilities = capabilities;
767        self
768    }
769
770    pub fn command_runner(mut self, command_runner: Arc<dyn WorkspaceCommandRunner>) -> Self {
771        self.capabilities.exec = true;
772        self.command_runner = Some(command_runner);
773        self
774    }
775
776    pub fn search(mut self, search: Arc<dyn WorkspaceSearch>) -> Self {
777        self.capabilities.search = true;
778        self.search = Some(search);
779        self
780    }
781
782    pub fn code_intelligence(mut self, provider: Arc<dyn WorkspaceCodeIntelligence>) -> Self {
783        self.capabilities.code_intelligence = true;
784        self.code_intelligence = Some(provider);
785        self
786    }
787
788    /// Attach a typed, session-local workspace retrieval catalog.
789    pub fn chunk_catalog(mut self, catalog: Arc<WorkspaceChunkCatalog>) -> Self {
790        self.chunk_catalog = Some(catalog);
791        self
792    }
793
794    pub fn persistent_index(mut self, index: Arc<WorkspacePersistentIndex>) -> Self {
795        self.persistent_index = Some(index);
796        self
797    }
798
799    pub fn git(mut self, git: Arc<dyn WorkspaceGit>) -> Self {
800        self.capabilities.git = true;
801        self.git = Some(git);
802        self
803    }
804
805    pub fn git_stash(mut self, git_stash: Arc<dyn WorkspaceGitStashProvider>) -> Self {
806        self.git_stash = Some(git_stash);
807        self
808    }
809
810    pub fn git_worktree(mut self, git_worktree: Arc<dyn WorkspaceGitWorktreeProvider>) -> Self {
811        self.git_worktree = Some(git_worktree);
812        self
813    }
814
815    /// Attach optional compare-and-swap file system extensions
816    /// ([`WorkspaceFileSystemExt`]). Tools that perform read-modify-write
817    /// cycles will pick this up via [`WorkspaceServices::read_for_edit`]
818    /// and [`WorkspaceServices::write_for_edit`].
819    pub fn file_system_ext(mut self, ext: Arc<dyn WorkspaceFileSystemExt>) -> Self {
820        self.file_system_ext = Some(ext);
821        self
822    }
823
824    pub fn text_reader(mut self, reader: Arc<dyn WorkspaceTextReader>) -> Self {
825        self.text_reader = Some(reader);
826        self
827    }
828
829    /// Apply a default timeout to non-bash workspace operations (file system,
830    /// search, git). Backends that may stall — remote, browser, DFS — should
831    /// set this so tools surface a timeout error rather than hanging.
832    pub fn operation_timeout(mut self, timeout: std::time::Duration) -> Self {
833        self.operation_timeout = Some(timeout);
834        self
835    }
836
837    pub fn build(self) -> Arc<WorkspaceServices> {
838        let mut services = WorkspaceServices::new_with_git(
839            self.workspace_ref,
840            self.capabilities,
841            self.path_resolver,
842            self.file_system,
843            self.command_runner,
844            self.search,
845            self.git,
846        );
847        services.file_system_ext = self.file_system_ext;
848        services.text_reader = self.text_reader;
849        services.capabilities.code_intelligence = self.code_intelligence.is_some();
850        services.code_intelligence = self.code_intelligence;
851        services.chunk_catalog = self.chunk_catalog;
852        services.persistent_index = self.persistent_index;
853        services.workspace_retrieval = None;
854        services.git_stash = self.git_stash;
855        services.git_worktree = self.git_worktree;
856        services.operation_timeout = self.operation_timeout;
857        Arc::new(services)
858    }
859}