Skip to main content

verdant_runtime/
cache.rs

1//! `LiveCache` — the M1 cache surface that the MCP server consumes.
2//!
3//! Keying philosophy: every cache entry is keyed by a deterministic blake3
4//! hash of the tool's *inputs*, where "inputs" includes any file content
5//! the tool's output is a function of. The store payload is the exact
6//! formatted bytes the MCP tool fed back to the model on the first
7//! execution. A subsequent identical call hits when (a) the input hash
8//! matches AND (b) every recorded file root revalidates clean against the
9//! current filesystem. If either fails, the entry is treated as invalid
10//! and the registered metadata is removed so a stale entry does not
11//! linger. M1 keeps the registry in memory; M2 will persist it.
12//!
13//! The cache surface is deliberately tool-agnostic: callers compute the
14//! input bytes (we provide canonicalization helpers in `key`), invoke
15//! `lookup` or `lookup_revalidate`, and on miss they execute the real
16//! tool and call `persist`. The cache does not run tools itself; that
17//! lives one layer up in `verdant-mcp`.
18
19use crate::store::{FileRootSerde, Key, Payload, Store, StoreError};
20use std::collections::HashMap;
21use std::os::unix::fs::MetadataExt;
22use std::path::{Path, PathBuf};
23use std::sync::RwLock;
24
25#[derive(Debug, thiserror::Error)]
26pub enum CacheError {
27    #[error("store: {0}")]
28    Store(#[from] StoreError),
29    #[error("io: {0}")]
30    Io(#[from] std::io::Error),
31}
32
33/// One file dependency of a cache entry. The tool computed its output as a
34/// function of (path, contents at expected_hash). On every green hit we
35/// re-blake3 the file and require the hash to still match; if it does not,
36/// the entry is invalidated.
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct FileRoot {
39    pub path: PathBuf,
40    pub expected_hash: String,
41}
42
43#[derive(Debug, Clone)]
44struct EntryMeta {
45    tool_kind: String,
46    file_roots: Vec<FileRoot>,
47}
48
49pub struct LiveCache {
50    store: Box<dyn Store>,
51    registry: RwLock<HashMap<String, EntryMeta>>,
52    /// Workspace base used to resolve workspace-relative `FileRoot::path`
53    /// during revalidation. `FileRoot` paths are stored relative so a
54    /// cache entry persisted on Alice's machine at `/home/alice/repo/`
55    /// is reusable on Bob's machine at `/home/bob/work/repo/` without
56    /// changing the cache key. `LiveCache::new` defaults the base to
57    /// the process cwd at construction time; binaries that know the
58    /// real project root should call `LiveCache::with_workspace`.
59    workspace_base: PathBuf,
60}
61
62#[derive(Debug, Clone, PartialEq)]
63pub enum LookupOutcome {
64    /// Cache hit. The payload is byte-for-byte the same as the original
65    /// execution and (for revalidating lookups) every file root has been
66    /// confirmed unchanged.
67    Hit(Payload),
68    /// No entry for this key.
69    Miss,
70    /// Entry existed but a file root has changed; the entry has been
71    /// removed from the registry so subsequent lookups behave as Miss
72    /// without paying the revalidation cost again.
73    Invalidated,
74}
75
76impl LiveCache {
77    pub fn new<S: Store + 'static>(store: S) -> Self {
78        let base = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
79        Self::from_box_with_workspace(Box::new(store), base)
80    }
81
82    pub fn with_workspace<S: Store + 'static>(
83        store: S,
84        workspace_base: impl Into<PathBuf>,
85    ) -> Self {
86        Self::from_box_with_workspace(Box::new(store), workspace_base.into())
87    }
88
89    pub fn from_box(store: Box<dyn Store>) -> Self {
90        let base = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
91        Self::from_box_with_workspace(store, base)
92    }
93
94    pub fn from_box_with_workspace(store: Box<dyn Store>, workspace_base: PathBuf) -> Self {
95        // Rehydrate the in-memory registry from on-disk meta files so a
96        // freshly constructed cache (e.g., a brand-new MCP server
97        // process started by Claude Code on each `claude -p` invocation)
98        // can serve previously-persisted entries instead of treating
99        // every key as Miss. Without this, M1's cross-session cache
100        // doesn't actually exist — every Run-2 lookup would miss,
101        // overwrite the same payload file, and the cache would provide
102        // zero savings between sessions.
103        let mut reg = HashMap::new();
104        if let Ok(items) = store.iter_meta() {
105            for (key, meta) in items {
106                let file_roots = meta
107                    .file_roots
108                    .into_iter()
109                    .map(|f| FileRoot {
110                        path: PathBuf::from(f.path),
111                        expected_hash: f.expected_hash,
112                    })
113                    .collect();
114                reg.insert(
115                    key.0,
116                    EntryMeta {
117                        tool_kind: meta.tool_kind,
118                        file_roots,
119                    },
120                );
121            }
122        }
123        Self {
124            store,
125            registry: RwLock::new(reg),
126            workspace_base,
127        }
128    }
129
130    pub fn store(&self) -> &dyn Store {
131        self.store.as_ref()
132    }
133
134    pub fn workspace_base(&self) -> &Path {
135        &self.workspace_base
136    }
137
138    pub fn entry_count(&self) -> usize {
139        self.registry
140            .read()
141            .unwrap_or_else(|e| e.into_inner())
142            .len()
143    }
144
145    /// Whether an entry for `key` is present, checking the in-memory registry
146    /// first and falling back to the store. Does not fetch or revalidate the
147    /// payload, so it is a cheap presence probe (used by provenance telemetry
148    /// to count how many tool-result edges in a prompt resolve to a known node).
149    pub fn contains(&self, key: &Key) -> bool {
150        if self
151            .registry
152            .read()
153            .unwrap_or_else(|e| e.into_inner())
154            .contains_key(&key.0)
155        {
156            return true;
157        }
158        self.store.contains(key)
159    }
160
161    /// Bare lookup with no file revalidation. Used by tools whose output
162    /// has no filesystem dependency (rare in M1 — even Bash depends on
163    /// the cwd's contents in practice). Most callers want
164    /// `lookup_revalidate`.
165    pub fn lookup(&self, key: &Key) -> Result<LookupOutcome, CacheError> {
166        let in_reg = self
167            .registry
168            .read()
169            .unwrap_or_else(|e| e.into_inner())
170            .contains_key(&key.0);
171        match self.store.lookup(key)? {
172            Some(p) => {
173                // Backends like `RemoteStore` cannot pre-populate the
174                // registry through `iter_meta` because there is no bulk
175                // listing over the wire; the registry stays empty and
176                // entries are discovered one round-trip at a time. Seed
177                // the registry from the payload meta so subsequent
178                // operations (invalidate_path, invalidate_upstream,
179                // entry_count) see the entry without another round-trip.
180                if !in_reg {
181                    self.populate_registry_from_meta(key, &p);
182                }
183                Ok(LookupOutcome::Hit(p))
184            }
185            None => {
186                if in_reg {
187                    // Registry says we have it but the store does not —
188                    // happens if the store was truncated externally
189                    // between persist and lookup, or if a `_shared`
190                    // entry the registry knows about was server-side
191                    // invalidated. Drop the orphan so subsequent
192                    // lookups return Miss directly.
193                    self.registry
194                        .write()
195                        .unwrap_or_else(|e| e.into_inner())
196                        .remove(&key.0);
197                }
198                Ok(LookupOutcome::Miss)
199            }
200        }
201    }
202
203    /// Lookup with revalidation: re-blake3 every recorded file root and
204    /// require the hash to still match the value captured on persist. On
205    /// any mismatch the entry is removed from the registry and `Invalidated`
206    /// is returned so the caller knows to re-execute the real tool.
207    ///
208    /// This is the primary lookup path for `read`, `glob`, and `grep`
209    /// tools whose output is a pure function of named file contents. Bash
210    /// typically cannot use this path because the set of files Bash
211    /// reads is not known a priori.
212    pub fn lookup_revalidate(&self, key: &Key) -> Result<LookupOutcome, CacheError> {
213        // Snapshot the metadata under a read lock, drop the lock before
214        // doing any I/O so we don't hold it across blake3 of large files,
215        // then upgrade to a write lock only if invalidation is required.
216        let cached_meta = {
217            let reg = self.registry.read().unwrap_or_else(|e| e.into_inner());
218            reg.get(&key.0).cloned()
219        };
220
221        // Fast path: registry already knows the roots. Revalidate them
222        // first to avoid a round-trip to a slow store on a dirty entry.
223        if let Some(meta) = &cached_meta {
224            match revalidate_file_roots(&self.workspace_base, &meta.file_roots) {
225                RevalidationOutcome::Ok => {}
226                RevalidationOutcome::Invalidated => {
227                    self.registry
228                        .write()
229                        .unwrap_or_else(|e| e.into_inner())
230                        .remove(&key.0);
231                    return Ok(LookupOutcome::Invalidated);
232                }
233            }
234        }
235
236        match self.store.lookup(key)? {
237            Some(p) => {
238                // If we did not have the entry in the registry, the
239                // store's payload meta carries the file roots the
240                // entry was persisted with. Revalidate against the
241                // local filesystem before trusting it — this is the
242                // cross-machine drift check that RemoteStore-backed
243                // caches rely on, because the server only knows its
244                // own filesystem and cannot detect that Bob's local
245                // checkout has diverged from Alice's.
246                if cached_meta.is_none() {
247                    let local_roots: Vec<FileRoot> = p
248                        .meta
249                        .file_roots
250                        .iter()
251                        .map(|f| FileRoot {
252                            path: PathBuf::from(&f.path),
253                            expected_hash: f.expected_hash.clone(),
254                        })
255                        .collect();
256                    match revalidate_file_roots(&self.workspace_base, &local_roots) {
257                        RevalidationOutcome::Ok => {
258                            self.populate_registry_from_meta(key, &p);
259                        }
260                        RevalidationOutcome::Invalidated => {
261                            return Ok(LookupOutcome::Invalidated);
262                        }
263                    }
264                }
265                Ok(LookupOutcome::Hit(p))
266            }
267            None => {
268                if cached_meta.is_some() {
269                    self.registry
270                        .write()
271                        .unwrap_or_else(|e| e.into_inner())
272                        .remove(&key.0);
273                }
274                Ok(LookupOutcome::Miss)
275            }
276        }
277    }
278
279    fn populate_registry_from_meta(&self, key: &Key, p: &Payload) {
280        let file_roots = p
281            .meta
282            .file_roots
283            .iter()
284            .map(|f| FileRoot {
285                path: PathBuf::from(&f.path),
286                expected_hash: f.expected_hash.clone(),
287            })
288            .collect();
289        self.registry
290            .write()
291            .unwrap_or_else(|e| e.into_inner())
292            .insert(
293                key.0.clone(),
294                EntryMeta {
295                    tool_kind: p.meta.tool_kind.clone(),
296                    file_roots,
297                },
298            );
299    }
300
301    /// Record a fresh tool execution. Caller has already produced the
302    /// formatted output bytes the model will see; we persist them under
303    /// `key` and register the file roots for future revalidation.
304    pub fn persist(
305        &self,
306        key: &Key,
307        bytes: &[u8],
308        tool_kind: &str,
309        file_roots: Vec<FileRoot>,
310    ) -> Result<(), CacheError> {
311        self.persist_with_upstreams(key, bytes, tool_kind, file_roots, Vec::new())
312    }
313
314    /// Persist an entry whose validity depends on the listed upstream
315    /// cache keys. The proxy's LlmCall path uses this so a tool-cache
316    /// invalidation can walk the upstream edge and drop dependent
317    /// completions.
318    pub fn persist_with_upstreams(
319        &self,
320        key: &Key,
321        bytes: &[u8],
322        tool_kind: &str,
323        file_roots: Vec<FileRoot>,
324        upstream_keys: Vec<Key>,
325    ) -> Result<(), CacheError> {
326        let serde_roots: Vec<FileRootSerde> = file_roots
327            .iter()
328            .map(|r| FileRootSerde {
329                path: r.path.display().to_string(),
330                expected_hash: r.expected_hash.clone(),
331            })
332            .collect();
333        let upstream_strings: Vec<String> = upstream_keys.iter().map(|k| k.0.clone()).collect();
334        self.store
335            .persist_with_upstreams(key, bytes, tool_kind, serde_roots, upstream_strings)?;
336        self.registry
337            .write()
338            .unwrap_or_else(|e| e.into_inner())
339            .insert(
340                key.0.clone(),
341                EntryMeta {
342                    tool_kind: tool_kind.to_string(),
343                    file_roots,
344                },
345            );
346        Ok(())
347    }
348
349    /// Drop the registry entry for `key`. The store payload remains on
350    /// disk (M1 is append-only; M2 adds eviction) but subsequent lookups
351    /// will Miss because the registry is the authoritative gate.
352    ///
353    /// Used by `write` and `edit` MCP tools to invalidate every cached
354    /// node whose path matches the written path; the verdant-mcp layer
355    /// computes the affected key set and calls this for each.
356    pub fn mark_dirty(&self, key: &Key) {
357        self.registry
358            .write()
359            .unwrap_or_else(|e| e.into_inner())
360            .remove(&key.0);
361        // Best-effort store cleanup; if it fails (concurrent writer,
362        // permissions glitch) we silently leave the bytes on disk
363        // because the registry is the authoritative gate and a stale
364        // payload that no entry references is harmless.
365        let _ = self.store.remove(key);
366    }
367
368    /// Drop every cache entry that depends on `upstream_key` either
369    /// directly (its `upstream_keys` includes that hex) or transitively
370    /// (its dependency closure does). Returns the number of entries
371    /// dropped.
372    ///
373    /// This is the cross-layer dirty propagation path that ties the
374    /// M3 LlmCall cache to M1's tool cache: when a `read` entry is
375    /// invalidated by `invalidate_path`, the proxy calls this with
376    /// the read's key and every LlmCall whose prompt consumed that
377    /// read drops out of the cache. Without this hop, an edited file
378    /// would silently feed the model the old bytes via a stale cached
379    /// completion.
380    pub fn invalidate_upstream(&self, upstream_key: &Key) -> usize {
381        // Walk the PERSISTED metadata, not just the in-memory registry, so the
382        // cascade crosses process boundaries: a tool process invalidating a
383        // file must drop the LLM-call entries a separate proxy process persisted
384        // into the shared store. The registry is only a per-process perf cache;
385        // `lookup` already trusts the store as the source of truth.
386        let metas = match self.store.iter_meta() {
387            Ok(m) => m,
388            Err(_) => return 0,
389        };
390        let mut dirty: std::collections::HashSet<String> =
391            std::collections::HashSet::from([upstream_key.0.clone()]);
392        loop {
393            let before = dirty.len();
394            for (k, meta) in &metas {
395                if dirty.contains(&k.0) {
396                    continue;
397                }
398                if meta.upstream_keys.iter().any(|u| dirty.contains(u)) {
399                    dirty.insert(k.0.clone());
400                }
401            }
402            if dirty.len() == before {
403                break;
404            }
405        }
406        let mut reg = self.registry.write().unwrap_or_else(|e| e.into_inner());
407        let mut dropped = 0;
408        for k in &dirty {
409            if k == &upstream_key.0 {
410                continue;
411            }
412            reg.remove(k);
413            if self.store.remove(&Key(k.clone())).is_ok() {
414                dropped += 1;
415            }
416        }
417        dropped
418    }
419
420    /// Drop every cached entry whose recorded file roots include `path`, then
421    /// cascade up the dependency edges. Scans the persisted store metadata so
422    /// the effect is visible across processes sharing the store. O(n) in store
423    /// size; the code anticipates a future path -> keys index.
424    pub fn invalidate_path(&self, path: &Path) -> usize {
425        let target = match path.canonicalize() {
426            Ok(p) => p,
427            Err(_) => path.to_path_buf(),
428        };
429        // Compare a lowercased form so an edit recorded under one casing
430        // still invalidates an entry recorded under another. On a
431        // case-insensitive filesystem `Src/Foo.rs` and `src/foo.rs` are
432        // one file; a missed invalidation leaves a stale hit, while a
433        // spurious extra invalidation only costs a recompute, so the
434        // conservative lowercased comparison is applied unconditionally.
435        let target_ci = lower_path(&target);
436        let path_ci = lower_path(path);
437        let metas = match self.store.iter_meta() {
438            Ok(m) => m,
439            Err(_) => return 0,
440        };
441        let to_drop: Vec<String> = metas
442            .iter()
443            .filter_map(|(k, meta)| {
444                let touches = meta.file_roots.iter().any(|r| {
445                    let recorded = PathBuf::from(&r.path);
446                    let resolved = resolve_root_path(&self.workspace_base, &recorded);
447                    let resolved_ci = lower_path(&resolved);
448                    match resolved.canonicalize() {
449                        Ok(c) => lower_path(&c) == target_ci,
450                        Err(_) => resolved_ci == path_ci || lower_path(&recorded) == path_ci,
451                    }
452                });
453                if touches {
454                    Some(k.0.clone())
455                } else {
456                    None
457                }
458            })
459            .collect();
460        let n = to_drop.len();
461        for k in to_drop {
462            let key = Key(k);
463            // Cascade up the dependency edge so any LlmCall whose prompt
464            // consumed this tool result also drops.
465            self.invalidate_upstream(&key);
466            self.registry
467                .write()
468                .unwrap_or_else(|e| e.into_inner())
469                .remove(&key.0);
470            let _ = self.store.remove(&key);
471        }
472        n
473    }
474
475    pub fn known_kinds(&self) -> Vec<String> {
476        let reg = self.registry.read().unwrap_or_else(|e| e.into_inner());
477        let mut kinds: Vec<String> = reg.values().map(|m| m.tool_kind.clone()).collect();
478        kinds.sort();
479        kinds.dedup();
480        kinds
481    }
482}
483
484/// Compute the blake3 hex digest of the file at `path`. Used both to
485/// record `FileRoot::expected_hash` on persist and to revalidate on
486/// lookup.
487enum RevalidationOutcome {
488    Ok,
489    Invalidated,
490}
491
492/// Fingerprints `path` the way revalidation does for a root recorded as
493/// `expected`: a `dir:` prefix picks a directory listing, `stat:` a size+mtime
494/// fingerprint (never re-read), anything else a blake3 content digest.
495pub fn current_root_fingerprint(path: &Path, expected: &str) -> std::io::Result<String> {
496    if expected.starts_with(DIR_PREFIX) {
497        fingerprint_dir(path)
498    } else if expected.starts_with(STAT_PREFIX) {
499        stat_fingerprint(path)
500    } else {
501        hash_file(path)
502    }
503}
504
505fn revalidate_file_roots(workspace_base: &Path, roots: &[FileRoot]) -> RevalidationOutcome {
506    let debug = std::env::var_os("VERDANT_DEBUG_INVALIDATION").is_some();
507    for root in roots {
508        let resolved = resolve_root_path(workspace_base, &root.path);
509        let current = match current_root_fingerprint(&resolved, &root.expected_hash) {
510            Ok(c) => c,
511            Err(_) => {
512                if debug {
513                    eprintln!(
514                        "verdant: invalidated by missing/unreadable {}",
515                        resolved.display()
516                    );
517                }
518                return RevalidationOutcome::Invalidated;
519            }
520        };
521        if current != root.expected_hash {
522            if debug {
523                eprintln!("verdant: invalidated by changed {}", resolved.display());
524            }
525            return RevalidationOutcome::Invalidated;
526        }
527    }
528    RevalidationOutcome::Ok
529}
530
531/// Join a workspace base with a recorded `FileRoot::path`. On Unix,
532/// `PathBuf::join` replaces the base when the argument is absolute,
533/// so this also handles legacy entries persisted with absolute paths
534/// (single-machine M4) without breaking. New entries persist
535/// workspace-relative paths and resolve through the base; cross-user
536/// `_shared` entries work because every consumer joins against its
537/// own workspace.
538fn resolve_root_path(workspace_base: &Path, recorded: &Path) -> PathBuf {
539    workspace_base.join(recorded)
540}
541
542/// Lowercased lossy string form of a path, used as a case-insensitive
543/// comparison key for path invalidation.
544fn lower_path(p: &Path) -> String {
545    p.to_string_lossy().to_lowercase()
546}
547
548/// Default ceiling for content hashing. A file larger than this is
549/// reported `FileHash::Oversized` and is therefore uncacheable, because
550/// (see `FileHash`) a size/mtime fingerprint is not a safe substitute
551/// for a content hash.
552const HASH_MAX_BYTES: u64 = 100 * 1024 * 1024;
553
554/// The content-hash ceiling, read from `$VERDANT_HASH_MAX_BYTES` if set
555/// and parseable, otherwise `HASH_MAX_BYTES`.
556pub fn hash_max_bytes() -> u64 {
557    std::env::var("VERDANT_HASH_MAX_BYTES")
558        .ok()
559        .and_then(|s| s.parse::<u64>().ok())
560        .unwrap_or(HASH_MAX_BYTES)
561}
562
563/// Outcome of fingerprinting a file for cache keying.
564#[derive(Debug, Clone, PartialEq, Eq)]
565pub enum FileHash {
566    /// blake3 content digest, hex-encoded.
567    Content(String),
568    /// File is larger than the content-hash ceiling. It carries no
569    /// digest on purpose: a size/mtime fingerprint would collide two
570    /// different files with equal size and mtime onto one key, so a
571    /// tool whose output depends on an oversized file must not cache.
572    Oversized,
573}
574
575impl FileHash {
576    pub fn content(&self) -> Option<&str> {
577        match self {
578            FileHash::Content(h) => Some(h),
579            FileHash::Oversized => None,
580        }
581    }
582}
583
584/// blake3 hex digest of the file at `path`, streamed so memory stays
585/// bounded regardless of file size. Always content-hashes; callers that
586/// must not cache oversized files use `hash_file_with_limit` instead.
587pub fn hash_file(path: &Path) -> std::io::Result<String> {
588    let mut hasher = blake3::Hasher::new();
589    let mut f = std::fs::File::open(path)?;
590    let mut buf = [0u8; 1 << 16];
591    loop {
592        let n = std::io::Read::read(&mut f, &mut buf)?;
593        if n == 0 {
594            break;
595        }
596        hasher.update(&buf[..n]);
597    }
598    Ok(hasher.finalize().to_hex().to_string())
599}
600
601/// Fingerprint `path` for cache keying. A file at or below `max` bytes is
602/// content-hashed; a larger file is reported `Oversized` so the caller
603/// declines to cache rather than keying on a collision-prone fingerprint.
604pub fn hash_file_with_limit(path: &Path, max: u64) -> std::io::Result<FileHash> {
605    if std::fs::metadata(path)?.len() > max {
606        return Ok(FileHash::Oversized);
607    }
608    Ok(FileHash::Content(hash_file(path)?))
609}
610
611/// Content-addressed identity of a tool result. The MCP tool layer registers a
612/// node under this key carrying the file roots the result depended on, and the
613/// proxy records the same key as an upstream edge of any LLM call whose prompt
614/// embedded that result, so editing one of those files cascades file -> this
615/// node -> the dependent completions. Both layers must hash identically, so the
616/// function lives here in the shared runtime crate.
617pub fn tool_result_key(content: &[u8]) -> Key {
618    let mut framed = Vec::with_capacity(content.len() + 12);
619    framed.extend_from_slice(b"tool_result\0");
620    framed.extend_from_slice(content);
621    Key::from_bytes(&framed)
622}
623
624pub const STAT_PREFIX: &str = "stat:";
625pub const DIR_PREFIX: &str = "dir:";
626
627/// Fingerprint a directory's entry list: blake3 over the sorted entry names,
628/// length-framed so adjacent names cannot collide onto one digest. Only the
629/// names participate, not the entries' contents or metadata: a command that
630/// listed a directory depends on WHAT it found there, and each file it then
631/// read is fingerprinted separately by content. The `dir:` prefix tells
632/// `revalidate_file_roots` to recompute this instead of hashing file bytes,
633/// exactly how `stat:` already discriminates.
634pub fn fingerprint_dir(path: &Path) -> std::io::Result<String> {
635    let mut names: Vec<Vec<u8>> = Vec::new();
636    for entry in std::fs::read_dir(path)? {
637        names.push(entry?.file_name().as_encoded_bytes().to_vec());
638    }
639    names.sort();
640    let mut hasher = blake3::Hasher::new();
641    for name in &names {
642        hasher.update(&(name.len() as u64).to_le_bytes());
643        hasher.update(name);
644    }
645    Ok(format!("{DIR_PREFIX}{}", hasher.finalize().to_hex()))
646}
647
648/// A cheap size+mtime fingerprint for a file, used for large/stable inputs
649/// (compilers, system libraries) that would be prohibitively expensive to
650/// content-hash on every cache validation. Soundness assumption: identical
651/// (size, nanosecond mtime) implies identical content, the same assumption
652/// build tools already make for their own incrementality.
653pub fn stat_fingerprint(path: &Path) -> std::io::Result<String> {
654    let m = std::fs::metadata(path)?;
655    Ok(format!(
656        "{STAT_PREFIX}{}:{}:{}",
657        m.len(),
658        m.mtime(),
659        m.mtime_nsec()
660    ))
661}
662
663/// Fingerprint a read-set file: content-hash it if it is at or below
664/// `content_max` (sound), otherwise fall back to a size+mtime fingerprint so a
665/// huge stable input does not block caching or cost a full re-read on every
666/// validation. The returned string is what `revalidate_file_roots` recomputes
667/// and compares; its form (bare hex vs `stat:` prefix) tells revalidation which
668/// mode to use.
669pub fn fingerprint_file(path: &Path, content_max: u64) -> std::io::Result<String> {
670    if std::fs::metadata(path)?.len() > content_max {
671        stat_fingerprint(path)
672    } else {
673        hash_file(path)
674    }
675}
676
677#[cfg(test)]
678mod tests {
679    use super::*;
680    use tempfile::TempDir;
681
682    fn cache(dir: &TempDir) -> LiveCache {
683        let store = crate::store::FileStore::open(dir.path().join("store")).unwrap();
684        LiveCache::new(store)
685    }
686
687    fn write_file(dir: &TempDir, name: &str, content: &[u8]) -> PathBuf {
688        let p = dir.path().join(name);
689        std::fs::write(&p, content).unwrap();
690        p
691    }
692
693    fn root_for(p: &Path) -> FileRoot {
694        FileRoot {
695            path: p.to_path_buf(),
696            expected_hash: hash_file(p).unwrap(),
697        }
698    }
699
700    #[test]
701    fn miss_then_persist_then_hit() {
702        let dir = TempDir::new().unwrap();
703        let cache = cache(&dir);
704        let p = write_file(&dir, "a.txt", b"alpha");
705        let key = Key::from_bytes(b"read|a.txt|alpha");
706
707        assert_eq!(cache.lookup(&key).unwrap(), LookupOutcome::Miss);
708
709        cache
710            .persist(&key, b"alpha-formatted", "read", vec![root_for(&p)])
711            .unwrap();
712
713        match cache.lookup(&key).unwrap() {
714            LookupOutcome::Hit(payload) => {
715                assert_eq!(payload.bytes, b"alpha-formatted");
716                assert_eq!(payload.meta.tool_kind, "read");
717            }
718            other => panic!("expected Hit, got {other:?}"),
719        }
720    }
721
722    #[test]
723    fn revalidate_unchanged_returns_hit() {
724        let dir = TempDir::new().unwrap();
725        let cache = cache(&dir);
726        let p = write_file(&dir, "b.txt", b"beta");
727        let key = Key::from_bytes(b"read|b.txt|beta");
728        cache
729            .persist(&key, b"beta-formatted", "read", vec![root_for(&p)])
730            .unwrap();
731        match cache.lookup_revalidate(&key).unwrap() {
732            LookupOutcome::Hit(_) => {}
733            other => panic!("expected Hit, got {other:?}"),
734        }
735    }
736
737    #[test]
738    fn dir_root_revalidates_on_listing_and_invalidates_on_new_entry() {
739        let dir = TempDir::new().unwrap();
740        let cache = cache(&dir);
741        let listed = dir.path().join("listed");
742        std::fs::create_dir(&listed).unwrap();
743        write_file(&dir, "listed/a.txt", b"alpha");
744        let key = Key::from_bytes(b"exec|ls listed");
745        let root = FileRoot {
746            path: listed.clone(),
747            expected_hash: fingerprint_dir(&listed).unwrap(),
748        };
749        cache.persist(&key, b"a.txt\n", "exec", vec![root]).unwrap();
750
751        match cache.lookup_revalidate(&key).unwrap() {
752            LookupOutcome::Hit(_) => {}
753            other => panic!("unchanged listing must hit, got {other:?}"),
754        }
755
756        // Renaming or adding an entry changes the name list, not any file
757        // the command read, so only the dir fingerprint can catch it.
758        write_file(&dir, "listed/b.txt", b"bravo");
759        match cache.lookup_revalidate(&key).unwrap() {
760            LookupOutcome::Invalidated => {}
761            other => panic!("a new entry must invalidate, got {other:?}"),
762        }
763    }
764
765    #[test]
766    fn dir_root_of_a_removed_directory_invalidates() {
767        let dir = TempDir::new().unwrap();
768        let cache = cache(&dir);
769        let listed = dir.path().join("gone");
770        std::fs::create_dir(&listed).unwrap();
771        let key = Key::from_bytes(b"exec|ls gone");
772        let root = FileRoot {
773            path: listed.clone(),
774            expected_hash: fingerprint_dir(&listed).unwrap(),
775        };
776        cache.persist(&key, b"", "exec", vec![root]).unwrap();
777        std::fs::remove_dir(&listed).unwrap();
778        match cache.lookup_revalidate(&key).unwrap() {
779            LookupOutcome::Invalidated => {}
780            other => panic!("a vanished dir must invalidate, got {other:?}"),
781        }
782    }
783
784    #[test]
785    fn revalidate_modified_invalidates() {
786        let dir = TempDir::new().unwrap();
787        let cache = cache(&dir);
788        let p = write_file(&dir, "c.txt", b"charlie");
789        let key = Key::from_bytes(b"read|c.txt|charlie");
790        cache
791            .persist(&key, b"charlie-formatted", "read", vec![root_for(&p)])
792            .unwrap();
793
794        std::fs::write(&p, b"DELTA").unwrap();
795
796        match cache.lookup_revalidate(&key).unwrap() {
797            LookupOutcome::Invalidated => {}
798            other => panic!("expected Invalidated, got {other:?}"),
799        }
800        assert_eq!(cache.entry_count(), 0);
801    }
802
803    #[test]
804    fn revalidate_deleted_invalidates() {
805        let dir = TempDir::new().unwrap();
806        let cache = cache(&dir);
807        let p = write_file(&dir, "d.txt", b"delta");
808        let key = Key::from_bytes(b"read|d.txt|delta");
809        cache
810            .persist(&key, b"delta-formatted", "read", vec![root_for(&p)])
811            .unwrap();
812
813        std::fs::remove_file(&p).unwrap();
814
815        match cache.lookup_revalidate(&key).unwrap() {
816            LookupOutcome::Invalidated => {}
817            other => panic!("expected Invalidated, got {other:?}"),
818        }
819    }
820
821    #[test]
822    fn mark_dirty_drops_entry() {
823        let dir = TempDir::new().unwrap();
824        let cache = cache(&dir);
825        let p = write_file(&dir, "e.txt", b"echo");
826        let key = Key::from_bytes(b"read|e.txt|echo");
827        cache
828            .persist(&key, b"echo-formatted", "read", vec![root_for(&p)])
829            .unwrap();
830        assert_eq!(cache.entry_count(), 1);
831        cache.mark_dirty(&key);
832        assert_eq!(cache.entry_count(), 0);
833        assert_eq!(cache.lookup(&key).unwrap(), LookupOutcome::Miss);
834    }
835
836    #[test]
837    fn invalidate_path_drops_matching_entries() {
838        let dir = TempDir::new().unwrap();
839        let cache = cache(&dir);
840        let p1 = write_file(&dir, "f1.txt", b"foxtrot");
841        let p2 = write_file(&dir, "f2.txt", b"foxtrot2");
842        let k1 = Key::from_bytes(b"read|f1");
843        let k2 = Key::from_bytes(b"read|f2");
844        cache
845            .persist(&k1, b"f1-out", "read", vec![root_for(&p1)])
846            .unwrap();
847        cache
848            .persist(&k2, b"f2-out", "read", vec![root_for(&p2)])
849            .unwrap();
850        assert_eq!(cache.entry_count(), 2);
851        let n = cache.invalidate_path(&p1);
852        assert_eq!(n, 1);
853        assert_eq!(cache.entry_count(), 1);
854        // k1 invalidated, k2 still present
855        match cache.lookup(&k2).unwrap() {
856            LookupOutcome::Hit(_) => {}
857            other => panic!("k2 should still hit, got {other:?}"),
858        }
859        match cache.lookup(&k1).unwrap() {
860            LookupOutcome::Miss => {}
861            other => panic!("k1 should miss, got {other:?}"),
862        }
863    }
864
865    #[test]
866    fn invalidate_path_matches_case_insensitively() {
867        // On a case-insensitive filesystem `Src/Foo.rs` and `src/foo.rs`
868        // name the same file; a path edit reported under one casing must
869        // still invalidate an entry whose file root was recorded under
870        // another. A missed invalidation leaves a stale hit.
871        let dir = TempDir::new().unwrap();
872        let cache = cache(&dir);
873        let p = write_file(&dir, "CaseFile.txt", b"contents");
874        let key = Key::from_bytes(b"read|casefile");
875        cache
876            .persist(&key, b"formatted", "read", vec![root_for(&p)])
877            .unwrap();
878        assert_eq!(cache.entry_count(), 1);
879
880        let differently_cased = dir.path().join("casefile.txt");
881        let n = cache.invalidate_path(&differently_cased);
882        assert_eq!(n, 1, "case-differing path must still invalidate the entry");
883        assert_eq!(cache.entry_count(), 0);
884    }
885
886    #[test]
887    fn multi_root_revalidation() {
888        let dir = TempDir::new().unwrap();
889        let cache = cache(&dir);
890        let p1 = write_file(&dir, "g1.txt", b"golf1");
891        let p2 = write_file(&dir, "g2.txt", b"golf2");
892        let key = Key::from_bytes(b"grep|pattern|g1+g2");
893        cache
894            .persist(
895                &key,
896                b"merged-output",
897                "grep",
898                vec![root_for(&p1), root_for(&p2)],
899            )
900            .unwrap();
901
902        // First revalidation: clean
903        match cache.lookup_revalidate(&key).unwrap() {
904            LookupOutcome::Hit(_) => {}
905            other => panic!("expected Hit, got {other:?}"),
906        }
907        // Modify only the second root: must invalidate
908        std::fs::write(&p2, b"changed").unwrap();
909        match cache.lookup_revalidate(&key).unwrap() {
910            LookupOutcome::Invalidated => {}
911            other => panic!("expected Invalidated, got {other:?}"),
912        }
913    }
914
915    #[test]
916    fn upstream_invalidation_drops_dependents() {
917        let dir = TempDir::new().unwrap();
918        let cache = cache(&dir);
919        let p = write_file(&dir, "src.txt", b"alpha");
920        let read_key = Key::from_bytes(b"read|src");
921        cache
922            .persist(&read_key, b"alpha-formatted", "read", vec![root_for(&p)])
923            .unwrap();
924        // Two LlmCalls both depend on the read result.
925        let llm1 = Key::from_bytes(b"llm|first-prompt");
926        let llm2 = Key::from_bytes(b"llm|second-prompt");
927        cache
928            .persist_with_upstreams(
929                &llm1,
930                b"completion-1",
931                "llm_call",
932                vec![],
933                vec![read_key.clone()],
934            )
935            .unwrap();
936        cache
937            .persist_with_upstreams(
938                &llm2,
939                b"completion-2",
940                "llm_call",
941                vec![],
942                vec![read_key.clone()],
943            )
944            .unwrap();
945        assert_eq!(cache.entry_count(), 3);
946
947        // Invalidating the read key must cascade to both LlmCalls.
948        let dropped = cache.invalidate_upstream(&read_key);
949        assert_eq!(dropped, 2);
950        assert_eq!(cache.lookup(&llm1).unwrap(), LookupOutcome::Miss);
951        assert_eq!(cache.lookup(&llm2).unwrap(), LookupOutcome::Miss);
952    }
953
954    #[test]
955    fn invalidate_path_cascades_to_dependent_llm_calls() {
956        let dir = TempDir::new().unwrap();
957        let cache = cache(&dir);
958        let p = write_file(&dir, "input.txt", b"hello");
959        let read_key = Key::from_bytes(b"read|input");
960        cache
961            .persist(&read_key, b"hello-formatted", "read", vec![root_for(&p)])
962            .unwrap();
963        let llm = Key::from_bytes(b"llm|sees-read");
964        cache
965            .persist_with_upstreams(
966                &llm,
967                b"completion",
968                "llm_call",
969                vec![],
970                vec![read_key.clone()],
971            )
972            .unwrap();
973        assert_eq!(cache.entry_count(), 2);
974
975        // Modify the file and invalidate by path.
976        std::fs::write(&p, b"changed").unwrap();
977        let n = cache.invalidate_path(&p);
978        assert_eq!(n, 1, "the read entry was the direct path match");
979        // The LlmCall must also be gone via the cascade.
980        assert_eq!(cache.lookup(&llm).unwrap(), LookupOutcome::Miss);
981        assert_eq!(cache.entry_count(), 0);
982    }
983
984    #[test]
985    fn transitive_invalidation_walks_multi_hop_chain() {
986        // A -> B -> C: invalidating A drops B and C.
987        let dir = TempDir::new().unwrap();
988        let cache = cache(&dir);
989        let key_a = Key::from_bytes(b"a");
990        let key_b = Key::from_bytes(b"b");
991        let key_c = Key::from_bytes(b"c");
992        let p = write_file(&dir, "f.txt", b"x");
993        cache
994            .persist(&key_a, b"a-bytes", "read", vec![root_for(&p)])
995            .unwrap();
996        cache
997            .persist_with_upstreams(&key_b, b"b-bytes", "llm_call", vec![], vec![key_a.clone()])
998            .unwrap();
999        cache
1000            .persist_with_upstreams(&key_c, b"c-bytes", "llm_call", vec![], vec![key_b.clone()])
1001            .unwrap();
1002
1003        let dropped = cache.invalidate_upstream(&key_a);
1004        assert_eq!(dropped, 2);
1005        assert_eq!(cache.lookup(&key_b).unwrap(), LookupOutcome::Miss);
1006        assert_eq!(cache.lookup(&key_c).unwrap(), LookupOutcome::Miss);
1007    }
1008
1009    #[test]
1010    fn upstream_keys_persist_across_rehydration() {
1011        let dir = TempDir::new().unwrap();
1012        let p = write_file(&dir, "g.txt", b"data");
1013        let read_key = Key::from_bytes(b"read|g");
1014        let llm_key = Key::from_bytes(b"llm|g-consumer");
1015
1016        {
1017            let cache = cache(&dir);
1018            cache
1019                .persist(&read_key, b"data-formatted", "read", vec![root_for(&p)])
1020                .unwrap();
1021            cache
1022                .persist_with_upstreams(
1023                    &llm_key,
1024                    b"completion",
1025                    "llm_call",
1026                    vec![],
1027                    vec![read_key.clone()],
1028                )
1029                .unwrap();
1030        }
1031
1032        // Fresh cache pointed at the same store: the upstream edge
1033        // must come back so a subsequent invalidation cascades.
1034        let store_root = dir.path().join("store");
1035        let store2 = crate::store::FileStore::open(store_root).unwrap();
1036        let cache2 = LiveCache::new(store2);
1037        assert_eq!(cache2.entry_count(), 2);
1038        let dropped = cache2.invalidate_upstream(&read_key);
1039        assert_eq!(dropped, 1, "rehydrated edge must support cascade");
1040    }
1041
1042    #[test]
1043    fn cross_instance_file_edit_cascades_tool_and_llm() {
1044        // The production scenario: a tool process and a proxy process share one
1045        // store. Editing a file in a THIRD instance must drop both the tool
1046        // result node (file dependency) and the LLM completion that consumed it,
1047        // visible to a fresh instance. Proves cross-process incremental agent
1048        // reasoning via the shared store, not an in-memory registry.
1049        let dir = TempDir::new().unwrap();
1050        let f = write_file(&dir, "dep.txt", b"v1");
1051        let content = b"TOOL: contents of dep.txt";
1052        let tkey = tool_result_key(content);
1053        let llm_key = Key::from_bytes(b"llm|consumed-the-tool-result");
1054
1055        {
1056            let producer = cache(&dir);
1057            producer
1058                .persist(&tkey, content, "tool_result", vec![root_for(&f)])
1059                .unwrap();
1060            producer
1061                .persist_with_upstreams(
1062                    &llm_key,
1063                    b"completion-bytes",
1064                    "llm_call",
1065                    vec![],
1066                    vec![tkey.clone()],
1067                )
1068                .unwrap();
1069        }
1070
1071        {
1072            let editor = cache(&dir);
1073            std::fs::write(&f, b"v2-changed").unwrap();
1074            let n = editor.invalidate_path(&f);
1075            assert!(
1076                n >= 1,
1077                "the tool node depending on the file must be dropped"
1078            );
1079        }
1080
1081        let reader = cache(&dir);
1082        assert!(
1083            matches!(reader.lookup(&tkey).unwrap(), LookupOutcome::Miss),
1084            "tool result node must be gone cross-instance"
1085        );
1086        assert!(
1087            matches!(reader.lookup(&llm_key).unwrap(), LookupOutcome::Miss),
1088            "the dependent LLM completion must be gone cross-instance"
1089        );
1090    }
1091
1092    #[test]
1093    fn fresh_cache_rehydrates_from_store_on_disk() {
1094        // M1's whole point: a process restart must not invalidate the
1095        // cache. Persist via one cache instance, drop it, build a fresh
1096        // cache pointed at the same store directory, and confirm the
1097        // entry is still served as a Hit.
1098        let dir = TempDir::new().unwrap();
1099        let p = write_file(&dir, "rehydrate.txt", b"persist me");
1100        let key = Key::from_bytes(b"read|rehydrate|persist me");
1101
1102        {
1103            let cache = cache(&dir);
1104            cache
1105                .persist(&key, b"served-once", "read", vec![root_for(&p)])
1106                .unwrap();
1107            assert_eq!(cache.entry_count(), 1);
1108        } // drop cache; in-memory registry destroyed.
1109
1110        let store_root = dir.path().join("store");
1111        let store2 = crate::store::FileStore::open(store_root).unwrap();
1112        let cache2 = LiveCache::new(store2);
1113        // Without rehydration this would be 0 and the next lookup would
1114        // miss, defeating the entire cross-session caching story.
1115        assert_eq!(cache2.entry_count(), 1);
1116        match cache2.lookup_revalidate(&key).unwrap() {
1117            LookupOutcome::Hit(payload) => assert_eq!(payload.bytes, b"served-once"),
1118            other => panic!("expected Hit after rehydrate, got {other:?}"),
1119        }
1120    }
1121
1122    #[test]
1123    fn hit_returns_byte_identical_payload() {
1124        // Critical correctness test: cache must hand back the exact bytes
1125        // it persisted, not a re-formatted view. A divergence here would
1126        // silently corrupt the model's view of the world.
1127        let dir = TempDir::new().unwrap();
1128        let cache = cache(&dir);
1129        let p = write_file(&dir, "h.txt", b"hotel");
1130        let key = Key::from_bytes(b"read|h");
1131        let original = b"  1\thotel-formatted-with-line-numbers\n  2\tetc\n";
1132        cache
1133            .persist(&key, original, "read", vec![root_for(&p)])
1134            .unwrap();
1135        match cache.lookup_revalidate(&key).unwrap() {
1136            LookupOutcome::Hit(p) => assert_eq!(p.bytes, original),
1137            other => panic!("expected Hit, got {other:?}"),
1138        }
1139    }
1140
1141    #[test]
1142    fn hash_file_with_limit_content_hashes_within_limit() {
1143        let dir = TempDir::new().unwrap();
1144        let p = write_file(&dir, "small.bin", b"comfortably within the limit");
1145        match hash_file_with_limit(&p, 1024).unwrap() {
1146            FileHash::Content(h) => assert_eq!(h, hash_file(&p).unwrap()),
1147            FileHash::Oversized => panic!("a file within the limit must content-hash"),
1148        }
1149    }
1150
1151    #[test]
1152    fn hash_file_with_limit_reports_oversized_above_limit() {
1153        let dir = TempDir::new().unwrap();
1154        let p = write_file(&dir, "big.bin", &[7u8; 4096]);
1155        assert_eq!(hash_file_with_limit(&p, 64).unwrap(), FileHash::Oversized);
1156    }
1157
1158    #[test]
1159    fn oversized_files_yield_no_keyable_digest() {
1160        // The removed metadata fallback hashed (path, size, mtime), so two
1161        // distinct oversized files with equal size and mtime collided onto
1162        // one key. FileHash::Oversized carries no digest, so distinct
1163        // oversized files cannot be keyed against each other at all.
1164        let dir = TempDir::new().unwrap();
1165        let a = write_file(&dir, "a.bin", &[1u8; 4096]);
1166        let b = write_file(&dir, "b.bin", &[2u8; 4096]);
1167        let ha = hash_file_with_limit(&a, 64).unwrap();
1168        let hb = hash_file_with_limit(&b, 64).unwrap();
1169        assert_eq!(ha, FileHash::Oversized);
1170        assert_eq!(hb, FileHash::Oversized);
1171        assert!(ha.content().is_none() && hb.content().is_none());
1172    }
1173}