Skip to main content

opencode_ctl/
created_set.rs

1//! The per-directory set of sessions this controller created (OpenCode host guard).
2//!
3//! Files in the ctl home: `created.<key>.json` = `[{id, directory}]` and its sidecar
4//! `created.<key>.lock`, which carries a `File::lock` for every read-merge-write.
5
6use std::fs::{File, OpenOptions};
7use std::io;
8use std::path::{Component, Path, PathBuf};
9use std::sync::atomic::{AtomicU64, Ordering};
10
11use serde::{Deserialize, Serialize};
12use sha2::{Digest, Sha256};
13
14/// 30 days, the age past which `sweep` removes untouched files.
15pub const SWEEP_MAX_AGE_MS: i64 = 30 * 24 * 60 * 60 * 1000;
16
17/// Ids probed with `session.get` per append at most.
18pub const PROBE_LIMIT: usize = 5;
19
20#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
21pub struct Entry {
22    pub id: String,
23    pub directory: String,
24}
25
26#[derive(Debug, Clone)]
27pub struct CreatedSet {
28    home: PathBuf,
29}
30
31/// Drop a Windows verbatim prefix: `\\?\C:\x` -> `C:\x`, `\\?\UNC\h\s` -> `\\h\s`.
32pub fn strip_verbatim(s: &str) -> String {
33    if let Some(rest) = s.strip_prefix(r"\\?\UNC\") {
34        format!(r"\\{rest}")
35    } else if let Some(rest) = s.strip_prefix(r"\\?\") {
36        rest.to_string()
37    } else {
38        s.to_string()
39    }
40}
41
42/// Resolve `.` and `..` without touching the file system.
43fn lexical_normalise(p: &Path) -> PathBuf {
44    let mut out = PathBuf::new();
45    for c in p.components() {
46        match c {
47            Component::CurDir => {}
48            Component::ParentDir => {
49                if !out.pop() && !out.has_root() {
50                    out.push("..");
51                }
52            }
53            other => out.push(other.as_os_str()),
54        }
55    }
56    out
57}
58
59/// 12 hex of sha256 of the canonicalised path (lexically normalised when it does not exist),
60/// Windows verbatim prefix stripped before hashing.
61pub fn key_for(directory: &Path) -> String {
62    let resolved =
63        std::fs::canonicalize(directory).unwrap_or_else(|_| lexical_normalise(directory));
64    let text = strip_verbatim(&resolved.to_string_lossy());
65    let digest = Sha256::digest(text.as_bytes());
66    digest.iter().take(6).map(|b| format!("{b:02x}")).collect()
67}
68
69fn read_entries(path: &Path) -> io::Result<Vec<Entry>> {
70    match std::fs::read(path) {
71        Ok(bytes) => Ok(serde_json::from_slice(&bytes).unwrap_or_default()),
72        Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(Vec::new()),
73        Err(e) => Err(e),
74    }
75}
76
77static TMP_SEQ: AtomicU64 = AtomicU64::new(0);
78
79impl CreatedSet {
80    pub fn new(home: PathBuf) -> Self {
81        Self { home }
82    }
83
84    fn data_path(&self, key: &str) -> PathBuf {
85        self.home.join(format!("created.{key}.json"))
86    }
87
88    fn lock_path(&self, key: &str) -> PathBuf {
89        self.home.join(format!("created.{key}.lock"))
90    }
91
92    fn open_lock(&self, key: &str) -> io::Result<File> {
93        std::fs::create_dir_all(&self.home)?;
94        let f = OpenOptions::new()
95            .create(true)
96            .read(true)
97            .write(true)
98            .truncate(false)
99            .open(self.lock_path(key))?;
100        f.lock()?;
101        Ok(f)
102    }
103
104    /// Read-merge-write one key's file under its lock.
105    fn mutate(&self, key: &str, f: impl FnOnce(&mut Vec<Entry>)) -> io::Result<()> {
106        let _lock = self.open_lock(key)?;
107        let path = self.data_path(key);
108        let mut entries = read_entries(&path)?;
109        let before = entries.clone();
110        f(&mut entries);
111        if entries == before && path.exists() {
112            return Ok(());
113        }
114        let tmp = self.home.join(format!(
115            "created.{key}.tmp{}-{}",
116            std::process::id(),
117            TMP_SEQ.fetch_add(1, Ordering::Relaxed)
118        ));
119        std::fs::write(
120            &tmp,
121            serde_json::to_vec(&entries).map_err(io::Error::other)?,
122        )?;
123        std::fs::rename(&tmp, &path).inspect_err(|_| {
124            let _ = std::fs::remove_file(&tmp);
125        })
126    }
127
128    pub fn append(&self, directory: &Path, id: &str) -> io::Result<()> {
129        let key = key_for(directory);
130        let entry = Entry {
131            id: id.to_string(),
132            directory: directory.to_string_lossy().into_owned(),
133        };
134        self.mutate(&key, |v| {
135            if !v.iter().any(|e| e.id == id) {
136                v.push(entry);
137            }
138        })
139    }
140
141    /// Every `created.<key>.json` in the home, as `(key, path)`.
142    fn data_files(&self) -> Vec<(String, PathBuf)> {
143        let Ok(rd) = std::fs::read_dir(&self.home) else {
144            return Vec::new();
145        };
146        let mut out: Vec<(String, PathBuf)> = rd
147            .flatten()
148            .filter_map(|e| {
149                let name = e.file_name().to_string_lossy().into_owned();
150                let key = name.strip_prefix("created.")?.strip_suffix(".json")?;
151                Some((key.to_string(), e.path()))
152            })
153            .collect();
154        out.sort();
155        out
156    }
157
158    pub fn prune(&self, id: &str) -> io::Result<()> {
159        for (key, path) in self.data_files() {
160            // A file that vanishes or does not parse reads as empty: nothing to prune there.
161            if read_entries(&path)
162                .unwrap_or_default()
163                .iter()
164                .any(|e| e.id == id)
165            {
166                self.mutate(&key, |v| v.retain(|e| e.id != id))?;
167            }
168        }
169        Ok(())
170    }
171
172    /// The directory recorded for `id`, scanning every file without locking.
173    pub fn contains(&self, id: &str) -> io::Result<Option<PathBuf>> {
174        for (_, path) in self.data_files() {
175            let Ok(bytes) = std::fs::read(&path) else {
176                continue;
177            };
178            let Ok(entries) = serde_json::from_slice::<Vec<Entry>>(&bytes) else {
179                continue;
180            };
181            if let Some(e) = entries.iter().find(|e| e.id == id) {
182                return Ok(Some(PathBuf::from(&e.directory)));
183            }
184        }
185        Ok(None)
186    }
187
188    /// The ids stored for `directory`'s key, in stored order.
189    pub fn ids_for(&self, directory: &Path) -> Vec<String> {
190        read_entries(&self.data_path(&key_for(directory)))
191            .unwrap_or_default()
192            .into_iter()
193            .map(|e| e.id)
194            .collect()
195    }
196
197    pub fn ids(&self) -> Vec<String> {
198        self.data_files()
199            .into_iter()
200            .flat_map(|(_, p)| read_entries(&p).unwrap_or_default())
201            .map(|e| e.id)
202            .collect()
203    }
204
205    /// Remove data files older than `max_age_ms` (by the data file's mtime) together with their
206    /// `.lock`, holding the lock; a `.lock` alone is removed only when itself older than 30 days.
207    pub fn sweep(&self, now_ms: i64, max_age_ms: i64) -> Vec<PathBuf> {
208        let age_of = |p: &Path| -> Option<i64> {
209            let m = std::fs::metadata(p).ok()?.modified().ok()?;
210            let ms = m.duration_since(std::time::UNIX_EPOCH).ok()?.as_millis() as i64;
211            Some(now_ms - ms)
212        };
213        let mut removed = Vec::new();
214        for (key, path) in self.data_files() {
215            if !age_of(&path).is_some_and(|a| a > max_age_ms) {
216                continue;
217            }
218            let Ok(lock) = self.open_lock(&key) else {
219                continue;
220            };
221            // Re-check under the lock: a writer may have refreshed the file meanwhile.
222            if age_of(&path).is_some_and(|a| a > max_age_ms) && std::fs::remove_file(&path).is_ok()
223            {
224                let _ = std::fs::remove_file(self.lock_path(&key));
225                removed.push(path);
226            }
227            drop(lock);
228        }
229        if let Ok(rd) = std::fs::read_dir(&self.home) {
230            for e in rd.flatten() {
231                let name = e.file_name().to_string_lossy().into_owned();
232                let Some(key) = name
233                    .strip_prefix("created.")
234                    .and_then(|r| r.strip_suffix(".lock"))
235                else {
236                    continue;
237                };
238                if !self.data_path(key).exists()
239                    && age_of(&e.path()).is_some_and(|a| a > SWEEP_MAX_AGE_MS)
240                    && std::fs::remove_file(e.path()).is_ok()
241                {
242                    removed.push(e.path());
243                }
244            }
245        }
246        removed
247    }
248}