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