Skip to main content

nexo_memory_snapshot/
path_resolver.rs

1//! Per-agent path discovery for the snapshotter.
2//!
3//! By default `LocalFsSnapshotter` resolves an agent's memdir as
4//! `<memdir_root>/<agent_id>` and its SQLite store as
5//! `<sqlite_root>/<agent_id>` — the operator-supplied globals from
6//! YAML. That is correct for single-tenant deployments where every
7//! agent shares the same memory layout.
8//!
9//! Multi-tenant SaaS breaks the symmetry: each agent's
10//! memdir typically lives under its own workspace
11//! (`agent_cfg.workspace/.git/`), and per-agent SQLite databases may
12//! sit under tenant-scoped state directories. The trait below lets
13//! the boot wire override the default lookup so the snapshotter
14//! captures the right files for each agent without requiring a YAML
15//! field per agent.
16//!
17//! Provider-agnostic by construction: the resolver knows nothing
18//! about LLM providers, brokers, or session stores. It is a pure
19//! `(agent_id, tenant) → paths` function.
20
21use std::path::PathBuf;
22
23/// Strategy for mapping an agent identity to its on-disk memory
24/// layout. Implementations must be deterministic (the same input
25/// always yields the same output) and safe to call from any thread.
26pub trait PathResolver: Send + Sync + 'static {
27    /// Where the agent's git-backed memory directory lives. The
28    /// snapshotter reads `.git/**` and any non-`.git` regular files
29    /// under this path.
30    fn memdir(&self, agent_id: &str, tenant: &str) -> PathBuf;
31
32    /// Where the agent's SQLite stores live. The snapshotter expects
33    /// `long_term.sqlite`, `vector.sqlite`, `concepts.sqlite`, and
34    /// `compactions.sqlite` directly under this path; missing files
35    /// are simply skipped.
36    fn sqlite_dir(&self, agent_id: &str, tenant: &str) -> PathBuf;
37}
38
39/// Default impl: `<memdir_root>/<agent_id>` and
40/// `<sqlite_root>/<agent_id>` — the layout the YAML config assumes
41/// when no operator override is supplied. Used by the `Builder` when
42/// the boot wire does not inject a richer resolver.
43#[derive(Debug, Clone)]
44pub struct DefaultPathResolver {
45    memdir_root: PathBuf,
46    sqlite_root: PathBuf,
47}
48
49impl DefaultPathResolver {
50    pub fn new(memdir_root: PathBuf, sqlite_root: PathBuf) -> Self {
51        Self {
52            memdir_root,
53            sqlite_root,
54        }
55    }
56}
57
58impl PathResolver for DefaultPathResolver {
59    fn memdir(&self, agent_id: &str, _tenant: &str) -> PathBuf {
60        self.memdir_root.join(agent_id)
61    }
62
63    fn sqlite_dir(&self, agent_id: &str, _tenant: &str) -> PathBuf {
64        self.sqlite_root.join(agent_id)
65    }
66}
67
68/// Builder helper: a closure-backed resolver. Useful at the boot
69/// wire when the lookup needs to consult an existing struct
70/// (e.g. an agent registry) without forcing the caller to define a
71/// dedicated type.
72pub struct ClosureResolver<F1, F2>
73where
74    F1: Fn(&str, &str) -> PathBuf + Send + Sync + 'static,
75    F2: Fn(&str, &str) -> PathBuf + Send + Sync + 'static,
76{
77    memdir_fn: F1,
78    sqlite_fn: F2,
79}
80
81impl<F1, F2> ClosureResolver<F1, F2>
82where
83    F1: Fn(&str, &str) -> PathBuf + Send + Sync + 'static,
84    F2: Fn(&str, &str) -> PathBuf + Send + Sync + 'static,
85{
86    pub fn new(memdir_fn: F1, sqlite_fn: F2) -> Self {
87        Self {
88            memdir_fn,
89            sqlite_fn,
90        }
91    }
92}
93
94impl<F1, F2> PathResolver for ClosureResolver<F1, F2>
95where
96    F1: Fn(&str, &str) -> PathBuf + Send + Sync + 'static,
97    F2: Fn(&str, &str) -> PathBuf + Send + Sync + 'static,
98{
99    fn memdir(&self, agent_id: &str, tenant: &str) -> PathBuf {
100        (self.memdir_fn)(agent_id, tenant)
101    }
102
103    fn sqlite_dir(&self, agent_id: &str, tenant: &str) -> PathBuf {
104        (self.sqlite_fn)(agent_id, tenant)
105    }
106}
107
108#[cfg(test)]
109mod tests {
110    use super::*;
111    use std::sync::Arc;
112
113    #[test]
114    fn default_resolver_joins_agent_id_under_root() {
115        let r = DefaultPathResolver::new(
116            PathBuf::from("/var/lib/memdir"),
117            PathBuf::from("/var/lib/sqlite"),
118        );
119        assert_eq!(
120            r.memdir("ana", "default"),
121            PathBuf::from("/var/lib/memdir/ana")
122        );
123        assert_eq!(
124            r.sqlite_dir("ana", "default"),
125            PathBuf::from("/var/lib/sqlite/ana")
126        );
127    }
128
129    #[test]
130    fn default_resolver_ignores_tenant_for_paths() {
131        // Single-tenant fallback never branches on tenant — it falls
132        // through whatever the operator set in YAML.
133        let r = DefaultPathResolver::new(PathBuf::from("/x"), PathBuf::from("/y"));
134        assert_eq!(r.memdir("ana", "acme"), r.memdir("ana", "globex"));
135    }
136
137    #[test]
138    fn closure_resolver_routes_per_tenant() {
139        let r = ClosureResolver::new(
140            |agent: &str, tenant: &str| PathBuf::from(format!("/var/{tenant}/memdir/{agent}")),
141            |agent: &str, tenant: &str| PathBuf::from(format!("/var/{tenant}/sqlite/{agent}")),
142        );
143        assert_eq!(
144            r.memdir("ana", "acme"),
145            PathBuf::from("/var/acme/memdir/ana")
146        );
147        assert_eq!(
148            r.memdir("ana", "globex"),
149            PathBuf::from("/var/globex/memdir/ana")
150        );
151    }
152
153    #[test]
154    fn dyn_path_resolver_can_be_held_as_arc() {
155        let r: Arc<dyn PathResolver> = Arc::new(DefaultPathResolver::new(
156            PathBuf::from("/a"),
157            PathBuf::from("/b"),
158        ));
159        assert_eq!(r.memdir("x", "default"), PathBuf::from("/a/x"));
160    }
161}