ai_agent/bridge/
bridge_pointer.rs1use serde::{Deserialize, Serialize};
20use std::fs;
21use std::path::PathBuf;
22use std::time::{SystemTime, UNIX_EPOCH};
23
24const MAX_WORKTREE_FANOUT: usize = 50;
28
29pub const BRIDGE_POINTER_TTL_MS: u64 = 4 * 60 * 60 * 1000;
31
32#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
34#[serde(rename_all = "lowercase")]
35pub enum BridgePointerSource {
36 Standalone,
37 Repl,
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct BridgePointer {
43 #[serde(rename = "sessionId")]
44 pub session_id: String,
45 #[serde(rename = "environmentId")]
46 pub environment_id: String,
47 pub source: BridgePointerSource,
48}
49
50#[derive(Debug, Clone)]
52pub struct BridgePointerWithAge {
53 pub session_id: String,
54 pub environment_id: String,
55 pub source: BridgePointerSource,
56 pub age_ms: u64,
57}
58
59pub fn get_bridge_pointer_path(dir: &str) -> PathBuf {
61 let projects_dir = get_projects_dir();
63 let sanitized = sanitize_path(dir);
64 projects_dir.join(sanitized).join("bridge-pointer.json")
65}
66
67fn get_projects_dir() -> PathBuf {
69 dirs::data_local_dir()
70 .unwrap_or_else(|| PathBuf::from("."))
71 .join("ai-code")
72 .join("projects")
73}
74
75fn sanitize_path(path: &str) -> String {
77 path.chars()
79 .map(|c| {
80 if c == '/' || c == '\\' || c == ':' {
81 '-'
82 } else {
83 c
84 }
85 })
86 .collect()
87}
88
89pub async fn write_bridge_pointer(dir: &str, pointer: &BridgePointer) -> Result<(), String> {
94 let path = get_bridge_pointer_path(dir);
95
96 if let Some(parent) = path.parent() {
98 fs::create_dir_all(parent).map_err(|e| format!("Failed to create directory: {}", e))?;
99 }
100
101 let content =
103 serde_json::to_string_pretty(pointer).map_err(|e| format!("Failed to serialize: {}", e))?;
104
105 fs::write(&path, content).map_err(|e| format!("Failed to write pointer: {}", e))?;
106
107 log_for_debugging(&format!("[bridge:pointer] wrote {}", path.display()));
108
109 Ok(())
110}
111
112pub async fn read_bridge_pointer(dir: &str) -> Option<BridgePointerWithAge> {
118 let path = get_bridge_pointer_path(dir);
119
120 let metadata = match fs::metadata(&path) {
122 Ok(m) => m,
123 Err(_) => return None,
124 };
125
126 let mtime_ms = metadata
127 .modified()
128 .ok()?
129 .duration_since(UNIX_EPOCH)
130 .ok()?
131 .as_millis() as u64;
132
133 let raw = match fs::read_to_string(&path) {
135 Ok(c) => c,
136 Err(_) => return None,
137 };
138
139 let parsed: BridgePointer = match serde_json::from_str(&raw) {
141 Ok(p) => p,
142 Err(_) => {
143 log_for_debugging(&format!(
144 "[bridge:pointer] invalid schema, clearing: {}",
145 path.display()
146 ));
147 let _ = clear_bridge_pointer(dir).await;
148 return None;
149 }
150 };
151
152 let age_ms = SystemTime::now()
154 .duration_since(UNIX_EPOCH)
155 .unwrap()
156 .as_millis() as u64
157 - mtime_ms;
158
159 if age_ms > BRIDGE_POINTER_TTL_MS {
160 log_for_debugging(&format!(
161 "[bridge:pointer] stale (>4h mtime), clearing: {}",
162 path.display()
163 ));
164 let _ = clear_bridge_pointer(dir).await;
165 return None;
166 }
167
168 Some(BridgePointerWithAge {
169 session_id: parsed.session_id,
170 environment_id: parsed.environment_id,
171 source: parsed.source,
172 age_ms,
173 })
174}
175
176pub async fn read_bridge_pointer_across_worktrees(
189 dir: &str,
190) -> Option<(BridgePointerWithAge, String)> {
191 if let Some(pointer) = read_bridge_pointer(dir).await {
194 return Some((pointer, dir.to_string()));
195 }
196
197 let worktrees = get_worktree_paths(dir).await?;
199 if worktrees.len() <= 1 {
200 return None;
201 }
202 if worktrees.len() > MAX_WORKTREE_FANOUT {
203 log_for_debugging(&format!(
204 "[bridge:pointer] {} worktrees exceeds fanout cap {}, skipping",
205 worktrees.len(),
206 MAX_WORKTREE_FANOUT
207 ));
208 return None;
209 }
210
211 let dir_key = sanitize_path(dir);
213 let candidates: Vec<&String> = worktrees
214 .iter()
215 .filter(|wt| sanitize_path(wt) != dir_key)
216 .collect();
217
218 let mut results: Vec<Option<(BridgePointerWithAge, String)>> = Vec::new();
220 for wt in candidates {
221 if let Some(p) = read_bridge_pointer(wt).await {
222 results.push(Some((p, wt.clone())));
223 }
224 }
225
226 let mut freshest: Option<(BridgePointerWithAge, String)> = None;
228 for r in results.into_iter().flatten() {
229 match &freshest {
230 Some(f) if r.0.age_ms >= f.0.age_ms => {}
231 _ => freshest = Some(r),
232 }
233 }
234
235 if let Some(ref f) = freshest {
236 log_for_debugging(&format!(
237 "[bridge:pointer] fanout found pointer in worktree {} (ageMs={})",
238 f.1, f.0.age_ms
239 ));
240 }
241
242 freshest
243}
244
245async fn get_worktree_paths(dir: &str) -> Option<Vec<String>> {
247 use std::process::Command;
248
249 let output = Command::new("git")
250 .args(&["worktree", "list", "--porcelain"])
251 .current_dir(dir)
252 .output()
253 .ok()?;
254
255 if !output.status.success() {
256 return None;
257 }
258
259 let output_str = String::from_utf8_lossy(&output.stdout);
260 let paths: Vec<String> = output_str
261 .lines()
262 .filter_map(|line| {
263 if line.starts_with("worktree ") {
264 Some(line.trim_start_matches("worktree ").to_string())
265 } else {
266 None
267 }
268 })
269 .collect();
270
271 if paths.is_empty() {
272 Some(vec![dir.to_string()])
274 } else {
275 Some(paths)
276 }
277}
278
279pub async fn clear_bridge_pointer(dir: &str) -> Result<(), String> {
282 let path = get_bridge_pointer_path(dir);
283
284 match fs::remove_file(&path) {
285 Ok(_) => {
286 log_for_debugging(&format!("[bridge:pointer] cleared {}", path.display()));
287 Ok(())
288 }
289 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), Err(e) => {
291 log_for_debugging(&format!("[bridge:pointer] clear failed: {}", e));
292 Err(format!("Failed to clear pointer: {}", e))
293 }
294 }
295}
296
297#[allow(unused_variables)]
299fn log_for_debugging(msg: &str) {
300 eprintln!("{}", msg);
301}
302
303#[cfg(test)]
304mod tests {
305 use super::*;
306
307 #[test]
308 fn test_bridge_pointer_serialization() {
309 let pointer = BridgePointer {
310 session_id: "test-session".to_string(),
311 environment_id: "test-env".to_string(),
312 source: BridgePointerSource::Standalone,
313 };
314
315 let json = serde_json::to_string(&pointer).unwrap();
316 let parsed: BridgePointer = serde_json::from_str(&json).unwrap();
317
318 assert_eq!(parsed.session_id, "test-session");
319 assert_eq!(parsed.environment_id, "test-env");
320 }
321
322 #[test]
323 fn test_sanitize_path() {
324 assert_eq!(sanitize_path("foo/bar"), "foo-bar");
325 assert_eq!(sanitize_path("foo\\bar"), "foo-bar");
326 }
327}