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