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    WorkspaceCommandRunner, WorkspaceFileSystem, WorkspaceFileSystemExt, WorkspaceGit,
8    WorkspaceGitStashProvider, WorkspaceGitWorktreeProvider, WorkspacePath, WorkspacePathResolver,
9    WorkspaceRef, WorkspaceResult, WorkspaceSearch, WorkspaceTextReader, WorkspaceWriteOutcome,
10};
11use crate::code_intelligence::{LocalCodeIntelligence, WorkspaceCodeIntelligence};
12use anyhow::{anyhow, Result};
13use std::path::{Path, PathBuf};
14use std::sync::Arc;
15
16/// The host-provided workspace capability bundle used by tool execution.
17pub struct WorkspaceServices {
18    workspace_ref: WorkspaceRef,
19    capabilities: WorkspaceCapabilities,
20    path_resolver: Arc<dyn WorkspacePathResolver>,
21    file_system: Arc<dyn WorkspaceFileSystem>,
22    file_system_ext: Option<Arc<dyn WorkspaceFileSystemExt>>,
23    text_reader: Option<Arc<dyn WorkspaceTextReader>>,
24    command_runner: Option<Arc<dyn WorkspaceCommandRunner>>,
25    search: Option<Arc<dyn WorkspaceSearch>>,
26    code_intelligence: Option<Arc<dyn WorkspaceCodeIntelligence>>,
27    git: Option<Arc<dyn WorkspaceGit>>,
28    git_stash: Option<Arc<dyn WorkspaceGitStashProvider>>,
29    git_worktree: Option<Arc<dyn WorkspaceGitWorktreeProvider>>,
30    /// Default timeout applied to non-bash workspace operations (file system,
31    /// search, git). Bash uses its own per-call timeout in [`CommandRequest`].
32    /// `None` means no enforced timeout — appropriate for the local backend.
33    operation_timeout: Option<std::time::Duration>,
34    local_root: Option<PathBuf>,
35}
36
37impl std::fmt::Debug for WorkspaceServices {
38    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
39        f.debug_struct("WorkspaceServices")
40            .field("workspace_ref", &self.workspace_ref)
41            .field("capabilities", &self.capabilities)
42            .field("file_system_ext", &self.file_system_ext.is_some())
43            .field("text_reader", &self.text_reader.is_some())
44            .field("command_runner", &self.command_runner.is_some())
45            .field("search", &self.search.is_some())
46            .field("code_intelligence", &self.code_intelligence.is_some())
47            .field("git", &self.git.is_some())
48            .field("git_stash", &self.git_stash.is_some())
49            .field("git_worktree", &self.git_worktree.is_some())
50            .field("local_root", &self.local_root)
51            .finish()
52    }
53}
54
55impl WorkspaceServices {
56    pub(crate) fn new_with_git(
57        workspace_ref: WorkspaceRef,
58        mut capabilities: WorkspaceCapabilities,
59        path_resolver: Arc<dyn WorkspacePathResolver>,
60        file_system: Arc<dyn WorkspaceFileSystem>,
61        command_runner: Option<Arc<dyn WorkspaceCommandRunner>>,
62        search: Option<Arc<dyn WorkspaceSearch>>,
63        git: Option<Arc<dyn WorkspaceGit>>,
64    ) -> Self {
65        if command_runner.is_none() {
66            capabilities.exec = false;
67        }
68        if search.is_none() {
69            capabilities.search = false;
70        }
71        if git.is_none() {
72            capabilities.git = false;
73        }
74        capabilities.code_intelligence = false;
75        Self {
76            workspace_ref,
77            capabilities,
78            path_resolver,
79            file_system,
80            file_system_ext: None,
81            text_reader: None,
82            command_runner,
83            search,
84            code_intelligence: None,
85            git,
86            git_stash: None,
87            git_worktree: None,
88            operation_timeout: None,
89            local_root: None,
90        }
91    }
92
93    pub fn builder(
94        workspace_ref: WorkspaceRef,
95        file_system: Arc<dyn WorkspaceFileSystem>,
96    ) -> WorkspaceServicesBuilder {
97        WorkspaceServicesBuilder::new(workspace_ref, file_system)
98    }
99
100    pub fn local(root: impl Into<PathBuf>) -> Arc<Self> {
101        let backend = Arc::new(LocalWorkspaceBackend::new(root.into()));
102        let workspace_ref = WorkspaceRef::new(
103            backend.root.display().to_string(),
104            backend.root.display().to_string(),
105        );
106        let path_resolver: Arc<dyn WorkspacePathResolver> = backend.clone();
107        let file_system: Arc<dyn WorkspaceFileSystem> = backend.clone();
108        let text_reader: Arc<dyn WorkspaceTextReader> = backend.clone();
109        let command_runner: Arc<dyn WorkspaceCommandRunner> = backend.clone();
110        let search: Arc<dyn WorkspaceSearch> = backend.clone();
111        let git: Arc<dyn WorkspaceGit> = backend.clone();
112        let git_stash: Arc<dyn WorkspaceGitStashProvider> = backend.clone();
113        let git_worktree: Arc<dyn WorkspaceGitWorktreeProvider> = backend.clone();
114        Arc::new(Self {
115            workspace_ref,
116            capabilities: WorkspaceCapabilities::local_default(),
117            path_resolver,
118            file_system,
119            file_system_ext: None,
120            text_reader: Some(text_reader),
121            command_runner: Some(command_runner),
122            search: Some(search),
123            code_intelligence: None,
124            git: Some(git),
125            git_stash: Some(git_stash),
126            git_worktree: Some(git_worktree),
127            operation_timeout: None,
128            local_root: Some(backend.root.clone()),
129        })
130    }
131
132    /// Local workspace services backed by an in-memory file manifest for
133    /// search. `read`/`write`/`ls`/`bash`/`git` preserve local backend
134    /// behavior; `glob` and `grep` use the manifest once the initial scan has
135    /// completed and fall back to filesystem search before that.
136    pub fn local_with_manifest(root: impl Into<PathBuf>) -> Arc<Self> {
137        let backend = ManifestWorkspaceBackend::new(root);
138        Self::local_with_manifest_backend(backend)
139    }
140
141    /// Build local manifest-backed services with native Code Intelligence.
142    ///
143    /// The provider subscribes to the manifest's existing change stream and
144    /// therefore does not create a second filesystem watcher or file index.
145    pub async fn local_with_code_intelligence(
146        root: impl Into<PathBuf>,
147        isolation_scope: impl Into<String>,
148    ) -> Result<Arc<Self>> {
149        let backend = ManifestWorkspaceBackend::new(root);
150        Self::local_with_code_intelligence_backend(backend, isolation_scope).await
151    }
152
153    /// Attach native Code Intelligence to one shared manifest backend.
154    pub async fn local_with_code_intelligence_backend(
155        backend: Arc<ManifestWorkspaceBackend>,
156        isolation_scope: impl Into<String>,
157    ) -> Result<Arc<Self>> {
158        let manifest = backend.manifest();
159        let file_system: Arc<dyn WorkspaceFileSystem> = backend.clone();
160        let services = Self::local_with_manifest_backend(backend);
161        let provider = LocalCodeIntelligence::start(isolation_scope, manifest, file_system)
162            .await
163            .map_err(|error| anyhow!("failed to start Code Intelligence: {error}"))?;
164        Ok(services.with_code_intelligence(provider))
165    }
166
167    /// Build local workspace services from a shared manifest backend. Hosts
168    /// can keep the same manifest for UI file pickers and agent tools.
169    pub fn local_with_manifest_backend(backend: Arc<ManifestWorkspaceBackend>) -> Arc<Self> {
170        let workspace_ref = WorkspaceRef::new(
171            backend.local_root().display().to_string(),
172            backend.local_root().display().to_string(),
173        );
174        let path_resolver: Arc<dyn WorkspacePathResolver> = backend.clone();
175        let file_system: Arc<dyn WorkspaceFileSystem> = backend.clone();
176        let text_reader: Arc<dyn WorkspaceTextReader> = backend.clone();
177        let command_runner: Arc<dyn WorkspaceCommandRunner> = backend.clone();
178        let search: Arc<dyn WorkspaceSearch> = backend.clone();
179        let git: Arc<dyn WorkspaceGit> = backend.clone();
180        let git_stash: Arc<dyn WorkspaceGitStashProvider> = backend.clone();
181        let git_worktree: Arc<dyn WorkspaceGitWorktreeProvider> = backend.clone();
182        Arc::new(Self {
183            workspace_ref,
184            capabilities: WorkspaceCapabilities::local_default(),
185            path_resolver,
186            file_system,
187            file_system_ext: None,
188            text_reader: Some(text_reader),
189            command_runner: Some(command_runner),
190            search: Some(search),
191            code_intelligence: None,
192            git: Some(git),
193            git_stash: Some(git_stash),
194            git_worktree: Some(git_worktree),
195            operation_timeout: None,
196            local_root: Some(backend.local_root().to_path_buf()),
197        })
198    }
199
200    pub fn workspace_ref(&self) -> &WorkspaceRef {
201        &self.workspace_ref
202    }
203
204    pub fn capabilities(&self) -> WorkspaceCapabilities {
205        self.capabilities
206    }
207
208    pub fn normalize_path(&self, input: &str) -> Result<WorkspacePath> {
209        self.path_resolver.normalize(input)
210    }
211
212    pub fn fs(&self) -> Arc<dyn WorkspaceFileSystem> {
213        Arc::clone(&self.file_system)
214    }
215
216    /// Optional compare-and-swap file system extensions.
217    ///
218    /// Returns `Some` when the backend supports version-aware writes (e.g.
219    /// S3 via ETag). Tools that perform read-modify-write cycles should
220    /// route through [`Self::read_for_edit`] and [`Self::write_for_edit`]
221    /// rather than touching this directly.
222    pub fn fs_ext(&self) -> Option<Arc<dyn WorkspaceFileSystemExt>> {
223        self.file_system_ext.clone()
224    }
225
226    pub fn text_reader(&self) -> Option<Arc<dyn WorkspaceTextReader>> {
227        self.text_reader.clone()
228    }
229
230    pub fn command_runner(&self) -> Option<Arc<dyn WorkspaceCommandRunner>> {
231        self.command_runner.clone()
232    }
233
234    pub fn search(&self) -> Option<Arc<dyn WorkspaceSearch>> {
235        self.search.clone()
236    }
237
238    /// Optional workspace-scoped semantic code query provider.
239    pub fn code_intelligence(&self) -> Option<Arc<dyn WorkspaceCodeIntelligence>> {
240        self.code_intelligence.clone()
241    }
242
243    /// Attach a semantic code query provider while preserving every existing
244    /// workspace capability and backend.
245    pub fn with_code_intelligence(
246        &self,
247        provider: Arc<dyn WorkspaceCodeIntelligence>,
248    ) -> Arc<Self> {
249        let mut capabilities = self.capabilities;
250        capabilities.code_intelligence = true;
251        Arc::new(Self {
252            workspace_ref: self.workspace_ref.clone(),
253            capabilities,
254            path_resolver: Arc::clone(&self.path_resolver),
255            file_system: Arc::clone(&self.file_system),
256            file_system_ext: self.file_system_ext.clone(),
257            text_reader: self.text_reader.clone(),
258            command_runner: self.command_runner.clone(),
259            search: self.search.clone(),
260            code_intelligence: Some(provider),
261            git: self.git.clone(),
262            git_stash: self.git_stash.clone(),
263            git_worktree: self.git_worktree.clone(),
264            operation_timeout: self.operation_timeout,
265            local_root: self.local_root.clone(),
266        })
267    }
268
269    pub fn git(&self) -> Option<Arc<dyn WorkspaceGit>> {
270        self.git.clone()
271    }
272
273    pub fn git_stash(&self) -> Option<Arc<dyn WorkspaceGitStashProvider>> {
274        self.git_stash.clone()
275    }
276
277    pub fn git_worktree(&self) -> Option<Arc<dyn WorkspaceGitWorktreeProvider>> {
278        self.git_worktree.clone()
279    }
280
281    /// Internal helper used by decorators (`with_remote_git` and any
282    /// future git-provider override) to swap the git layer of an existing
283    /// `WorkspaceServices` without losing unrelated fields.
284    ///
285    /// Every field is **explicitly listed** in the returned struct
286    /// literal. This is the point of the helper — adding a new field to
287    /// `WorkspaceServices` will trip a compile error here, and the author
288    /// of that new field has to decide whether a git-provider swap
289    /// preserves it. Previously the decorator went through
290    /// `WorkspaceServicesBuilder`, which silently dropped any field the
291    /// builder did not know about (notably `local_root`).
292    ///
293    /// `git_worktree` is reset to `None` because worktree operations are
294    /// part of the same domain as the git provider — keeping the local
295    /// worktree provider while routing `status`/`log`/`diff` to a remote
296    /// server would surface inconsistent state to the model.
297    pub(crate) fn with_git_provider(
298        &self,
299        git: Arc<dyn WorkspaceGit>,
300        git_stash: Option<Arc<dyn WorkspaceGitStashProvider>>,
301    ) -> Arc<Self> {
302        let mut capabilities = self.capabilities;
303        capabilities.git = true;
304        Arc::new(Self {
305            workspace_ref: self.workspace_ref.clone(),
306            capabilities,
307            path_resolver: Arc::clone(&self.path_resolver),
308            file_system: Arc::clone(&self.file_system),
309            file_system_ext: self.file_system_ext.clone(),
310            text_reader: self.text_reader.clone(),
311            command_runner: self.command_runner.clone(),
312            search: self.search.clone(),
313            code_intelligence: self.code_intelligence.clone(),
314            git: Some(git),
315            git_stash,
316            git_worktree: None,
317            operation_timeout: self.operation_timeout,
318            local_root: self.local_root.clone(),
319        })
320    }
321
322    /// Default timeout applied to non-bash workspace operations.
323    ///
324    /// `None` means no enforced timeout. Backends that may stall (remote,
325    /// browser, DFS) should set this so tools using [`Self::run_with_timeout`]
326    /// surface a timeout error instead of letting the agent loop hang.
327    pub fn operation_timeout(&self) -> Option<std::time::Duration> {
328        self.operation_timeout
329    }
330
331    /// Run a workspace future under the configured operation timeout.
332    ///
333    /// Tools that route through file system / search / git providers should
334    /// wrap their calls with this helper so non-local backends never stall
335    /// the agent loop indefinitely.
336    ///
337    /// Polymorphic in the error type so the helper works equally well for
338    /// futures returning `anyhow::Result<T>` (the legacy callers — search,
339    /// git, etc.) and for futures returning [`WorkspaceResult<T>`] (the
340    /// migrated `WorkspaceFileSystem` callers). The `E: From<anyhow::Error>`
341    /// bound is satisfied by both `anyhow::Error` (trivially) and
342    /// [`WorkspaceError`] (via its `#[from]` `Backend` variant); a timeout
343    /// surfaces as that From conversion of an `anyhow!(...)` message.
344    pub async fn run_with_timeout<F, T, E>(
345        &self,
346        op: &'static str,
347        fut: F,
348    ) -> std::result::Result<T, E>
349    where
350        F: std::future::Future<Output = std::result::Result<T, E>>,
351        E: From<anyhow::Error>,
352    {
353        match self.operation_timeout {
354            Some(d) => tokio::time::timeout(d, fut).await.map_err(|_| {
355                E::from(anyhow!(
356                    "workspace operation '{}' timed out after {:?}",
357                    op,
358                    d
359                ))
360            })?,
361            None => fut.await,
362        }
363    }
364
365    /// Read a file for a subsequent modify-write cycle, requesting a version
366    /// token when the backend supports compare-and-swap writes.
367    ///
368    /// Returns `(content, Some(version))` when [`Self::fs_ext`] is available
369    /// (e.g. on S3, where the version is the object ETag); `(content, None)`
370    /// otherwise. Pair with [`Self::write_for_edit`].
371    pub async fn read_for_edit(
372        &self,
373        path: &WorkspacePath,
374    ) -> WorkspaceResult<(String, Option<String>)> {
375        if let Some(ext) = self.fs_ext() {
376            let path = path.clone();
377            return self
378                .run_with_timeout("read_text_with_version", async move {
379                    let (content, version) = ext.read_text_with_version(&path).await?;
380                    Ok((content, Some(version)))
381                })
382                .await;
383        }
384        let fs = self.fs();
385        let path_owned = path.clone();
386        let content = self
387            .run_with_timeout("read_text", async move { fs.read_text(&path_owned).await })
388            .await?;
389        Ok((content, None))
390    }
391
392    /// Companion to [`Self::read_for_edit`]. Performs a compare-and-swap
393    /// write when both [`Self::fs_ext`] is available *and* a version token
394    /// was returned by the prior read; falls back to a plain write
395    /// otherwise. On version mismatch the returned error is the typed
396    /// [`WorkspaceError::VersionConflict`] variant; callers can also still
397    /// downcast `anyhow::Error::downcast_ref::<WorkspaceVersionConflict>()`
398    /// when the value has been lifted into an `anyhow::Result`.
399    pub async fn write_for_edit(
400        &self,
401        path: &WorkspacePath,
402        content: &str,
403        expected_version: Option<&str>,
404    ) -> WorkspaceResult<WorkspaceWriteOutcome> {
405        if let (Some(ext), Some(version)) = (self.fs_ext(), expected_version) {
406            let path = path.clone();
407            let content = content.to_string();
408            let expected = version.to_string();
409            return self
410                .run_with_timeout("write_text_if_version", async move {
411                    ext.write_text_if_version(&path, &content, &expected).await
412                })
413                .await;
414        }
415        let fs = self.fs();
416        let path = path.clone();
417        let content = content.to_string();
418        self.run_with_timeout(
419            "write_text",
420            async move { fs.write_text(&path, &content).await },
421        )
422        .await
423    }
424
425    pub fn local_root(&self) -> Option<&Path> {
426        self.local_root.as_deref()
427    }
428
429    pub fn display_path(&self, path: &WorkspacePath) -> String {
430        if path.is_root() {
431            return self.workspace_ref.display_root.clone();
432        }
433
434        let root = self.workspace_ref.display_root.trim_end_matches('/');
435        if root.is_empty() {
436            path.as_str().to_string()
437        } else {
438            format!("{root}/{}", path.as_str())
439        }
440    }
441}
442
443/// Builder for assembling workspace services without constructor arity churn.
444pub struct WorkspaceServicesBuilder {
445    workspace_ref: WorkspaceRef,
446    capabilities: WorkspaceCapabilities,
447    path_resolver: Arc<dyn WorkspacePathResolver>,
448    file_system: Arc<dyn WorkspaceFileSystem>,
449    file_system_ext: Option<Arc<dyn WorkspaceFileSystemExt>>,
450    text_reader: Option<Arc<dyn WorkspaceTextReader>>,
451    command_runner: Option<Arc<dyn WorkspaceCommandRunner>>,
452    search: Option<Arc<dyn WorkspaceSearch>>,
453    code_intelligence: Option<Arc<dyn WorkspaceCodeIntelligence>>,
454    git: Option<Arc<dyn WorkspaceGit>>,
455    git_stash: Option<Arc<dyn WorkspaceGitStashProvider>>,
456    git_worktree: Option<Arc<dyn WorkspaceGitWorktreeProvider>>,
457    operation_timeout: Option<std::time::Duration>,
458}
459
460impl WorkspaceServicesBuilder {
461    pub fn new(workspace_ref: WorkspaceRef, file_system: Arc<dyn WorkspaceFileSystem>) -> Self {
462        Self {
463            workspace_ref,
464            capabilities: WorkspaceCapabilities::read_write(),
465            path_resolver: Arc::new(VirtualPathResolver),
466            file_system,
467            file_system_ext: None,
468            text_reader: None,
469            command_runner: None,
470            search: None,
471            code_intelligence: None,
472            git: None,
473            git_stash: None,
474            git_worktree: None,
475            operation_timeout: None,
476        }
477    }
478
479    pub fn capabilities(mut self, capabilities: WorkspaceCapabilities) -> Self {
480        self.capabilities = capabilities;
481        self
482    }
483
484    pub fn command_runner(mut self, command_runner: Arc<dyn WorkspaceCommandRunner>) -> Self {
485        self.capabilities.exec = true;
486        self.command_runner = Some(command_runner);
487        self
488    }
489
490    pub fn search(mut self, search: Arc<dyn WorkspaceSearch>) -> Self {
491        self.capabilities.search = true;
492        self.search = Some(search);
493        self
494    }
495
496    pub fn code_intelligence(mut self, provider: Arc<dyn WorkspaceCodeIntelligence>) -> Self {
497        self.capabilities.code_intelligence = true;
498        self.code_intelligence = Some(provider);
499        self
500    }
501
502    pub fn git(mut self, git: Arc<dyn WorkspaceGit>) -> Self {
503        self.capabilities.git = true;
504        self.git = Some(git);
505        self
506    }
507
508    pub fn git_stash(mut self, git_stash: Arc<dyn WorkspaceGitStashProvider>) -> Self {
509        self.git_stash = Some(git_stash);
510        self
511    }
512
513    pub fn git_worktree(mut self, git_worktree: Arc<dyn WorkspaceGitWorktreeProvider>) -> Self {
514        self.git_worktree = Some(git_worktree);
515        self
516    }
517
518    /// Attach optional compare-and-swap file system extensions
519    /// ([`WorkspaceFileSystemExt`]). Tools that perform read-modify-write
520    /// cycles will pick this up via [`WorkspaceServices::read_for_edit`]
521    /// and [`WorkspaceServices::write_for_edit`].
522    pub fn file_system_ext(mut self, ext: Arc<dyn WorkspaceFileSystemExt>) -> Self {
523        self.file_system_ext = Some(ext);
524        self
525    }
526
527    pub fn text_reader(mut self, reader: Arc<dyn WorkspaceTextReader>) -> Self {
528        self.text_reader = Some(reader);
529        self
530    }
531
532    /// Apply a default timeout to non-bash workspace operations (file system,
533    /// search, git). Backends that may stall — remote, browser, DFS — should
534    /// set this so tools surface a timeout error rather than hanging.
535    pub fn operation_timeout(mut self, timeout: std::time::Duration) -> Self {
536        self.operation_timeout = Some(timeout);
537        self
538    }
539
540    pub fn build(self) -> Arc<WorkspaceServices> {
541        let mut services = WorkspaceServices::new_with_git(
542            self.workspace_ref,
543            self.capabilities,
544            self.path_resolver,
545            self.file_system,
546            self.command_runner,
547            self.search,
548            self.git,
549        );
550        services.file_system_ext = self.file_system_ext;
551        services.text_reader = self.text_reader;
552        services.capabilities.code_intelligence = self.code_intelligence.is_some();
553        services.code_intelligence = self.code_intelligence;
554        services.git_stash = self.git_stash;
555        services.git_worktree = self.git_worktree;
556        services.operation_timeout = self.operation_timeout;
557        Arc::new(services)
558    }
559}