Skip to main content

agentd/store/
file.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2//! The local-filesystem store — `store.kind: file`. One file per key, atomic
3//! writes, an exclusive instance lock, and traversal closed at the adapter.
4//!
5//! This is the adapter that lets a laptop satisfy durability without standing
6//! up a coordination backend, which is why it is the default for a long-lived
7//! instance. It is deliberately NOT a fleet store: a directory has no
8//! compare-and-set a second process would respect, so instead of pretending,
9//! `open` takes an exclusive `flock` and a second instance fails at startup
10//! with the holder's pid. Finding that out at startup is much cheaper than
11//! finding it out from interleaved runs.
12//!
13//! What it is not: a breach of "agentd runs no code of its own". That rule is
14//! about the AGENT's tools and the trust boundary — there is no `fs` tool, and
15//! this adapter is not reachable by anything the model can call. It is the
16//! runtime's own ledger, on the same side of the boundary as the credential
17//! cache in `auth/cache.rs`, whose directory convention it reuses.
18
19use super::{KeySeq, PutOutcome, Store, StoreError};
20use serde_json::Value;
21use std::fs;
22use std::io;
23use std::path::{Path, PathBuf};
24
25/// The file a key's latest envelope lives in, relative to the root.
26const EXT: &str = "json";
27/// The lock that makes the single-writer property enforced rather than assumed.
28const LOCK: &str = ".lock";
29
30/// `Debug` prints the root only: a store handle appears in test assertions and
31/// in error context, and the lock's file descriptor is noise there.
32#[derive(Debug)]
33pub struct FileStore {
34    root: PathBuf,
35    /// The seq last seen on disk per key. The instance lock makes this process
36    /// the ONLY writer of the root, so the cache is exact once a key has been
37    /// touched — and the per-put CAS can compare against it instead of reading
38    /// and parsing the whole envelope back from disk on EVERY write (measured:
39    /// that read-back is ~20% of a step-heavy run's cycles). A key not yet in
40    /// the cache still reads disk once, so state written by a PREVIOUS life
41    /// still wins the compare-and-set.
42    seqs: std::sync::Mutex<std::collections::HashMap<String, u64>>,
43    /// Held for the life of the store: dropping it releases the `flock`.
44    _lock: LockFile,
45}
46
47impl FileStore {
48    /// Open `root`, creating it, and take the exclusive instance lock.
49    pub fn open(root: &Path) -> Result<FileStore, StoreError> {
50        fs::create_dir_all(root)
51            .map_err(|e| StoreError::Io(format!("store dir {}: {e}", root.display())))?;
52        restrict_dir(root);
53        let lock = LockFile::acquire(&root.join(LOCK)).map_err(StoreError::Io)?;
54        Ok(FileStore {
55            root: root.to_path_buf(),
56            seqs: std::sync::Mutex::new(std::collections::HashMap::new()),
57            _lock: lock,
58        })
59    }
60
61    pub fn root(&self) -> &Path {
62        &self.root
63    }
64
65    /// `<root>/<encoded segment>/…/<encoded id>.json`.
66    ///
67    /// Ids reach this adapter from run ids, task ids and context ids, so a
68    /// traversal is closed HERE rather than assumed to have been closed
69    /// upstream: every segment is percent-encoded, which makes `.`, `..` and a
70    /// separator unrepresentable in the encoded form.
71    fn path_of(&self, key: &str) -> Result<PathBuf, StoreError> {
72        let mut p = self.root.clone();
73        let segs: Vec<&str> = key.split('/').filter(|s| !s.is_empty()).collect();
74        if segs.is_empty() {
75            return Err(StoreError::Mapping("empty store key".into()));
76        }
77        for (i, seg) in segs.iter().enumerate() {
78            if seg.contains('\0') {
79                return Err(StoreError::Mapping("store key contains NUL".into()));
80            }
81            let enc = encode(seg);
82            if i + 1 == segs.len() {
83                p.push(format!("{enc}.{EXT}"));
84            } else {
85                p.push(enc);
86            }
87        }
88        Ok(p)
89    }
90
91    /// Read the envelope at `path`, if the file exists and parses.
92    fn read(&self, path: &Path) -> Result<Option<Value>, StoreError> {
93        match fs::read(path) {
94            Ok(b) => serde_json::from_slice(&b)
95                .map(Some)
96                .map_err(|e| StoreError::Corrupt(format!("{}: {e}", path.display()))),
97            Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None),
98            Err(e) => Err(StoreError::Io(format!("read {}: {e}", path.display()))),
99        }
100    }
101}
102
103impl Store for FileStore {
104    fn put(&self, key: &str, seq: u64, envelope: &Value) -> Result<PutOutcome, StoreError> {
105        let path = self.path_of(key)?;
106        // CAS against what is on disk — via the seq cache when this process
107        // has already touched the key (single-writer, so the cache is exact),
108        // via one disk read the first time (a previous life's state).
109        let cached = self.seqs.lock().expect("seqs").get(key).copied();
110        let latest = match cached {
111            Some(l) => Some(l),
112            None => self
113                .read(&path)?
114                .and_then(|cur| cur.get("seq").and_then(Value::as_u64)),
115        };
116        if let Some(l) = latest
117            && seq <= l
118        {
119            return Ok(PutOutcome::Conflict {
120                latest_seq: Some(l),
121            });
122        }
123        if let Some(parent) = path.parent() {
124            fs::create_dir_all(parent)
125                .map_err(|e| StoreError::Io(format!("mkdir {}: {e}", parent.display())))?;
126            // Every level, not just the leaf: `create_dir_all` makes the
127            // intermediate directories with the process umask (0755 by
128            // default), and a key is `<prefix>/<instance>/<kind>/<id>` — three
129            // levels deep. The root's own 0700 hides them today, but a root the
130            // operator points somewhere pre-existing would not, and each level
131            // names an instance and an entity kind.
132            restrict_tree(&self.root, parent);
133        }
134        let body = serde_json::to_vec(envelope)
135            .map_err(|e| StoreError::Mapping(format!("envelope: {e}")))?;
136        write_atomic(&path, &body).map_err(StoreError::Io)?;
137        self.seqs.lock().expect("seqs").insert(key.to_string(), seq);
138        Ok(PutOutcome::Ok)
139    }
140
141    fn get(&self, key: &str, _seq: Option<u64>) -> Result<Option<Value>, StoreError> {
142        // Latest-only, like the http adapter: a pinned seq reads as the latest.
143        let v = self.read(&self.path_of(key)?)?;
144        // A tombstone (latest state null) reads as absent, so a deleted entity
145        // cannot come back to life through a restore.
146        Ok(v.filter(|v| !v.get("state").is_some_and(Value::is_null)))
147    }
148
149    fn list(&self, prefix: &str) -> Result<Vec<KeySeq>, StoreError> {
150        let mut dir = self.root.clone();
151        for seg in prefix.split('/').filter(|s| !s.is_empty()) {
152            dir.push(encode(seg));
153        }
154        let mut out = Vec::new();
155        walk(&dir, &self.root, &mut out)?;
156        out.sort_by(|a, b| a.key.cmp(&b.key));
157        Ok(out)
158    }
159
160    fn delete(&self, key: &str) -> Result<(), StoreError> {
161        self.seqs.lock().expect("seqs").remove(key);
162        match fs::remove_file(self.path_of(key)?) {
163            Ok(()) => Ok(()),
164            Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
165            Err(e) => Err(StoreError::Io(format!("delete {key}: {e}"))),
166        }
167    }
168
169    fn kind(&self) -> &'static str {
170        "file"
171    }
172}
173
174/// Recursively collect every `*.json` under `dir`, keyed by its path relative
175/// to `root` with the segments decoded back to the original key.
176fn walk(dir: &Path, root: &Path, out: &mut Vec<KeySeq>) -> Result<(), StoreError> {
177    let rd = match fs::read_dir(dir) {
178        Ok(rd) => rd,
179        Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(()),
180        Err(e) => return Err(StoreError::Io(format!("list {}: {e}", dir.display()))),
181    };
182    for ent in rd.flatten() {
183        let p = ent.path();
184        if p.is_dir() {
185            walk(&p, root, out)?;
186        } else if p.extension().is_some_and(|e| e == EXT) {
187            let Ok(rel) = p.strip_prefix(root) else {
188                continue;
189            };
190            let mut segs: Vec<String> = rel
191                .components()
192                .map(|c| decode(&c.as_os_str().to_string_lossy()))
193                .collect();
194            if let Some(last) = segs.last_mut()
195                && let Some(stem) = last.strip_suffix(&format!(".{EXT}"))
196            {
197                *last = stem.to_string();
198            }
199            let seq = fs::read(&p)
200                .ok()
201                .and_then(|b| serde_json::from_slice::<Value>(&b).ok())
202                .and_then(|v| v.get("seq").and_then(Value::as_u64));
203            out.push(KeySeq {
204                key: segs.join("/"),
205                seq,
206            });
207        }
208    }
209    Ok(())
210}
211
212/// Write `body` to `path` so a crash leaves either the old bytes or the new
213/// ones and never a partial file: a temp file in the same directory, fsync'd,
214/// renamed over the target, then the directory itself fsync'd so the rename is
215/// durable too.
216fn write_atomic(path: &Path, body: &[u8]) -> Result<(), String> {
217    use std::io::Write;
218    let dir = path.parent().ok_or_else(|| "no parent dir".to_string())?;
219    let tmp = dir.join(format!(
220        ".{}.tmp.{}",
221        path.file_name().unwrap_or_default().to_string_lossy(),
222        std::process::id()
223    ));
224    {
225        let mut f = fs::File::create(&tmp).map_err(|e| format!("create {}: {e}", tmp.display()))?;
226        restrict_file(&f);
227        f.write_all(body)
228            .map_err(|e| format!("write {}: {e}", tmp.display()))?;
229        f.sync_all()
230            .map_err(|e| format!("fsync {}: {e}", tmp.display()))?;
231    }
232    fs::rename(&tmp, path).map_err(|e| {
233        let _ = fs::remove_file(&tmp);
234        format!("rename into {}: {e}", path.display())
235    })?;
236    // The rename itself is only durable once the directory entry is synced.
237    if let Ok(d) = fs::File::open(dir) {
238        let _ = d.sync_all();
239    }
240    Ok(())
241}
242
243/// `0700` on every directory from `root` (exclusive) down to `leaf` (inclusive).
244fn restrict_tree(root: &Path, leaf: &Path) {
245    let Ok(rel) = leaf.strip_prefix(root) else {
246        restrict_dir(leaf);
247        return;
248    };
249    let mut p = root.to_path_buf();
250    for comp in rel.components() {
251        p.push(comp);
252        restrict_dir(&p);
253    }
254}
255
256/// `0700` on a state directory: it holds conversation content and tool results.
257fn restrict_dir(p: &Path) {
258    #[cfg(unix)]
259    {
260        use std::os::unix::fs::PermissionsExt;
261        let _ = fs::set_permissions(p, fs::Permissions::from_mode(0o700));
262    }
263    #[cfg(not(unix))]
264    let _ = p;
265}
266
267/// `0600` on a state file, set before any bytes are written.
268fn restrict_file(f: &fs::File) {
269    #[cfg(unix)]
270    {
271        use std::os::unix::fs::PermissionsExt;
272        let _ = f.set_permissions(fs::Permissions::from_mode(0o600));
273    }
274    #[cfg(not(unix))]
275    let _ = f;
276}
277
278/// Percent-encode everything outside an unreserved set. `.`/`..`/`/` become
279/// unrepresentable, which is what closes traversal.
280fn encode(seg: &str) -> String {
281    let mut out = String::with_capacity(seg.len());
282    for b in seg.bytes() {
283        let safe = b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_');
284        if safe {
285            out.push(b as char);
286        } else {
287            out.push_str(&format!("%{b:02X}"));
288        }
289    }
290    out
291}
292
293fn decode(seg: &str) -> String {
294    let b = seg.as_bytes();
295    let mut out = Vec::with_capacity(b.len());
296    let mut i = 0;
297    while i < b.len() {
298        if b[i] == b'%' && i + 2 < b.len() {
299            let hex = std::str::from_utf8(&b[i + 1..i + 3]).unwrap_or("");
300            if let Ok(v) = u8::from_str_radix(hex, 16) {
301                out.push(v);
302                i += 3;
303                continue;
304            }
305        }
306        out.push(b[i]);
307        i += 1;
308    }
309    String::from_utf8_lossy(&out).into_owned()
310}
311
312/// An exclusive `flock` held for the life of the store.
313#[derive(Debug)]
314struct LockFile {
315    _file: fs::File,
316}
317
318impl LockFile {
319    fn acquire(path: &Path) -> Result<LockFile, String> {
320        let file = fs::OpenOptions::new()
321            .create(true)
322            .read(true)
323            .write(true)
324            .truncate(false)
325            .open(path)
326            .map_err(|e| format!("lock {}: {e}", path.display()))?;
327        restrict_file(&file);
328        #[cfg(unix)]
329        {
330            use std::io::{Read, Seek, Write};
331            use std::os::unix::io::AsRawFd;
332            // Non-blocking: a held lock must fail fast and say who holds it,
333            // not stall a startup that is never going to succeed.
334            let rc = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
335            if rc != 0 {
336                let mut holder = String::new();
337                let mut f = &file;
338                let _ = f.rewind();
339                let _ = f.read_to_string(&mut holder);
340                let who = holder.trim();
341                let who = if who.is_empty() {
342                    "another process".to_string()
343                } else {
344                    format!("pid {who}")
345                };
346                return Err(format!(
347                    "{} is locked by {who} — another agentd is using this state \
348                     directory; give this instance its own agent.name or store.file.path",
349                    path.parent().unwrap_or(path).display()
350                ));
351            }
352            let mut f = &file;
353            let _ = f.rewind();
354            let _ = f.set_len(0);
355            let _ = write!(f, "{}", std::process::id());
356            let _ = f.flush();
357        }
358        Ok(LockFile { _file: file })
359    }
360}