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
10pub 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
31use 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#[async_trait]
42pub trait Workspace: Send + Sync + std::fmt::Debug {
43 async fn environment(&self) -> Result<EnvironmentInfo>;
45
46 fn metadata(&self) -> WorkspaceMetadata;
48
49 async fn invalidate_environment_cache(&self);
51
52 async fn list_files(
55 &self,
56 query: Option<&str>,
57 max_results: Option<usize>,
58 ) -> Result<Vec<String>>;
59
60 fn working_directory(&self) -> &std::path::Path;
62
63 async fn read_file(
65 &self,
66 request: ReadFileRequest,
67 ctx: &WorkspaceOpContext,
68 ) -> Result<FileContentResult>;
69
70 async fn list_directory(
72 &self,
73 request: ListDirectoryRequest,
74 ctx: &WorkspaceOpContext,
75 ) -> Result<FileListResult>;
76
77 async fn glob(&self, request: GlobRequest, ctx: &WorkspaceOpContext) -> Result<GlobResult>;
79
80 async fn grep(&self, request: GrepRequest, ctx: &WorkspaceOpContext) -> Result<SearchResult>;
82
83 async fn astgrep(
85 &self,
86 request: AstGrepRequest,
87 ctx: &WorkspaceOpContext,
88 ) -> Result<SearchResult>;
89
90 async fn apply_edits(
92 &self,
93 request: ApplyEditsRequest,
94 ctx: &WorkspaceOpContext,
95 ) -> Result<EditResult>;
96
97 async fn write_file(
99 &self,
100 request: WriteFileRequest,
101 ctx: &WorkspaceOpContext,
102 ) -> Result<EditResult>;
103}
104
105#[derive(Debug, Clone, Serialize, Deserialize)]
107pub struct WorkspaceMetadata {
108 pub id: String,
109 pub workspace_type: WorkspaceType,
110 pub location: String, }
112
113#[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#[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 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#[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#[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#[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#[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#[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#[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#[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#[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#[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
306pub 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#[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#[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#[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
567pub const MAX_DIRECTORY_DEPTH: usize = 3;
569
570pub const MAX_DIRECTORY_ITEMS: usize = 1000;
572
573impl EnvironmentInfo {
574 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 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}