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