vv_agent/workspace/
memory.rs1use std::any::Any;
2use std::collections::{BTreeMap, BTreeSet};
3use std::sync::{Arc, Mutex};
4
5use super::{
6 artifacts::is_reserved_artifact_path, current_utc_isoformat, exclusive_workspace_path,
7 glob_match, insert_parent_dirs, normalize_workspace_path, normalized_glob_pattern, not_found,
8 suffix_with_dot, FileInfo, WorkspaceBackend,
9};
10
11#[derive(Debug, Clone)]
12pub struct MemoryWorkspaceBackend {
13 files: Arc<Mutex<BTreeMap<String, Vec<u8>>>>,
14 dirs: Arc<Mutex<BTreeSet<String>>>,
15}
16
17impl Default for MemoryWorkspaceBackend {
18 fn default() -> Self {
19 let mut dirs = BTreeSet::new();
20 dirs.insert(String::new());
21 Self {
22 files: Arc::new(Mutex::new(BTreeMap::new())),
23 dirs: Arc::new(Mutex::new(dirs)),
24 }
25 }
26}
27
28impl WorkspaceBackend for MemoryWorkspaceBackend {
29 fn as_any(&self) -> &dyn Any {
30 self
31 }
32
33 fn list_files(&self, base: &str, glob: &str) -> std::io::Result<Vec<String>> {
34 let base = normalize_workspace_path(base);
35 if is_reserved_artifact_path(&base) {
36 return Ok(Vec::new());
37 }
38 let glob = normalized_glob_pattern(glob);
39 let pattern = if base.is_empty() {
40 glob
41 } else {
42 format!("{base}/{glob}")
43 };
44 let files = self.files.lock().expect("memory workspace poisoned");
45 let mut matches = files
46 .keys()
47 .filter(|path| !is_reserved_artifact_path(path))
48 .filter(|path| glob_match(path, &pattern))
49 .cloned()
50 .collect::<Vec<_>>();
51 matches.sort();
52 Ok(matches)
53 }
54
55 fn read_text(&self, path: &str) -> std::io::Result<String> {
56 let bytes = self.read_bytes(path)?;
57 Ok(String::from_utf8_lossy(&bytes).to_string())
58 }
59
60 fn read_bytes(&self, path: &str) -> std::io::Result<Vec<u8>> {
61 let key = normalize_workspace_path(path);
62 let files = self.files.lock().expect("memory workspace poisoned");
63 files.get(&key).cloned().ok_or_else(|| not_found(path))
64 }
65
66 fn write_text(&self, path: &str, content: &str, append: bool) -> std::io::Result<usize> {
67 if is_reserved_artifact_path(path) {
68 return Err(std::io::Error::new(
69 std::io::ErrorKind::PermissionDenied,
70 "artifact paths are immutable",
71 ));
72 }
73 let key = normalize_workspace_path(path);
74 let mut files = self.files.lock().expect("memory workspace poisoned");
75 let entry = files.entry(key.clone()).or_default();
76 if append {
77 entry.extend_from_slice(content.as_bytes());
78 } else {
79 *entry = content.as_bytes().to_vec();
80 }
81 drop(files);
82 self.ensure_parent_dirs(&key);
83 Ok(content.len())
84 }
85
86 fn write_text_exclusive(&self, path: &str, content: &str) -> std::io::Result<usize> {
87 let mut chunks = std::iter::once(Ok(content.to_string()));
88 self.write_text_chunks_exclusive(path, &mut chunks)
89 }
90
91 fn write_text_chunks_exclusive(
92 &self,
93 path: &str,
94 chunks: &mut dyn Iterator<Item = std::io::Result<String>>,
95 ) -> std::io::Result<usize> {
96 let key = exclusive_workspace_path(path)?;
97 let mut data = Vec::new();
98 for chunk in chunks {
99 data.extend_from_slice(chunk?.as_bytes());
100 }
101 let size = data.len();
102 let mut files = self.files.lock().expect("memory workspace poisoned");
103 let dirs = self.dirs.lock().expect("memory workspace poisoned");
104 if files.contains_key(&key) || dirs.contains(&key) {
105 return Err(std::io::Error::new(
106 std::io::ErrorKind::AlreadyExists,
107 format!("path already exists: {path}"),
108 ));
109 }
110 for parent in key.split('/').scan(String::new(), |current, segment| {
111 if !current.is_empty() {
112 current.push('/');
113 }
114 current.push_str(segment);
115 Some(current.clone())
116 }) {
117 if parent != key && files.contains_key(&parent) {
118 return Err(std::io::Error::new(
119 std::io::ErrorKind::NotADirectory,
120 format!("exclusive path parent is not a directory: {parent}"),
121 ));
122 }
123 }
124 files.insert(key.clone(), data);
125 drop(dirs);
126 drop(files);
127 self.ensure_parent_dirs(&key);
128 Ok(size)
129 }
130
131 fn file_info(&self, path: &str) -> std::io::Result<Option<FileInfo>> {
132 let key = normalize_workspace_path(path);
133 let files = self.files.lock().expect("memory workspace poisoned");
134 if let Some(content) = files.get(&key) {
135 return Ok(Some(FileInfo {
136 path: key,
137 is_file: true,
138 is_dir: false,
139 size: content.len() as u64,
140 modified_at: current_utc_isoformat(),
141 suffix: suffix_with_dot(path),
142 }));
143 }
144 drop(files);
145 let dirs = self.dirs.lock().expect("memory workspace poisoned");
146 Ok(dirs.get(&key).map(|path| FileInfo {
147 path: if path.is_empty() {
148 ".".to_string()
149 } else {
150 path.clone()
151 },
152 is_file: false,
153 is_dir: true,
154 size: 0,
155 modified_at: current_utc_isoformat(),
156 suffix: String::new(),
157 }))
158 }
159
160 fn exists(&self, path: &str) -> bool {
161 let key = normalize_workspace_path(path);
162 self.files
163 .lock()
164 .expect("memory workspace poisoned")
165 .contains_key(&key)
166 || self
167 .dirs
168 .lock()
169 .expect("memory workspace poisoned")
170 .contains(&key)
171 }
172
173 fn is_file(&self, path: &str) -> bool {
174 let key = normalize_workspace_path(path);
175 self.files
176 .lock()
177 .expect("memory workspace poisoned")
178 .contains_key(&key)
179 }
180
181 fn mkdir(&self, path: &str) -> std::io::Result<()> {
182 let key = normalize_workspace_path(path);
183 let mut dirs = self.dirs.lock().expect("memory workspace poisoned");
184 dirs.insert(key.clone());
185 insert_parent_dirs(&mut dirs, &key);
186 Ok(())
187 }
188}
189
190impl MemoryWorkspaceBackend {
191 fn ensure_parent_dirs(&self, key: &str) {
192 let mut dirs = self.dirs.lock().expect("memory workspace poisoned");
193 insert_parent_dirs(&mut dirs, key);
194 }
195}