Skip to main content

steer_workspace/
lib.rs

1pub mod config;
2pub mod error;
3pub mod local;
4pub mod manager;
5pub mod ops;
6pub mod result;
7pub mod utils;
8mod workspace_registry;
9
10// Re-export main types
11pub use config::{RemoteAuth, WorkspaceConfig};
12pub use error::{
13    EnvironmentManagerError, EnvironmentManagerResult, Result, WorkspaceError,
14    WorkspaceManagerError, WorkspaceManagerResult,
15};
16pub use local::LocalEnvironmentManager;
17pub use local::LocalWorkspaceManager;
18pub use manager::{
19    CreateEnvironmentRequest, CreateWorkspaceRequest, DeleteWorkspaceRequest,
20    EnvironmentDeletePolicy, EnvironmentDescriptor, EnvironmentManager, ListWorkspacesRequest,
21    RepoManager, WorkspaceCreateStrategy, WorkspaceManager,
22};
23pub use ops::{
24    ApplyEditsRequest, AstGrepRequest, EditOperation, GlobRequest, GrepRequest,
25    ListDirectoryRequest, ReadFileRequest, WorkspaceOpContext, WriteFileRequest,
26};
27pub use result::{
28    EditResult, FileContentResult, FileEntry, FileListResult, GlobResult, SearchMatch, SearchResult,
29};
30
31// Module with the trait and core types
32use async_trait::async_trait;
33#[cfg(feature = "schema")]
34use schemars::JsonSchema;
35use serde::{Deserialize, Serialize};
36use std::time::{Duration, Instant};
37use tracing::debug;
38use uuid::Uuid;
39
40/// Core workspace abstraction for environment information and file operations
41#[async_trait]
42pub trait Workspace: Send + Sync + std::fmt::Debug {
43    /// Get environment information for this workspace
44    async fn environment(&self) -> Result<EnvironmentInfo>;
45
46    /// Get workspace metadata
47    fn metadata(&self) -> WorkspaceMetadata;
48
49    /// Invalidate cached environment information (force refresh on next call)
50    async fn invalidate_environment_cache(&self);
51
52    /// List files in the workspace for fuzzy finding
53    /// Returns workspace-relative paths, filtered by optional query
54    async fn list_files(
55        &self,
56        query: Option<&str>,
57        max_results: Option<usize>,
58    ) -> Result<Vec<String>>;
59
60    /// Get the working directory for this workspace
61    fn working_directory(&self) -> &std::path::Path;
62
63    /// Read file contents with optional offset/limit.
64    async fn read_file(
65        &self,
66        request: ReadFileRequest,
67        ctx: &WorkspaceOpContext,
68    ) -> Result<FileContentResult>;
69
70    /// List a directory (similar to ls).
71    async fn list_directory(
72        &self,
73        request: ListDirectoryRequest,
74        ctx: &WorkspaceOpContext,
75    ) -> Result<FileListResult>;
76
77    /// Apply glob patterns.
78    async fn glob(&self, request: GlobRequest, ctx: &WorkspaceOpContext) -> Result<GlobResult>;
79
80    /// Text search (grep-style).
81    async fn grep(&self, request: GrepRequest, ctx: &WorkspaceOpContext) -> Result<SearchResult>;
82
83    /// AST search (astgrep-style).
84    async fn astgrep(
85        &self,
86        request: AstGrepRequest,
87        ctx: &WorkspaceOpContext,
88    ) -> Result<SearchResult>;
89
90    /// Apply one or more edits to a file.
91    async fn apply_edits(
92        &self,
93        request: ApplyEditsRequest,
94        ctx: &WorkspaceOpContext,
95    ) -> Result<EditResult>;
96
97    /// Write/replace entire file content.
98    async fn write_file(
99        &self,
100        request: WriteFileRequest,
101        ctx: &WorkspaceOpContext,
102    ) -> Result<EditResult>;
103}
104
105/// Metadata about a workspace
106#[derive(Debug, Clone, Serialize, Deserialize)]
107pub struct WorkspaceMetadata {
108    pub id: String,
109    pub workspace_type: WorkspaceType,
110    pub location: String, // local path, remote URL, or container ID
111}
112
113/// Type of workspace
114#[derive(Debug, Clone, Serialize, Deserialize)]
115pub enum WorkspaceType {
116    Local,
117    Remote,
118}
119
120impl WorkspaceType {
121    pub fn as_str(&self) -> &'static str {
122        match self {
123            WorkspaceType::Local => "Local",
124            WorkspaceType::Remote => "Remote",
125        }
126    }
127}
128
129/// Stable identifier for an execution environment.
130#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
131#[cfg_attr(feature = "schema", derive(JsonSchema))]
132#[serde(transparent)]
133pub struct EnvironmentId(#[cfg_attr(feature = "schema", schemars(with = "String"))] pub Uuid);
134
135impl Default for EnvironmentId {
136    fn default() -> Self {
137        Self::new()
138    }
139}
140
141impl EnvironmentId {
142    pub fn new() -> Self {
143        Self(Uuid::new_v4())
144    }
145
146    pub fn from_uuid(uuid: Uuid) -> Self {
147        Self(uuid)
148    }
149
150    pub fn as_uuid(&self) -> Uuid {
151        self.0
152    }
153
154    /// Identifier for the implicit local environment.
155    pub fn local() -> Self {
156        Self(Uuid::nil())
157    }
158
159    pub fn is_local(&self) -> bool {
160        self.0.is_nil()
161    }
162}
163
164/// Stable identifier for a workspace within an environment.
165#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
166#[cfg_attr(feature = "schema", derive(JsonSchema))]
167#[serde(transparent)]
168pub struct WorkspaceId(#[cfg_attr(feature = "schema", schemars(with = "String"))] pub Uuid);
169
170impl Default for WorkspaceId {
171    fn default() -> Self {
172        Self::new()
173    }
174}
175
176impl WorkspaceId {
177    pub fn new() -> Self {
178        Self(Uuid::new_v4())
179    }
180
181    pub fn from_uuid(uuid: Uuid) -> Self {
182        Self(uuid)
183    }
184
185    pub fn as_uuid(&self) -> Uuid {
186        self.0
187    }
188}
189
190/// Stable identifier for a repository within an environment.
191#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
192#[cfg_attr(feature = "schema", derive(JsonSchema))]
193#[serde(transparent)]
194pub struct RepoId(#[cfg_attr(feature = "schema", schemars(with = "String"))] pub Uuid);
195
196impl Default for RepoId {
197    fn default() -> Self {
198        Self::new()
199    }
200}
201
202impl RepoId {
203    pub fn new() -> Self {
204        Self(Uuid::new_v4())
205    }
206
207    pub fn from_uuid(uuid: Uuid) -> Self {
208        Self(uuid)
209    }
210
211    pub fn as_uuid(&self) -> Uuid {
212        self.0
213    }
214}
215
216/// Reference to a repository inside a specific environment.
217#[derive(Debug, Clone, Serialize, Deserialize)]
218#[cfg_attr(feature = "schema", derive(JsonSchema))]
219pub struct RepoRef {
220    pub environment_id: EnvironmentId,
221    pub repo_id: RepoId,
222    pub root_path: std::path::PathBuf,
223    pub vcs_kind: Option<VcsKind>,
224}
225
226/// Repository metadata for listing and workspace grouping.
227#[derive(Debug, Clone, Serialize, Deserialize)]
228#[cfg_attr(feature = "schema", derive(JsonSchema))]
229pub struct RepoInfo {
230    pub repo_id: RepoId,
231    pub environment_id: EnvironmentId,
232    pub root_path: std::path::PathBuf,
233    pub vcs_kind: Option<VcsKind>,
234}
235
236/// Reference to a workspace inside a specific environment.
237#[derive(Debug, Clone, Serialize, Deserialize)]
238#[cfg_attr(feature = "schema", derive(JsonSchema))]
239pub struct WorkspaceRef {
240    pub environment_id: EnvironmentId,
241    pub workspace_id: WorkspaceId,
242    pub repo_id: RepoId,
243}
244
245/// Workspace metadata for listing and UI grouping.
246#[derive(Debug, Clone, Serialize, Deserialize)]
247#[cfg_attr(feature = "schema", derive(JsonSchema))]
248pub struct WorkspaceInfo {
249    pub workspace_id: WorkspaceId,
250    pub environment_id: EnvironmentId,
251    pub repo_id: RepoId,
252    pub parent_workspace_id: Option<WorkspaceId>,
253    pub name: Option<String>,
254    pub path: std::path::PathBuf,
255}
256
257/// Workspace status for orchestration and UI display.
258#[derive(Debug, Clone, Serialize, Deserialize)]
259pub struct WorkspaceStatus {
260    pub workspace_id: WorkspaceId,
261    pub environment_id: EnvironmentId,
262    pub repo_id: RepoId,
263    pub path: std::path::PathBuf,
264    pub vcs: Option<VcsInfo>,
265}
266
267/// Cached environment information with TTL
268#[derive(Debug, Clone)]
269pub(crate) struct CachedEnvironment {
270    pub info: EnvironmentInfo,
271    pub cached_at: Instant,
272    pub ttl: Duration,
273}
274
275impl CachedEnvironment {
276    pub fn new(info: EnvironmentInfo, ttl: Duration) -> Self {
277        Self {
278            info,
279            cached_at: Instant::now(),
280            ttl,
281        }
282    }
283
284    pub fn is_expired(&self) -> bool {
285        self.cached_at.elapsed() > self.ttl
286    }
287}
288
289/// Supported version control systems
290#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
291#[cfg_attr(feature = "schema", derive(JsonSchema))]
292pub enum VcsKind {
293    Git,
294    Jj,
295}
296
297impl VcsKind {
298    pub fn as_str(&self) -> &'static str {
299        match self {
300            VcsKind::Git => "git",
301            VcsKind::Jj => "jj",
302        }
303    }
304}
305
306/// Trait for status types that can render LLM-readable summaries.
307pub trait LlmStatus {
308    fn as_llm_string(&self) -> String;
309}
310
311#[derive(Debug, Clone, Serialize, Deserialize)]
312pub enum GitHead {
313    Branch(String),
314    Detached,
315    Unborn,
316}
317
318#[derive(Debug, Clone, Serialize, Deserialize)]
319pub enum GitStatusSummary {
320    Added,
321    Removed,
322    Modified,
323    TypeChange,
324    Renamed,
325    Copied,
326    IntentToAdd,
327    Conflict,
328}
329
330#[derive(Debug, Clone, Serialize, Deserialize)]
331pub struct GitStatusEntry {
332    pub summary: GitStatusSummary,
333    pub path: String,
334}
335
336#[derive(Debug, Clone, Serialize, Deserialize)]
337pub struct GitCommitSummary {
338    pub id: String,
339    pub summary: String,
340}
341
342#[derive(Debug, Clone, Serialize, Deserialize)]
343pub struct GitStatus {
344    pub head: Option<GitHead>,
345    pub entries: Vec<GitStatusEntry>,
346    pub recent_commits: Vec<GitCommitSummary>,
347    pub error: Option<String>,
348}
349
350impl GitStatus {
351    pub fn new(
352        head: GitHead,
353        entries: Vec<GitStatusEntry>,
354        recent_commits: Vec<GitCommitSummary>,
355    ) -> Self {
356        Self {
357            head: Some(head),
358            entries,
359            recent_commits,
360            error: None,
361        }
362    }
363
364    pub fn unavailable(message: impl Into<String>) -> Self {
365        Self {
366            head: None,
367            entries: Vec::new(),
368            recent_commits: Vec::new(),
369            error: Some(message.into()),
370        }
371    }
372}
373
374impl LlmStatus for GitStatus {
375    fn as_llm_string(&self) -> String {
376        if let Some(error) = &self.error {
377            return format!("Status unavailable: {error}");
378        }
379        let head = match &self.head {
380            Some(head) => head,
381            None => return "Status unavailable: missing git head".to_string(),
382        };
383
384        let mut result = String::new();
385        match head {
386            GitHead::Branch(branch) => {
387                result.push_str(&format!("Current branch: {branch}\n\n"));
388            }
389            GitHead::Detached => {
390                result.push_str("Current branch: HEAD (detached)\n\n");
391            }
392            GitHead::Unborn => {
393                result.push_str("Current branch: <unborn>\n\n");
394            }
395        }
396
397        result.push_str("Status:\n");
398        if self.entries.is_empty() {
399            result.push_str("Working tree clean\n");
400        } else {
401            for entry in &self.entries {
402                let (status_char, wt_char) = match entry.summary {
403                    GitStatusSummary::Added => (' ', '?'),
404                    GitStatusSummary::Removed => ('D', ' '),
405                    GitStatusSummary::Modified => ('M', ' '),
406                    GitStatusSummary::TypeChange => ('T', ' '),
407                    GitStatusSummary::Renamed => ('R', ' '),
408                    GitStatusSummary::Copied => ('C', ' '),
409                    GitStatusSummary::IntentToAdd => ('A', ' '),
410                    GitStatusSummary::Conflict => ('U', 'U'),
411                };
412                result.push_str(&format!("{status_char}{wt_char} {}\n", entry.path));
413            }
414        }
415
416        result.push_str("\nRecent commits:\n");
417        if self.recent_commits.is_empty() {
418            result.push_str("<no commits>\n");
419        } else {
420            for commit in &self.recent_commits {
421                result.push_str(&format!("{} {}\n", commit.id, commit.summary));
422            }
423        }
424
425        result
426    }
427}
428
429#[derive(Debug, Clone, Serialize, Deserialize)]
430pub enum JjChangeType {
431    Added,
432    Removed,
433    Modified,
434}
435
436#[derive(Debug, Clone, Serialize, Deserialize)]
437pub struct JjChange {
438    pub change_type: JjChangeType,
439    pub path: String,
440}
441
442#[derive(Debug, Clone, Serialize, Deserialize)]
443pub struct JjCommitSummary {
444    pub change_id: String,
445    pub commit_id: String,
446    pub description: String,
447}
448
449#[derive(Debug, Clone, Serialize, Deserialize)]
450pub struct JjStatus {
451    pub changes: Vec<JjChange>,
452    pub working_copy: Option<JjCommitSummary>,
453    pub parents: Vec<JjCommitSummary>,
454    pub error: Option<String>,
455}
456
457impl JjStatus {
458    pub fn new(
459        changes: Vec<JjChange>,
460        working_copy: JjCommitSummary,
461        parents: Vec<JjCommitSummary>,
462    ) -> Self {
463        Self {
464            changes,
465            working_copy: Some(working_copy),
466            parents,
467            error: None,
468        }
469    }
470
471    pub fn unavailable(message: impl Into<String>) -> Self {
472        Self {
473            changes: Vec::new(),
474            working_copy: None,
475            parents: Vec::new(),
476            error: Some(message.into()),
477        }
478    }
479}
480
481impl LlmStatus for JjStatus {
482    fn as_llm_string(&self) -> String {
483        if let Some(error) = &self.error {
484            return format!("Status unavailable: {error}");
485        }
486        let working_copy = match &self.working_copy {
487            Some(working_copy) => working_copy,
488            None => return "Status unavailable: missing jj working copy".to_string(),
489        };
490
491        let mut status = String::new();
492        status.push_str("Working copy changes:\n");
493        if self.changes.is_empty() {
494            status.push_str("<none>\n");
495        } else {
496            for change in &self.changes {
497                let status_char = match change.change_type {
498                    JjChangeType::Added => 'A',
499                    JjChangeType::Removed => 'D',
500                    JjChangeType::Modified => 'M',
501                };
502                status.push_str(&format!("{status_char} {}\n", change.path));
503            }
504        }
505        status.push_str(&format!(
506            "Working copy (@): {} {} {}\n",
507            working_copy.change_id, working_copy.commit_id, working_copy.description
508        ));
509
510        if self.parents.is_empty() {
511            status.push_str("Parent commit (@-): <none>\n");
512        } else {
513            for (index, parent) in self.parents.iter().enumerate() {
514                let marker = if index == 0 {
515                    "(@-)".to_string()
516                } else {
517                    format!("(@-{})", index + 1)
518                };
519                status.push_str(&format!(
520                    "Parent commit {marker}: {} {} {}\n",
521                    parent.change_id, parent.commit_id, parent.description
522                ));
523            }
524        }
525
526        status
527    }
528}
529
530/// VCS-specific status data.
531#[derive(Debug, Clone, Serialize, Deserialize)]
532pub enum VcsStatus {
533    Git(GitStatus),
534    Jj(JjStatus),
535}
536
537impl LlmStatus for VcsStatus {
538    fn as_llm_string(&self) -> String {
539        match self {
540            VcsStatus::Git(status) => status.as_llm_string(),
541            VcsStatus::Jj(status) => status.as_llm_string(),
542        }
543    }
544}
545
546/// Version control information for a workspace
547#[derive(Debug, Clone, Serialize, Deserialize)]
548pub struct VcsInfo {
549    pub kind: VcsKind,
550    pub root: std::path::PathBuf,
551    pub status: VcsStatus,
552}
553
554/// Environment information for a workspace
555#[derive(Debug, Clone, Serialize, Deserialize)]
556pub struct EnvironmentInfo {
557    pub working_directory: std::path::PathBuf,
558    pub vcs: Option<VcsInfo>,
559    pub platform: String,
560    pub date: String,
561    pub directory_structure: String,
562    pub readme_content: Option<String>,
563    pub memory_file_name: Option<String>,
564    pub memory_file_content: Option<String>,
565}
566
567/// Default maximum depth for directory structure traversal
568pub const MAX_DIRECTORY_DEPTH: usize = 3;
569
570/// Default maximum number of items to include in directory structure
571pub const MAX_DIRECTORY_ITEMS: usize = 1000;
572
573impl EnvironmentInfo {
574    /// Collect environment information for a given path
575    pub fn collect_for_path(path: &std::path::Path) -> Result<Self> {
576        use crate::utils::{DirectoryStructureUtils, EnvironmentUtils, VcsUtils};
577
578        let platform = EnvironmentUtils::get_platform().to_string();
579        let date = EnvironmentUtils::get_current_date();
580
581        let directory_structure = DirectoryStructureUtils::get_directory_structure(
582            path,
583            MAX_DIRECTORY_DEPTH,
584            Some(MAX_DIRECTORY_ITEMS),
585        )?;
586        debug!("directory_structure: {}", directory_structure);
587
588        let readme_content = EnvironmentUtils::read_readme(path);
589        let (memory_file_name, memory_file_content) = match EnvironmentUtils::read_memory_file(path)
590        {
591            Some((name, content)) => (Some(name), Some(content)),
592            None => (None, None),
593        };
594
595        Ok(Self {
596            working_directory: path.to_path_buf(),
597            vcs: VcsUtils::collect_vcs_info(path),
598            platform,
599            date,
600            directory_structure,
601            readme_content,
602            memory_file_name,
603            memory_file_content,
604        })
605    }
606
607    /// Format environment info as context for system prompt
608    pub fn as_context(&self) -> String {
609        let vcs_line = match &self.vcs {
610            Some(vcs) => format!("VCS: {} ({})", vcs.kind.as_str(), vcs.root.display()),
611            None => "VCS: none".to_string(),
612        };
613        let mut context = format!(
614            "Here is useful information about the environment you are running in:\n<env>\nWorking directory: {}\n{}\nPlatform: {}\nToday's date: {}\n</env>",
615            self.working_directory.display(),
616            vcs_line,
617            self.platform,
618            self.date
619        );
620
621        if !self.directory_structure.is_empty() {
622            context.push_str(&format!("\n\n<file_structure>\nBelow is a snapshot of this project's file structure at the start of the conversation. The file structure may be filtered to omit `.gitignore`ed patterns. This snapshot will NOT update during the conversation.\n\n{}\n</file_structure>", self.directory_structure));
623        }
624
625        if let Some(ref vcs) = self.vcs {
626            context.push_str(&format!(
627                "\n<vcs_status>\nThis is the VCS status at the start of the conversation. Note that this status is a snapshot in time, and will not update during the conversation.\n\nVCS: {}\nRoot: {}\n\n{}\n</vcs_status>",
628                vcs.kind.as_str(),
629                vcs.root.display(),
630                vcs.status.as_llm_string()
631            ));
632        }
633
634        if let Some(ref readme) = self.readme_content {
635            context.push_str(&format!("\n<file name=\"README.md\">\nThis is the README.md file at the start of the conversation. Note that this README is a snapshot in time, and will not update during the conversation.\n\n{readme}\n</file>"));
636        }
637
638        if let (Some(name), Some(content)) = (&self.memory_file_name, &self.memory_file_content) {
639            context.push_str(&format!("\n<file name=\"{name}\">\nThis is the {name} file at the start of the conversation. Note that this file is a snapshot in time, and will not update during the conversation.\n\n{content}\n</file>"));
640        }
641
642        context
643    }
644}