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