Skip to main content

ai_agent/bridge/
bridge_pointer.rs

1//! Bridge pointer for crash-recovery.
2//!
3//! Translated from openclaudecode/openclaudecode/src/bridge/bridgePointer.ts
4//!
5//! Crash-recovery pointer for Remote Control sessions.
6//!
7//! Written immediately after a bridge session is created, periodically
8//! refreshed during the session, and cleared on clean shutdown. If the
9//! process dies unclean (crash, kill -9, terminal closed), the pointer
10//! persists. On next startup, `claude remote-control` detects it and offers
11//! to resume via the --session-id flow.
12//!
13//! Staleness is checked against the file's mtime (not an embedded timestamp)
14//! so that a periodic re-write with the same content serves as a refresh.
15//!
16//! Scoped per working directory (alongside transcript JSONL files) so two
17//! concurrent bridges in different repos don't clobber each other.
18
19use serde::{Deserialize, Serialize};
20use std::fs;
21use std::path::PathBuf;
22use std::time::{SystemTime, UNIX_EPOCH};
23
24/// Upper bound on worktree fanout. git worktree list is naturally bounded
25/// (50 is a LOT), but this caps the parallel stat() burst and guards against
26/// pathological setups. Above this, --continue falls back to current-dir-only.
27const MAX_WORKTREE_FANOUT: usize = 50;
28
29/// Crash-recovery pointer TTL in milliseconds (4 hours)
30pub const BRIDGE_POINTER_TTL_MS: u64 = 4 * 60 * 60 * 1000;
31
32/// Bridge pointer source
33#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
34#[serde(rename_all = "lowercase")]
35pub enum BridgePointerSource {
36    Standalone,
37    Repl,
38}
39
40/// Bridge pointer data
41#[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/// Bridge pointer with age information
51#[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
59/// Get the bridge pointer path for a directory
60pub fn get_bridge_pointer_path(dir: &str) -> PathBuf {
61    // Get projects dir and sanitize the path
62    let projects_dir = get_projects_dir();
63    let sanitized = sanitize_path(dir);
64    projects_dir.join(sanitized).join("bridge-pointer.json")
65}
66
67/// Get the projects directory (simplified implementation)
68fn get_projects_dir() -> PathBuf {
69    dirs::data_local_dir()
70        .unwrap_or_else(|| PathBuf::from("."))
71        .join("ai-code")
72        .join("projects")
73}
74
75/// Sanitize a path for safe file system use
76fn sanitize_path(path: &str) -> String {
77    // Remove potentially dangerous characters
78    path.chars()
79        .map(|c| {
80            if c == '/' || c == '\\' || c == ':' {
81                '-'
82            } else {
83                c
84            }
85        })
86        .collect()
87}
88
89/// Write the pointer. Also used to refresh mtime during long sessions —
90/// calling with the same IDs is a cheap no-content-change write that bumps
91/// the staleness clock. Best-effort — a crash-recovery file must never
92/// itself cause a crash. Logs and swallows on error.
93pub async fn write_bridge_pointer(dir: &str, pointer: &BridgePointer) -> Result<(), String> {
94    let path = get_bridge_pointer_path(dir);
95
96    // Create parent directory if needed
97    if let Some(parent) = path.parent() {
98        fs::create_dir_all(parent).map_err(|e| format!("Failed to create directory: {}", e))?;
99    }
100
101    // Serialize and write the pointer
102    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
112/// Read the pointer and its age (ms since last write). Operates directly
113/// and handles errors — no existence check. Returns None on any failure:
114/// missing file, corrupted JSON, schema mismatch, or stale (mtime > 4h ago).
115/// Stale/invalid pointers are deleted so they don't keep re-prompting after
116/// the backend has already GC'd the env.
117pub async fn read_bridge_pointer(dir: &str) -> Option<BridgePointerWithAge> {
118    let path = get_bridge_pointer_path(dir);
119
120    // Get file metadata for mtime
121    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    // Read the file content
134    let raw = match fs::read_to_string(&path) {
135        Ok(c) => c,
136        Err(_) => return None,
137    };
138
139    // Parse the JSON
140    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    // Check staleness
153    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
176/// Worktree-aware read for `--continue`. The REPL bridge writes its pointer
177/// to the original CWD which EnterWorktreeTool/activeWorktreeSession can
178/// mutate to a worktree path — but `claude remote-control --continue` runs
179/// with resolve('.') = shell CWD. This fans out across git worktree
180/// siblings to find the freshest pointer, matching /resume's semantics.
181///
182/// Fast path: checks `dir` first. Only shells out to `git worktree list` if
183/// that misses — the common case (pointer in launch dir) is one stat, zero
184/// exec. Fanout reads run in parallel; capped at MAX_WORKTREE_FANOUT.
185///
186/// Returns the pointer AND the dir it was found in, so the caller can clear
187/// the right file on resume failure.
188pub async fn read_bridge_pointer_across_worktrees(
189    dir: &str,
190) -> Option<(BridgePointerWithAge, String)> {
191    // Fast path: current dir. Covers standalone bridge (always matches) and
192    // REPL bridge when no worktree mutation happened.
193    if let Some(pointer) = read_bridge_pointer(dir).await {
194        return Some((pointer, dir.to_string()));
195    }
196
197    // Fanout: scan worktree siblings
198    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    // Dedupe against `dir`
212    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    // Parallel stat+read
219    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    // Pick freshest (lowest ageMs)
227    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
245/// Get worktree paths (simplified implementation)
246async 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        // Fallback: just return the directory itself
273        Some(vec![dir.to_string()])
274    } else {
275        Some(paths)
276    }
277}
278
279/// Delete the pointer. Idempotent — ENOENT is expected when the process
280/// shut down clean previously.
281pub 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(()), // Expected on clean shutdown
290        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/// Simple logging helper
298#[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}