Skip to main content

a3s_code_core/workspace/
mod.rs

1//! Workspace capability abstractions.
2//!
3//! Built-in tools expose stable model-facing contracts (`read`, `write`, `ls`,
4//! `bash`, ...). The concrete place where those operations happen is supplied
5//! by a workspace capability backend. The default backend is the local
6//! filesystem (see [`LocalWorkspaceBackend`]); hosts can provide remote,
7//! browser, DFS, or container-backed implementations by assembling
8//! [`WorkspaceServices`] through [`WorkspaceServicesBuilder`].
9
10#[cfg(test)]
11pub(crate) mod conformance;
12mod error;
13mod local;
14mod local_access;
15mod manifest;
16mod path;
17mod remote_git;
18pub(crate) mod retrieval;
19#[cfg(feature = "s3")]
20mod s3;
21mod services;
22mod source_egress;
23
24pub use error::{WorkspaceError, WorkspaceResult};
25pub use local::LocalWorkspaceBackend;
26pub use local_access::LocalWorkspaceAccessPolicy;
27pub use manifest::{
28    scan_workspace_files, LocalWorkspaceFile, LocalWorkspaceFileStatus, LocalWorkspaceManifest,
29    LocalWorkspaceManifestSnapshot, ManifestWorkspaceBackend, RecentWorkspaceFile,
30    WorkspaceFileChange, WorkspaceFileChangeKind,
31};
32pub use path::VirtualPathResolver;
33use path::{
34    default_path_input, has_windows_path_prefix, normalize_relative_path, pathbuf_to_workspace_path,
35};
36pub(crate) use path::{escape_control_chars_for_display, validate_relative_pattern};
37pub use remote_git::{RemoteGitBackend, RemoteGitBackendConfig, RemoteGitConflict};
38pub use retrieval::{
39    ChunkCatalogLimits, ChunkCatalogSnapshot, ChunkingConfig, CustomWorkspaceChunkingStrategy,
40    FixedWindowChunkingOptions, LexicalSearchHit, LexicalSearchRequest, LexicalSearchResult,
41    RecursiveChunkingOptions, WorkspaceChunk, WorkspaceChunkCatalog, WorkspaceChunkId,
42    WorkspaceChunkRange, WorkspaceChunkingError, WorkspaceChunkingInput, WorkspaceChunkingStrategy,
43    WorkspaceEligibilityPolicy, WorkspaceEmbeddingBatchMetrics, WorkspaceHybridChannelRank,
44    WorkspaceHybridChannelStatus, WorkspaceHybridFallbackReason, WorkspaceHybridSearchHit,
45    WorkspaceHybridSearchRequest, WorkspaceHybridSearchResult, WorkspaceIndexError,
46    WorkspaceRerankAlgorithm, WorkspaceRerankFallbackReason, WorkspaceRerankMode,
47    WorkspaceRerankOptions, WorkspaceRerankStatus, WorkspaceRetrievalChannel,
48    WorkspaceRetrievalError, WorkspaceRetrievalOptions, WorkspaceRetrievalPhase,
49    WorkspaceRetrievalResult, WorkspaceRetrievalRuntime, WorkspaceRetrievalStatus,
50    WorkspaceSemanticFallbackReason, WorkspaceSemanticIndexLimits, WorkspaceSemanticSearchHit,
51    WorkspaceSemanticSearchRequest, WorkspaceSemanticSearchResult, WorkspaceVecShadowPhase,
52    WorkspaceVecShadowStatus, WorkspaceVectorEngine,
53};
54#[cfg(feature = "s3")]
55pub use s3::{S3BackendConfig, S3WorkspaceBackend};
56pub use services::{WorkspaceServices, WorkspaceServicesBuilder};
57
58use anyhow::Result;
59use async_trait::async_trait;
60use std::collections::HashMap;
61use std::sync::Arc;
62
63/// Identity and display metadata for a workspace.
64#[derive(Debug, Clone, PartialEq, Eq)]
65pub struct WorkspaceRef {
66    /// Stable workspace identifier used by host backends.
67    pub id: String,
68    /// Human-readable root shown in tool output.
69    pub display_root: String,
70}
71
72impl WorkspaceRef {
73    pub fn new(id: impl Into<String>, display_root: impl Into<String>) -> Self {
74        Self {
75            id: id.into(),
76            display_root: display_root.into(),
77        }
78    }
79}
80
81/// A normalized virtual path inside a workspace.
82#[derive(Debug, Clone, PartialEq, Eq, Hash)]
83pub struct WorkspacePath {
84    inner: String,
85}
86
87impl WorkspacePath {
88    pub fn root() -> Self {
89        Self {
90            inner: ".".to_string(),
91        }
92    }
93
94    pub fn from_normalized(path: impl Into<String>) -> Self {
95        let path = path.into();
96        let path = path.trim_matches('/');
97        if path.is_empty() || path == "." {
98            Self::root()
99        } else {
100            Self {
101                inner: path.replace('\\', "/"),
102            }
103        }
104    }
105
106    pub fn as_str(&self) -> &str {
107        &self.inner
108    }
109
110    pub fn is_root(&self) -> bool {
111        self.inner == "."
112    }
113}
114
115/// Workspace capability flags used to gate which built-in tools are registered.
116///
117/// Each flag corresponds to a provider trait on [`WorkspaceServices`]; flags
118/// without a backing provider are deliberately omitted so the surface stays
119/// minimal until a real consumer appears.
120#[derive(Debug, Clone, Copy, PartialEq, Eq)]
121pub struct WorkspaceCapabilities {
122    pub read: bool,
123    pub write: bool,
124    pub exec: bool,
125    pub search: bool,
126    pub git: bool,
127    pub code_intelligence: bool,
128}
129
130impl WorkspaceCapabilities {
131    pub fn local_default() -> Self {
132        Self {
133            read: true,
134            write: true,
135            exec: true,
136            search: true,
137            git: true,
138            code_intelligence: false,
139        }
140    }
141
142    pub fn read_write() -> Self {
143        Self {
144            read: true,
145            write: true,
146            exec: false,
147            search: false,
148            git: false,
149            code_intelligence: false,
150        }
151    }
152}
153
154impl Default for WorkspaceCapabilities {
155    fn default() -> Self {
156        Self::read_write()
157    }
158}
159
160/// Directory entry kind.
161#[derive(Debug, Clone, Copy, PartialEq, Eq)]
162pub enum WorkspaceFileType {
163    File,
164    Directory,
165    Symlink,
166    Unknown,
167}
168
169impl WorkspaceFileType {
170    pub fn as_tool_kind(self) -> &'static str {
171        match self {
172            Self::File => "file",
173            Self::Directory => "dir",
174            Self::Symlink => "link",
175            Self::Unknown => "unknown",
176        }
177    }
178}
179
180/// Directory entry returned by a workspace backend.
181#[derive(Debug, Clone, PartialEq, Eq)]
182pub struct WorkspaceDirEntry {
183    pub name: String,
184    pub kind: WorkspaceFileType,
185    pub size: u64,
186}
187
188/// Result metadata for a write operation.
189#[derive(Debug, Clone, PartialEq, Eq)]
190pub struct WorkspaceWriteOutcome {
191    pub bytes: usize,
192    pub lines: usize,
193}
194
195/// Glob request for workspace-backed search.
196#[derive(Debug, Clone, PartialEq, Eq)]
197pub struct WorkspaceGlobRequest {
198    pub base: WorkspacePath,
199    pub pattern: String,
200}
201
202/// Glob result returned by a workspace search provider.
203#[derive(Debug, Clone, PartialEq, Eq)]
204pub struct WorkspaceGlobResult {
205    pub matches: Vec<WorkspacePath>,
206}
207
208/// Grep request for workspace-backed search.
209#[derive(Debug, Clone, PartialEq, Eq)]
210pub struct WorkspaceGrepRequest {
211    pub base: WorkspacePath,
212    pub pattern: String,
213    pub glob: Option<String>,
214    pub context_lines: usize,
215    pub case_insensitive: bool,
216    /// Maximum rendered match bytes. Zero requests a metadata-only scan:
217    /// backends count every matching line and collect distinct source paths
218    /// without constructing match text.
219    pub max_output_size: usize,
220}
221
222/// Grep result returned by a workspace search provider.
223#[derive(Debug, Clone, PartialEq, Eq)]
224pub struct WorkspaceGrepResult {
225    pub output: String,
226    pub match_count: usize,
227    pub file_count: usize,
228    pub truncated: bool,
229}
230
231/// Grep result plus structured source evidence when supplied by a backend.
232#[derive(Debug, Clone, PartialEq, Eq)]
233pub struct WorkspaceGrepOutcome {
234    pub result: WorkspaceGrepResult,
235    /// Distinct paths that contributed rendered match lines, in result order.
236    /// `None` denotes a legacy/custom backend with display output only.
237    pub matched_paths: Option<Vec<WorkspacePath>>,
238}
239
240/// Repository status returned by a workspace Git provider.
241#[derive(Debug, Clone, PartialEq, Eq)]
242pub struct WorkspaceGitStatus {
243    pub branch: String,
244    pub commit: String,
245    pub is_worktree: bool,
246    pub is_dirty: bool,
247    pub dirty_count: usize,
248}
249
250/// Commit information returned by a workspace Git provider.
251#[derive(Debug, Clone, PartialEq, Eq)]
252pub struct WorkspaceGitCommit {
253    pub id: String,
254    pub message: String,
255    pub author: String,
256    pub date: String,
257}
258
259/// Branch information returned by a workspace Git provider.
260#[derive(Debug, Clone, PartialEq, Eq)]
261pub struct WorkspaceGitBranch {
262    pub name: String,
263    pub is_current: bool,
264}
265
266/// Branch creation request for a workspace Git provider.
267#[derive(Debug, Clone, PartialEq, Eq)]
268pub struct WorkspaceGitCreateBranchRequest {
269    pub name: String,
270    pub base: String,
271}
272
273/// Checkout request for a workspace Git provider.
274#[derive(Debug, Clone, PartialEq, Eq)]
275pub struct WorkspaceGitCheckoutRequest {
276    pub refspec: String,
277    pub force: bool,
278}
279
280/// Checkout output returned by a workspace Git provider.
281#[derive(Debug, Clone, PartialEq, Eq)]
282pub struct WorkspaceGitCheckoutOutput {
283    pub stdout: String,
284}
285
286/// Diff request for a workspace Git provider.
287#[derive(Debug, Clone, PartialEq, Eq)]
288pub struct WorkspaceGitDiffRequest {
289    pub target: Option<String>,
290}
291
292/// Stash information returned by a workspace Git provider.
293#[derive(Debug, Clone, PartialEq, Eq)]
294pub struct WorkspaceGitStash {
295    pub index: usize,
296    pub message: String,
297}
298
299/// Stash request for a workspace Git provider.
300#[derive(Debug, Clone, PartialEq, Eq)]
301pub struct WorkspaceGitStashRequest {
302    pub message: Option<String>,
303    pub include_untracked: bool,
304}
305
306/// Remote information returned by a workspace Git provider.
307#[derive(Debug, Clone, PartialEq, Eq)]
308pub struct WorkspaceGitRemote {
309    pub name: String,
310    pub url: String,
311    pub direction: String,
312}
313
314/// Worktree information returned by a workspace Git provider.
315#[derive(Debug, Clone, PartialEq, Eq)]
316pub struct WorkspaceGitWorktree {
317    pub path: String,
318    pub branch: String,
319    pub is_bare: bool,
320    pub is_detached: bool,
321}
322
323/// Worktree creation request for a workspace Git provider.
324#[derive(Debug, Clone, PartialEq, Eq)]
325pub struct WorkspaceGitCreateWorktreeRequest {
326    pub branch: String,
327    pub path: Option<String>,
328    pub new_branch: bool,
329}
330
331/// Worktree removal request for a workspace Git provider.
332#[derive(Debug, Clone, PartialEq, Eq)]
333pub struct WorkspaceGitRemoveWorktreeRequest {
334    pub path: String,
335    pub force: bool,
336}
337
338/// Mutation result for workspace Git worktree operations.
339#[derive(Debug, Clone, PartialEq, Eq)]
340pub struct WorkspaceGitWorktreeMutation {
341    pub path: String,
342    pub branch: Option<String>,
343}
344
345/// Observer that receives streaming output deltas from a workspace command.
346///
347/// Backend implementations call this on each chunk of stdout/stderr they
348/// observe. Tool layers wire host event channels behind this trait, so the
349/// workspace abstraction does not depend on any tool event type.
350#[async_trait]
351pub trait CommandOutputObserver: Send + Sync {
352    async fn on_output_delta(&self, delta: &str);
353
354    /// Receive the final bounded-capture accounting for the command.
355    ///
356    /// The default keeps existing remote workspace runners source-compatible.
357    /// Runners that bound output should report the original byte count so
358    /// callers can distinguish a complete result from a partial observation.
359    async fn on_output_complete(&self, _summary: &CommandOutputSummary) {}
360}
361
362/// Final accounting for a bounded command-output capture.
363#[derive(Debug, Clone, Copy, PartialEq, Eq)]
364pub struct CommandOutputSummary {
365    /// Total stdout and stderr bytes observed before completion or timeout.
366    pub total_bytes: usize,
367    /// Original process bytes retained in the rendered output.
368    pub captured_bytes: usize,
369    /// Whether bytes were omitted from the middle of the rendered output.
370    pub truncated: bool,
371    /// Whether command execution reached its own deadline.
372    pub timed_out: bool,
373}
374
375/// Command execution request.
376#[derive(Clone)]
377pub struct CommandRequest {
378    pub command: String,
379    pub timeout_ms: u64,
380    pub output_observer: Option<Arc<dyn CommandOutputObserver>>,
381    pub env: Option<Arc<HashMap<String, String>>>,
382}
383
384impl std::fmt::Debug for CommandRequest {
385    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
386        f.debug_struct("CommandRequest")
387            .field("command", &self.command)
388            .field("timeout_ms", &self.timeout_ms)
389            .field("output_observer", &self.output_observer.is_some())
390            .field("env", &self.env.as_ref().map(|env| env.len()))
391            .finish()
392    }
393}
394
395/// Command execution output.
396#[derive(Debug, Clone, PartialEq, Eq)]
397pub struct CommandOutput {
398    pub output: String,
399    pub exit_code: i32,
400    pub timed_out: bool,
401}
402
403/// Normalizes and validates host-supplied paths before they reach a backend.
404pub trait WorkspacePathResolver: Send + Sync {
405    fn normalize(&self, input: &str) -> Result<WorkspacePath>;
406}
407
408/// File operations available to built-in file tools.
409///
410/// **Trait stability policy:** new methods added to this trait are a breaking
411/// change for every external backend implementation. Until the workspace
412/// extension story is stabilised, new methods will be added to a separate
413/// `WorkspaceFileSystemExt` trait (with default implementations that fall back
414/// to the core methods) rather than to this trait directly. Backend authors
415/// can rely on this trait surface remaining additive only through extension
416/// traits.
417#[async_trait]
418pub trait WorkspaceFileSystem: Send + Sync {
419    async fn read_text(&self, path: &WorkspacePath) -> WorkspaceResult<String>;
420    async fn write_text(
421        &self,
422        path: &WorkspacePath,
423        content: &str,
424    ) -> WorkspaceResult<WorkspaceWriteOutcome>;
425    async fn list_dir(&self, path: &WorkspacePath) -> WorkspaceResult<Vec<WorkspaceDirEntry>>;
426}
427
428/// A bounded text range returned by an optional streaming workspace reader.
429#[derive(Debug, Clone, PartialEq, Eq)]
430pub struct WorkspaceTextRange {
431    pub lines: Vec<String>,
432    pub next_offset: Option<usize>,
433    pub eof: bool,
434    /// Exact line count when EOF was observed while satisfying the request.
435    pub total_lines: Option<usize>,
436}
437
438/// Optional streaming text capability for backends that can avoid loading a
439/// complete file when a caller needs only a line range.
440#[async_trait]
441pub trait WorkspaceTextReader: Send + Sync {
442    async fn read_text_range(
443        &self,
444        path: &WorkspacePath,
445        offset: usize,
446        limit: usize,
447    ) -> WorkspaceResult<WorkspaceTextRange>;
448}
449
450/// Error returned by [`WorkspaceFileSystemExt::write_text_if_version`] when
451/// the underlying object version no longer matches the expected version.
452///
453/// Surfaced through `anyhow::Error`; tools recover by downcasting:
454/// `err.downcast_ref::<WorkspaceVersionConflict>()`. The typical response is
455/// to re-read the file and retry the modify-write cycle once.
456#[derive(Debug, Clone, thiserror::Error)]
457#[error(
458    "version conflict on {path}: expected version {expected:?}, found {actual:?} (file modified by another writer; re-read and retry)"
459)]
460pub struct WorkspaceVersionConflict {
461    pub path: String,
462    pub expected: String,
463    /// Backend-reported current version, if known. S3 does not return the
464    /// current ETag on `412 Precondition Failed`, so this is typically `None`.
465    pub actual: Option<String>,
466}
467
468/// Optional compare-and-swap extensions to [`WorkspaceFileSystem`].
469///
470/// Implemented by backends that expose object-level versioning (S3 ETag,
471/// future GCS generation, ...) so tools that perform read-modify-write
472/// cycles can reject concurrent overwrites. Tools should access this through
473/// [`WorkspaceServices::fs_ext`] — when absent, callers fall back to plain
474/// `read_text` / `write_text` (last-writer-wins).
475///
476/// Kept as a separate trait rather than inheriting from
477/// [`WorkspaceFileSystem`] so existing backend implementations are not
478/// forced to opt in.
479#[async_trait]
480pub trait WorkspaceFileSystemExt: Send + Sync {
481    /// Read text content together with an opaque version token. Tokens are
482    /// backend-specific (S3 returns the ETag) and treated as opaque by
483    /// callers — they are only ever compared for equality on the backend
484    /// side.
485    async fn read_text_with_version(
486        &self,
487        path: &WorkspacePath,
488    ) -> WorkspaceResult<(String, String)>;
489
490    /// Write content iff the current object version matches `expected_version`.
491    /// On mismatch the returned error is the typed
492    /// [`WorkspaceError::VersionConflict`] variant; callers can also still
493    /// downcast through `anyhow::Error` when the value has been lifted into
494    /// the legacy result type.
495    async fn write_text_if_version(
496        &self,
497        path: &WorkspacePath,
498        content: &str,
499        expected_version: &str,
500    ) -> WorkspaceResult<WorkspaceWriteOutcome>;
501}
502
503/// Shell/command execution available to the `bash` tool.
504#[async_trait]
505pub trait WorkspaceCommandRunner: Send + Sync {
506    async fn exec(&self, request: CommandRequest) -> Result<CommandOutput>;
507}
508
509/// Search operations available to `glob` and `grep`.
510#[async_trait]
511pub trait WorkspaceSearch: Send + Sync {
512    async fn glob(&self, request: WorkspaceGlobRequest) -> Result<WorkspaceGlobResult>;
513    async fn grep(&self, request: WorkspaceGrepRequest) -> Result<WorkspaceGrepResult>;
514
515    /// Run grep with structured source paths when the backend can provide them.
516    ///
517    /// The default preserves compatibility for custom backends implementing
518    /// only [`Self::grep`]. Callers must treat its display output as untrusted.
519    async fn grep_with_sources(
520        &self,
521        request: WorkspaceGrepRequest,
522    ) -> Result<WorkspaceGrepOutcome> {
523        let result = self.grep(request).await?;
524        Ok(WorkspaceGrepOutcome {
525            result,
526            matched_paths: None,
527        })
528    }
529}
530
531/// Core Git operations supported by virtually every workspace Git backend.
532///
533/// Optional features (stash, worktrees) live in separate traits so backends
534/// like browser-side `isomorphic-git` can implement only what they support
535/// instead of returning runtime "unsupported" errors.
536#[async_trait]
537pub trait WorkspaceGit: Send + Sync {
538    async fn is_repository(&self) -> Result<bool>;
539    async fn status(&self) -> Result<WorkspaceGitStatus>;
540    async fn log(&self, max_count: usize) -> Result<Vec<WorkspaceGitCommit>>;
541    async fn list_branches(&self) -> Result<Vec<WorkspaceGitBranch>>;
542    async fn create_branch(&self, request: WorkspaceGitCreateBranchRequest) -> Result<()>;
543    async fn checkout(
544        &self,
545        request: WorkspaceGitCheckoutRequest,
546    ) -> Result<WorkspaceGitCheckoutOutput>;
547    async fn diff(&self, request: WorkspaceGitDiffRequest) -> Result<String>;
548    async fn list_remotes(&self) -> Result<Vec<WorkspaceGitRemote>>;
549}
550
551/// Optional Git stash operations.
552///
553/// Browser-side libraries such as `isomorphic-git` do not implement stash;
554/// backends that cannot stash simply do not implement this trait.
555#[async_trait]
556pub trait WorkspaceGitStashProvider: Send + Sync {
557    async fn list_stashes(&self) -> Result<Vec<WorkspaceGitStash>>;
558    async fn stash(&self, request: WorkspaceGitStashRequest) -> Result<()>;
559}
560
561/// Optional Git worktree operations.
562///
563/// Worktrees are a local-filesystem concept and are typically not supported
564/// by remote or browser-backed git providers.
565#[async_trait]
566pub trait WorkspaceGitWorktreeProvider: Send + Sync {
567    async fn list_worktrees(&self) -> Result<Vec<WorkspaceGitWorktree>>;
568    async fn create_worktree(
569        &self,
570        request: WorkspaceGitCreateWorktreeRequest,
571    ) -> Result<WorkspaceGitWorktreeMutation>;
572    async fn remove_worktree(
573        &self,
574        request: WorkspaceGitRemoveWorktreeRequest,
575    ) -> Result<WorkspaceGitWorktreeMutation>;
576}
577
578#[cfg(test)]
579#[path = "tests.rs"]
580mod tests;