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
492fn revalidate_file_roots(workspace_base: &Path, roots: &[FileRoot]) -> RevalidationOutcome {
493    let debug = std::env::var_os("VERDANT_DEBUG_INVALIDATION").is_some();
494    for root in roots {
495        let resolved = resolve_root_path(workspace_base, &root.path);
496        // The recorded fingerprint dictates how to revalidate: a `stat:` prefix
497        // is a size+mtime fingerprint for a large/stable input (never re-read),
498        // anything else is a blake3 content digest (re-hashed, fully sound).
499        let current = if root.expected_hash.starts_with(DIR_PREFIX) {
500            match fingerprint_dir(&resolved) {
501                Ok(d) => d,
502                Err(_) => {
503                    if debug {
504                        eprintln!(
505                            "verdant: invalidated by missing/unlistable dir {}",
506                            resolved.display()
507                        );
508                    }
509                    return RevalidationOutcome::Invalidated;
510                }
511            }
512        } else if root.expected_hash.starts_with(STAT_PREFIX) {
513            match stat_fingerprint(&resolved) {
514                Ok(s) => s,
515                Err(_) => {
516                    if debug {
517                        eprintln!(
518                            "verdant: invalidated by missing/unreadable {}",
519                            resolved.display()
520                        );
521                    }
522                    return RevalidationOutcome::Invalidated;
523                }
524            }
525        } else {
526            match hash_file(&resolved) {
527                Ok(h) => h,
528                Err(_) => {
529                    if debug {
530                        eprintln!(
531                            "verdant: invalidated by missing/unreadable {}",
532                            resolved.display()
533                        );
534                    }
535                    return RevalidationOutcome::Invalidated;
536                }
537            }
538        };
539        if current != root.expected_hash {
540            if debug {
541                eprintln!("verdant: invalidated by changed {}", resolved.display());
542            }
543            return RevalidationOutcome::Invalidated;
544        }
545    }
546    RevalidationOutcome::Ok
547}
548
549/// Join a workspace base with a recorded `FileRoot::path`. On Unix,
550/// `PathBuf::join` replaces the base when the argument is absolute,
551/// so this also handles legacy entries persisted with absolute paths
552/// (single-machine M4) without breaking. New entries persist
553/// workspace-relative paths and resolve through the base; cross-user
554/// `_shared` entries work because every consumer joins against its
555/// own workspace.
556fn resolve_root_path(workspace_base: &Path, recorded: &Path) -> PathBuf {
557    workspace_base.join(recorded)
558}
559
560/// Lowercased lossy string form of a path, used as a case-insensitive
561/// comparison key for path invalidation.
562fn lower_path(p: &Path) -> String {
563    p.to_string_lossy().to_lowercase()
564}
565
566/// Default ceiling for content hashing. A file larger than this is
567/// reported `FileHash::Oversized` and is therefore uncacheable, because
568/// (see `FileHash`) a size/mtime fingerprint is not a safe substitute
569/// for a content hash.
570const HASH_MAX_BYTES: u64 = 100 * 1024 * 1024;
571
572/// The content-hash ceiling, read from `$VERDANT_HASH_MAX_BYTES` if set
573/// and parseable, otherwise `HASH_MAX_BYTES`.
574pub fn hash_max_bytes() -> u64 {
575    std::env::var("VERDANT_HASH_MAX_BYTES")
576        .ok()
577        .and_then(|s| s.parse::<u64>().ok())
578        .unwrap_or(HASH_MAX_BYTES)
579}
580
581/// Outcome of fingerprinting a file for cache keying.
582#[derive(Debug, Clone, PartialEq, Eq)]
583pub enum FileHash {
584    /// blake3 content digest, hex-encoded.
585    Content(String),
586    /// File is larger than the content-hash ceiling. It carries no
587    /// digest on purpose: a size/mtime fingerprint would collide two
588    /// different files with equal size and mtime onto one key, so a
589    /// tool whose output depends on an oversized file must not cache.
590    Oversized,
591}
592
593impl FileHash {
594    pub fn content(&self) -> Option<&str> {
595        match self {
596            FileHash::Content(h) => Some(h),
597            FileHash::Oversized => None,
598        }
599    }
600}
601
602/// blake3 hex digest of the file at `path`, streamed so memory stays
603/// bounded regardless of file size. Always content-hashes; callers that
604/// must not cache oversized files use `hash_file_with_limit` instead.
605pub fn hash_file(path: &Path) -> std::io::Result<String> {
606    let mut hasher = blake3::Hasher::new();
607    let mut f = std::fs::File::open(path)?;
608    let mut buf = [0u8; 1 << 16];
609    loop {
610        let n = std::io::Read::read(&mut f, &mut buf)?;
611        if n == 0 {
612            break;
613        }
614        hasher.update(&buf[..n]);
615    }
616    Ok(hasher.finalize().to_hex().to_string())
617}
618
619/// Fingerprint `path` for cache keying. A file at or below `max` bytes is
620/// content-hashed; a larger file is reported `Oversized` so the caller
621/// declines to cache rather than keying on a collision-prone fingerprint.
622pub fn hash_file_with_limit(path: &Path, max: u64) -> std::io::Result<FileHash> {
623    if std::fs::metadata(path)?.len() > max {
624        return Ok(FileHash::Oversized);
625    }
626    Ok(FileHash::Content(hash_file(path)?))
627}
628
629/// Content-addressed identity of a tool result. The MCP tool layer registers a
630/// node under this key carrying the file roots the result depended on, and the
631/// proxy records the same key as an upstream edge of any LLM call whose prompt
632/// embedded that result, so editing one of those files cascades file -> this
633/// node -> the dependent completions. Both layers must hash identically, so the
634/// function lives here in the shared runtime crate.
635pub fn tool_result_key(content: &[u8]) -> Key {
636    let mut framed = Vec::with_capacity(content.len() + 12);
637    framed.extend_from_slice(b"tool_result\0");
638    framed.extend_from_slice(content);
639    Key::from_bytes(&framed)
640}
641
642pub const STAT_PREFIX: &str = "stat:";
643pub const DIR_PREFIX: &str = "dir:";
644
645/// Fingerprint a directory's entry list: blake3 over the sorted entry names,
646/// length-framed so adjacent names cannot collide onto one digest. Only the
647/// names participate, not the entries' contents or metadata: a command that
648/// listed a directory depends on WHAT it found there, and each file it then
649/// read is fingerprinted separately by content. The `dir:` prefix tells
650/// `revalidate_file_roots` to recompute this instead of hashing file bytes,
651/// exactly how `stat:` already discriminates.
652pub fn fingerprint_dir(path: &Path) -> std::io::Result<String> {
653    let mut names: Vec<Vec<u8>> = Vec::new();
654    for entry in std::fs::read_dir(path)? {
655        names.push(entry?.file_name().as_encoded_bytes().to_vec());
656    }
657    names.sort();
658    let mut hasher = blake3::Hasher::new();
659    for name in &names {
660        hasher.update(&(name.len() as u64).to_le_bytes());
661        hasher.update(name);
662    }
663    Ok(format!("{DIR_PREFIX}{}", hasher.finalize().to_hex()))
664}
665
666/// A cheap size+mtime fingerprint for a file, used for large/stable inputs
667/// (compilers, system libraries) that would be prohibitively expensive to
668/// content-hash on every cache validation. Soundness assumption: identical
669/// (size, nanosecond mtime) implies identical content, the same assumption
670/// build tools already make for their own incrementality.
671pub fn stat_fingerprint(path: &Path) -> std::io::Result<String> {
672    let m = std::fs::metadata(path)?;
673    Ok(format!(
674        "{STAT_PREFIX}{}:{}:{}",
675        m.len(),
676        m.mtime(),
677        m.mtime_nsec()
678    ))
679}
680
681/// Fingerprint a read-set file: content-hash it if it is at or below
682/// `content_max` (sound), otherwise fall back to a size+mtime fingerprint so a
683/// huge stable input does not block caching or cost a full re-read on every
684/// validation. The returned string is what `revalidate_file_roots` recomputes
685/// and compares; its form (bare hex vs `stat:` prefix) tells revalidation which
686/// mode to use.
687pub fn fingerprint_file(path: &Path, content_max: u64) -> std::io::Result<String> {
688    if std::fs::metadata(path)?.len() > content_max {
689        stat_fingerprint(path)
690    } else {
691        hash_file(path)
692    }
693}
694
695#[cfg(test)]
696mod tests {
697    use super::*;
698    use tempfile::TempDir;
699
700    fn cache(dir: &TempDir) -> LiveCache {
701        let store = crate::store::FileStore::open(dir.path().join("store")).unwrap();
702        LiveCache::new(store)
703    }
704
705    fn write_file(dir: &TempDir, name: &str, content: &[u8]) -> PathBuf {
706        let p = dir.path().join(name);
707        std::fs::write(&p, content).unwrap();
708        p
709    }
710
711    fn root_for(p: &Path) -> FileRoot {
712        FileRoot {
713            path: p.to_path_buf(),
714            expected_hash: hash_file(p).unwrap(),
715        }
716    }
717
718    #[test]
719    fn miss_then_persist_then_hit() {
720        let dir = TempDir::new().unwrap();
721        let cache = cache(&dir);
722        let p = write_file(&dir, "a.txt", b"alpha");
723        let key = Key::from_bytes(b"read|a.txt|alpha");
724
725        assert_eq!(cache.lookup(&key).unwrap(), LookupOutcome::Miss);
726
727        cache
728            .persist(&key, b"alpha-formatted", "read", vec![root_for(&p)])
729            .unwrap();
730
731        match cache.lookup(&key).unwrap() {
732            LookupOutcome::Hit(payload) => {
733                assert_eq!(payload.bytes, b"alpha-formatted");
734                assert_eq!(payload.meta.tool_kind, "read");
735            }
736            other => panic!("expected Hit, got {other:?}"),
737        }
738    }
739
740    #[test]
741    fn revalidate_unchanged_returns_hit() {
742        let dir = TempDir::new().unwrap();
743        let cache = cache(&dir);
744        let p = write_file(&dir, "b.txt", b"beta");
745        let key = Key::from_bytes(b"read|b.txt|beta");
746        cache
747            .persist(&key, b"beta-formatted", "read", vec![root_for(&p)])
748            .unwrap();
749        match cache.lookup_revalidate(&key).unwrap() {
750            LookupOutcome::Hit(_) => {}
751            other => panic!("expected Hit, got {other:?}"),
752        }
753    }
754
755    #[test]
756    fn dir_root_revalidates_on_listing_and_invalidates_on_new_entry() {
757        let dir = TempDir::new().unwrap();
758        let cache = cache(&dir);
759        let listed = dir.path().join("listed");
760        std::fs::create_dir(&listed).unwrap();
761        write_file(&dir, "listed/a.txt", b"alpha");
762        let key = Key::from_bytes(b"exec|ls listed");
763        let root = FileRoot {
764            path: listed.clone(),
765            expected_hash: fingerprint_dir(&listed).unwrap(),
766        };
767        cache.persist(&key, b"a.txt\n", "exec", vec![root]).unwrap();
768
769        match cache.lookup_revalidate(&key).unwrap() {
770            LookupOutcome::Hit(_) => {}
771            other => panic!("unchanged listing must hit, got {other:?}"),
772        }
773
774        // Renaming or adding an entry changes the name list, not any file
775        // the command read, so only the dir fingerprint can catch it.
776        write_file(&dir, "listed/b.txt", b"bravo");
777        match cache.lookup_revalidate(&key).unwrap() {
778            LookupOutcome::Invalidated => {}
779            other => panic!("a new entry must invalidate, got {other:?}"),
780        }
781    }
782
783    #[test]
784    fn dir_root_of_a_removed_directory_invalidates() {
785        let dir = TempDir::new().unwrap();
786        let cache = cache(&dir);
787        let listed = dir.path().join("gone");
788        std::fs::create_dir(&listed).unwrap();
789        let key = Key::from_bytes(b"exec|ls gone");
790        let root = FileRoot {
791            path: listed.clone(),
792            expected_hash: fingerprint_dir(&listed).unwrap(),
793        };
794        cache.persist(&key, b"", "exec", vec![root]).unwrap();
795        std::fs::remove_dir(&listed).unwrap();
796        match cache.lookup_revalidate(&key).unwrap() {
797            LookupOutcome::Invalidated => {}
798            other => panic!("a vanished dir must invalidate, got {other:?}"),
799        }
800    }
801
802    #[test]
803    fn revalidate_modified_invalidates() {
804        let dir = TempDir::new().unwrap();
805        let cache = cache(&dir);
806        let p = write_file(&dir, "c.txt", b"charlie");
807        let key = Key::from_bytes(b"read|c.txt|charlie");
808        cache
809            .persist(&key, b"charlie-formatted", "read", vec![root_for(&p)])
810            .unwrap();
811
812        std::fs::write(&p, b"DELTA").unwrap();
813
814        match cache.lookup_revalidate(&key).unwrap() {
815            LookupOutcome::Invalidated => {}
816            other => panic!("expected Invalidated, got {other:?}"),
817        }
818        assert_eq!(cache.entry_count(), 0);
819    }
820
821    #[test]
822    fn revalidate_deleted_invalidates() {
823        let dir = TempDir::new().unwrap();
824        let cache = cache(&dir);
825        let p = write_file(&dir, "d.txt", b"delta");
826        let key = Key::from_bytes(b"read|d.txt|delta");
827        cache
828            .persist(&key, b"delta-formatted", "read", vec![root_for(&p)])
829            .unwrap();
830
831        std::fs::remove_file(&p).unwrap();
832
833        match cache.lookup_revalidate(&key).unwrap() {
834            LookupOutcome::Invalidated => {}
835            other => panic!("expected Invalidated, got {other:?}"),
836        }
837    }
838
839    #[test]
840    fn mark_dirty_drops_entry() {
841        let dir = TempDir::new().unwrap();
842        let cache = cache(&dir);
843        let p = write_file(&dir, "e.txt", b"echo");
844        let key = Key::from_bytes(b"read|e.txt|echo");
845        cache
846            .persist(&key, b"echo-formatted", "read", vec![root_for(&p)])
847            .unwrap();
848        assert_eq!(cache.entry_count(), 1);
849        cache.mark_dirty(&key);
850        assert_eq!(cache.entry_count(), 0);
851        assert_eq!(cache.lookup(&key).unwrap(), LookupOutcome::Miss);
852    }
853
854    #[test]
855    fn invalidate_path_drops_matching_entries() {
856        let dir = TempDir::new().unwrap();
857        let cache = cache(&dir);
858        let p1 = write_file(&dir, "f1.txt", b"foxtrot");
859        let p2 = write_file(&dir, "f2.txt", b"foxtrot2");
860        let k1 = Key::from_bytes(b"read|f1");
861        let k2 = Key::from_bytes(b"read|f2");
862        cache
863            .persist(&k1, b"f1-out", "read", vec![root_for(&p1)])
864            .unwrap();
865        cache
866            .persist(&k2, b"f2-out", "read", vec![root_for(&p2)])
867            .unwrap();
868        assert_eq!(cache.entry_count(), 2);
869        let n = cache.invalidate_path(&p1);
870        assert_eq!(n, 1);
871        assert_eq!(cache.entry_count(), 1);
872        // k1 invalidated, k2 still present
873        match cache.lookup(&k2).unwrap() {
874            LookupOutcome::Hit(_) => {}
875            other => panic!("k2 should still hit, got {other:?}"),
876        }
877        match cache.lookup(&k1).unwrap() {
878            LookupOutcome::Miss => {}
879            other => panic!("k1 should miss, got {other:?}"),
880        }
881    }
882
883    #[test]
884    fn invalidate_path_matches_case_insensitively() {
885        // On a case-insensitive filesystem `Src/Foo.rs` and `src/foo.rs`
886        // name the same file; a path edit reported under one casing must
887        // still invalidate an entry whose file root was recorded under
888        // another. A missed invalidation leaves a stale hit.
889        let dir = TempDir::new().unwrap();
890        let cache = cache(&dir);
891        let p = write_file(&dir, "CaseFile.txt", b"contents");
892        let key = Key::from_bytes(b"read|casefile");
893        cache
894            .persist(&key, b"formatted", "read", vec![root_for(&p)])
895            .unwrap();
896        assert_eq!(cache.entry_count(), 1);
897
898        let differently_cased = dir.path().join("casefile.txt");
899        let n = cache.invalidate_path(&differently_cased);
900        assert_eq!(n, 1, "case-differing path must still invalidate the entry");
901        assert_eq!(cache.entry_count(), 0);
902    }
903
904    #[test]
905    fn multi_root_revalidation() {
906        let dir = TempDir::new().unwrap();
907        let cache = cache(&dir);
908        let p1 = write_file(&dir, "g1.txt", b"golf1");
909        let p2 = write_file(&dir, "g2.txt", b"golf2");
910        let key = Key::from_bytes(b"grep|pattern|g1+g2");
911        cache
912            .persist(
913                &key,
914                b"merged-output",
915                "grep",
916                vec![root_for(&p1), root_for(&p2)],
917            )
918            .unwrap();
919
920        // First revalidation: clean
921        match cache.lookup_revalidate(&key).unwrap() {
922            LookupOutcome::Hit(_) => {}
923            other => panic!("expected Hit, got {other:?}"),
924        }
925        // Modify only the second root: must invalidate
926        std::fs::write(&p2, b"changed").unwrap();
927        match cache.lookup_revalidate(&key).unwrap() {
928            LookupOutcome::Invalidated => {}
929            other => panic!("expected Invalidated, got {other:?}"),
930        }
931    }
932
933    #[test]
934    fn upstream_invalidation_drops_dependents() {
935        let dir = TempDir::new().unwrap();
936        let cache = cache(&dir);
937        let p = write_file(&dir, "src.txt", b"alpha");
938        let read_key = Key::from_bytes(b"read|src");
939        cache
940            .persist(&read_key, b"alpha-formatted", "read", vec![root_for(&p)])
941            .unwrap();
942        // Two LlmCalls both depend on the read result.
943        let llm1 = Key::from_bytes(b"llm|first-prompt");
944        let llm2 = Key::from_bytes(b"llm|second-prompt");
945        cache
946            .persist_with_upstreams(
947                &llm1,
948                b"completion-1",
949                "llm_call",
950                vec![],
951                vec![read_key.clone()],
952            )
953            .unwrap();
954        cache
955            .persist_with_upstreams(
956                &llm2,
957                b"completion-2",
958                "llm_call",
959                vec![],
960                vec![read_key.clone()],
961            )
962            .unwrap();
963        assert_eq!(cache.entry_count(), 3);
964
965        // Invalidating the read key must cascade to both LlmCalls.
966        let dropped = cache.invalidate_upstream(&read_key);
967        assert_eq!(dropped, 2);
968        assert_eq!(cache.lookup(&llm1).unwrap(), LookupOutcome::Miss);
969        assert_eq!(cache.lookup(&llm2).unwrap(), LookupOutcome::Miss);
970    }
971
972    #[test]
973    fn invalidate_path_cascades_to_dependent_llm_calls() {
974        let dir = TempDir::new().unwrap();
975        let cache = cache(&dir);
976        let p = write_file(&dir, "input.txt", b"hello");
977        let read_key = Key::from_bytes(b"read|input");
978        cache
979            .persist(&read_key, b"hello-formatted", "read", vec![root_for(&p)])
980            .unwrap();
981        let llm = Key::from_bytes(b"llm|sees-read");
982        cache
983            .persist_with_upstreams(
984                &llm,
985                b"completion",
986                "llm_call",
987                vec![],
988                vec![read_key.clone()],
989            )
990            .unwrap();
991        assert_eq!(cache.entry_count(), 2);
992
993        // Modify the file and invalidate by path.
994        std::fs::write(&p, b"changed").unwrap();
995        let n = cache.invalidate_path(&p);
996        assert_eq!(n, 1, "the read entry was the direct path match");
997        // The LlmCall must also be gone via the cascade.
998        assert_eq!(cache.lookup(&llm).unwrap(), LookupOutcome::Miss);
999        assert_eq!(cache.entry_count(), 0);
1000    }
1001
1002    #[test]
1003    fn transitive_invalidation_walks_multi_hop_chain() {
1004        // A -> B -> C: invalidating A drops B and C.
1005        let dir = TempDir::new().unwrap();
1006        let cache = cache(&dir);
1007        let key_a = Key::from_bytes(b"a");
1008        let key_b = Key::from_bytes(b"b");
1009        let key_c = Key::from_bytes(b"c");
1010        let p = write_file(&dir, "f.txt", b"x");
1011        cache
1012            .persist(&key_a, b"a-bytes", "read", vec![root_for(&p)])
1013            .unwrap();
1014        cache
1015            .persist_with_upstreams(&key_b, b"b-bytes", "llm_call", vec![], vec![key_a.clone()])
1016            .unwrap();
1017        cache
1018            .persist_with_upstreams(&key_c, b"c-bytes", "llm_call", vec![], vec![key_b.clone()])
1019            .unwrap();
1020
1021        let dropped = cache.invalidate_upstream(&key_a);
1022        assert_eq!(dropped, 2);
1023        assert_eq!(cache.lookup(&key_b).unwrap(), LookupOutcome::Miss);
1024        assert_eq!(cache.lookup(&key_c).unwrap(), LookupOutcome::Miss);
1025    }
1026
1027    #[test]
1028    fn upstream_keys_persist_across_rehydration() {
1029        let dir = TempDir::new().unwrap();
1030        let p = write_file(&dir, "g.txt", b"data");
1031        let read_key = Key::from_bytes(b"read|g");
1032        let llm_key = Key::from_bytes(b"llm|g-consumer");
1033
1034        {
1035            let cache = cache(&dir);
1036            cache
1037                .persist(&read_key, b"data-formatted", "read", vec![root_for(&p)])
1038                .unwrap();
1039            cache
1040                .persist_with_upstreams(
1041                    &llm_key,
1042                    b"completion",
1043                    "llm_call",
1044                    vec![],
1045                    vec![read_key.clone()],
1046                )
1047                .unwrap();
1048        }
1049
1050        // Fresh cache pointed at the same store: the upstream edge
1051        // must come back so a subsequent invalidation cascades.
1052        let store_root = dir.path().join("store");
1053        let store2 = crate::store::FileStore::open(store_root).unwrap();
1054        let cache2 = LiveCache::new(store2);
1055        assert_eq!(cache2.entry_count(), 2);
1056        let dropped = cache2.invalidate_upstream(&read_key);
1057        assert_eq!(dropped, 1, "rehydrated edge must support cascade");
1058    }
1059
1060    #[test]
1061    fn cross_instance_file_edit_cascades_tool_and_llm() {
1062        // The production scenario: a tool process and a proxy process share one
1063        // store. Editing a file in a THIRD instance must drop both the tool
1064        // result node (file dependency) and the LLM completion that consumed it,
1065        // visible to a fresh instance. Proves cross-process incremental agent
1066        // reasoning via the shared store, not an in-memory registry.
1067        let dir = TempDir::new().unwrap();
1068        let f = write_file(&dir, "dep.txt", b"v1");
1069        let content = b"TOOL: contents of dep.txt";
1070        let tkey = tool_result_key(content);
1071        let llm_key = Key::from_bytes(b"llm|consumed-the-tool-result");
1072
1073        {
1074            let producer = cache(&dir);
1075            producer
1076                .persist(&tkey, content, "tool_result", vec![root_for(&f)])
1077                .unwrap();
1078            producer
1079                .persist_with_upstreams(
1080                    &llm_key,
1081                    b"completion-bytes",
1082                    "llm_call",
1083                    vec![],
1084                    vec![tkey.clone()],
1085                )
1086                .unwrap();
1087        }
1088
1089        {
1090            let editor = cache(&dir);
1091            std::fs::write(&f, b"v2-changed").unwrap();
1092            let n = editor.invalidate_path(&f);
1093            assert!(
1094                n >= 1,
1095                "the tool node depending on the file must be dropped"
1096            );
1097        }
1098
1099        let reader = cache(&dir);
1100        assert!(
1101            matches!(reader.lookup(&tkey).unwrap(), LookupOutcome::Miss),
1102            "tool result node must be gone cross-instance"
1103        );
1104        assert!(
1105            matches!(reader.lookup(&llm_key).unwrap(), LookupOutcome::Miss),
1106            "the dependent LLM completion must be gone cross-instance"
1107        );
1108    }
1109
1110    #[test]
1111    fn fresh_cache_rehydrates_from_store_on_disk() {
1112        // M1's whole point: a process restart must not invalidate the
1113        // cache. Persist via one cache instance, drop it, build a fresh
1114        // cache pointed at the same store directory, and confirm the
1115        // entry is still served as a Hit.
1116        let dir = TempDir::new().unwrap();
1117        let p = write_file(&dir, "rehydrate.txt", b"persist me");
1118        let key = Key::from_bytes(b"read|rehydrate|persist me");
1119
1120        {
1121            let cache = cache(&dir);
1122            cache
1123                .persist(&key, b"served-once", "read", vec![root_for(&p)])
1124                .unwrap();
1125            assert_eq!(cache.entry_count(), 1);
1126        } // drop cache; in-memory registry destroyed.
1127
1128        let store_root = dir.path().join("store");
1129        let store2 = crate::store::FileStore::open(store_root).unwrap();
1130        let cache2 = LiveCache::new(store2);
1131        // Without rehydration this would be 0 and the next lookup would
1132        // miss, defeating the entire cross-session caching story.
1133        assert_eq!(cache2.entry_count(), 1);
1134        match cache2.lookup_revalidate(&key).unwrap() {
1135            LookupOutcome::Hit(payload) => assert_eq!(payload.bytes, b"served-once"),
1136            other => panic!("expected Hit after rehydrate, got {other:?}"),
1137        }
1138    }
1139
1140    #[test]
1141    fn hit_returns_byte_identical_payload() {
1142        // Critical correctness test: cache must hand back the exact bytes
1143        // it persisted, not a re-formatted view. A divergence here would
1144        // silently corrupt the model's view of the world.
1145        let dir = TempDir::new().unwrap();
1146        let cache = cache(&dir);
1147        let p = write_file(&dir, "h.txt", b"hotel");
1148        let key = Key::from_bytes(b"read|h");
1149        let original = b"  1\thotel-formatted-with-line-numbers\n  2\tetc\n";
1150        cache
1151            .persist(&key, original, "read", vec![root_for(&p)])
1152            .unwrap();
1153        match cache.lookup_revalidate(&key).unwrap() {
1154            LookupOutcome::Hit(p) => assert_eq!(p.bytes, original),
1155            other => panic!("expected Hit, got {other:?}"),
1156        }
1157    }
1158
1159    #[test]
1160    fn hash_file_with_limit_content_hashes_within_limit() {
1161        let dir = TempDir::new().unwrap();
1162        let p = write_file(&dir, "small.bin", b"comfortably within the limit");
1163        match hash_file_with_limit(&p, 1024).unwrap() {
1164            FileHash::Content(h) => assert_eq!(h, hash_file(&p).unwrap()),
1165            FileHash::Oversized => panic!("a file within the limit must content-hash"),
1166        }
1167    }
1168
1169    #[test]
1170    fn hash_file_with_limit_reports_oversized_above_limit() {
1171        let dir = TempDir::new().unwrap();
1172        let p = write_file(&dir, "big.bin", &[7u8; 4096]);
1173        assert_eq!(hash_file_with_limit(&p, 64).unwrap(), FileHash::Oversized);
1174    }
1175
1176    #[test]
1177    fn oversized_files_yield_no_keyable_digest() {
1178        // The removed metadata fallback hashed (path, size, mtime), so two
1179        // distinct oversized files with equal size and mtime collided onto
1180        // one key. FileHash::Oversized carries no digest, so distinct
1181        // oversized files cannot be keyed against each other at all.
1182        let dir = TempDir::new().unwrap();
1183        let a = write_file(&dir, "a.bin", &[1u8; 4096]);
1184        let b = write_file(&dir, "b.bin", &[2u8; 4096]);
1185        let ha = hash_file_with_limit(&a, 64).unwrap();
1186        let hb = hash_file_with_limit(&b, 64).unwrap();
1187        assert_eq!(ha, FileHash::Oversized);
1188        assert_eq!(hb, FileHash::Oversized);
1189        assert!(ha.content().is_none() && hb.content().is_none());
1190    }
1191}