1use std::path::{Path, PathBuf};
11use std::sync::Mutex;
12use std::time::{Duration, Instant};
13
14use chrono::{DateTime, Utc};
15use serde::{Deserialize, Serialize};
16use sha2::{Digest, Sha256};
17
18use crate::core::security_events::SecurityCounts;
19
20pub const SCHEMA: u32 = 1;
21
22const WRITE_INTERVAL: Duration = Duration::from_secs(1);
24
25#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
26#[serde(default)]
27pub struct ValueSnapshot {
28 pub schema: u32,
29 pub session_id: String,
30 pub project_root: Option<String>,
31 pub started_at: Option<DateTime<Utc>>,
32 pub updated_at: Option<DateTime<Utc>>,
33 pub tool_calls: u64,
34 pub tokens_input: u64,
36 pub tokens_saved: u64,
38 pub cache_hits: u64,
39 pub files_read: u64,
40 pub commands_run: u64,
41 pub security: SecurityCounts,
42 pub host_pid: Option<u32>,
44}
45
46impl ValueSnapshot {
47 pub fn from_session(session: &crate::core::session::SessionState) -> Self {
48 let s = &session.stats;
49 Self {
50 schema: SCHEMA,
51 session_id: session.id.clone(),
52 project_root: session.project_root.clone(),
53 started_at: Some(session.started_at),
54 updated_at: Some(Utc::now()),
55 tool_calls: u64::from(s.total_tool_calls),
56 tokens_input: s.total_tokens_input,
57 tokens_saved: s.total_tokens_saved,
58 cache_hits: u64::from(s.cache_hits),
59 files_read: u64::from(s.files_read),
60 commands_run: u64::from(s.commands_run),
61 security: s.security,
62 host_pid: super::host::host_pid(),
63 }
64 }
65
66 pub fn saved_pct(&self) -> Option<f64> {
68 (self.tokens_input > 0).then(|| self.tokens_saved as f64 * 100.0 / self.tokens_input as f64)
69 }
70
71 pub fn is_fresh(&self, max_age: Duration) -> bool {
74 let Some(updated) = self.updated_at else {
75 return false;
76 };
77 let age = Utc::now().signed_duration_since(updated);
78 age.to_std().map_or(true, |age| age <= max_age)
79 }
80
81 pub fn is_empty(&self) -> bool {
83 self.tokens_saved == 0 && self.security.is_empty()
84 }
85}
86
87pub fn value_dir() -> Option<PathBuf> {
88 crate::core::data_dir::lean_ctx_data_dir()
89 .ok()
90 .map(|dir| dir.join("value"))
91}
92
93fn sanitize_id(id: &str) -> Option<&str> {
94 let ok = !id.is_empty()
95 && id.len() <= 128
96 && id
97 .chars()
98 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
99 && !id.starts_with('.');
100 ok.then_some(id)
101}
102
103pub fn session_path(dir: &Path, id: &str) -> Option<PathBuf> {
104 sanitize_id(id).map(|id| dir.join("sessions").join(format!("{id}.json")))
105}
106
107pub fn current_path(dir: &Path) -> PathBuf {
108 dir.join("current.json")
109}
110
111pub fn project_path(dir: &Path, root: &str) -> PathBuf {
115 let digest = crate::core::agent_identity::hex_encode(&Sha256::digest(root.as_bytes()));
116 dir.join("projects").join(format!("{}.json", &digest[..16]))
117}
118
119pub fn write_to(dir: &Path, snap: &ValueSnapshot) -> Result<(), String> {
122 let path = session_path(dir, &snap.session_id).ok_or("invalid session id")?;
123 let json = serde_json::to_vec(snap).map_err(|e| e.to_string())?;
124 let project = snap
125 .project_root
126 .as_deref()
127 .filter(|root| !root.is_empty())
128 .map(|root| project_path(dir, root));
129 for target in [Some(&path), project.as_ref()].into_iter().flatten() {
130 if let Some(parent) = target.parent() {
131 std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
132 }
133 crate::core::atomic_fs::try_atomic_write(target, &json, None).map_err(|e| e.to_string())?;
134 }
135 crate::core::atomic_fs::try_atomic_write(¤t_path(dir), &json, None)
136 .map_err(|e| e.to_string())
137}
138
139pub fn write_throttled(snap: &ValueSnapshot) {
143 static LAST: Mutex<Option<(Instant, SecurityCounts)>> = Mutex::new(None);
144 {
145 let Ok(mut last) = LAST.lock() else { return };
146 if let Some((at, security)) = *last
147 && at.elapsed() < WRITE_INTERVAL
148 && security == snap.security
149 {
150 return;
151 }
152 *last = Some((Instant::now(), snap.security));
153 }
154 let Some(dir) = value_dir() else { return };
155 if let Err(error) = write_to(&dir, snap) {
156 tracing::debug!("lean-ctx: value snapshot write failed: {error}");
157 }
158}
159
160pub fn read_path(path: &Path) -> Option<ValueSnapshot> {
161 let bytes = std::fs::read(path).ok()?;
162 serde_json::from_slice(&bytes).ok()
163}
164
165pub fn load(id: Option<&str>) -> Option<ValueSnapshot> {
167 let dir = value_dir()?;
168 match id {
169 Some(id) => read_path(&session_path(&dir, id)?),
170 None => read_path(¤t_path(&dir)),
171 }
172}
173
174pub fn load_for_dir(cwd: &Path) -> Option<ValueSnapshot> {
177 load_for_dir_in(&value_dir()?, cwd)
178}
179
180pub fn load_for_dir_in(dir: &Path, cwd: &Path) -> Option<ValueSnapshot> {
181 let canonical = crate::core::pathutil::safe_canonicalize_or_self(cwd);
182 [canonical.as_path(), cwd]
183 .into_iter()
184 .flat_map(Path::ancestors)
185 .filter_map(Path::to_str)
186 .find_map(|root| {
187 read_path(&project_path(dir, root))
188 .filter(|snap| snap.project_root.as_deref() == Some(root))
189 })
190}
191
192pub fn load_for_caller_in(dir: &Path, cwd: &Path, max_age: Duration) -> Option<ValueSnapshot> {
200 load_own_or_legacy_in(dir, cwd, &super::host::ancestors(), max_age)
201}
202
203pub fn load_own_or_legacy_in(
205 dir: &Path,
206 cwd: &Path,
207 hosts: &[u32],
208 max_age: Duration,
209) -> Option<ValueSnapshot> {
210 let sessions = fresh_project_sessions(dir, cwd, max_age);
211 if let Some(own) = pick_by_host(&sessions, hosts) {
212 return Some(own);
213 }
214 if sessions.iter().any(|s| s.host_pid.is_some()) {
215 return None;
216 }
217 load_for_dir_in(dir, cwd).filter(|s| s.is_fresh(max_age))
218}
219
220pub fn load_for_hosts_in(
223 dir: &Path,
224 cwd: &Path,
225 hosts: &[u32],
226 max_age: Duration,
227) -> Option<ValueSnapshot> {
228 pick_by_host(&fresh_project_sessions(dir, cwd, max_age), hosts)
229}
230
231pub fn host_recorded_in(dir: &Path, cwd: &Path, max_age: Duration) -> bool {
234 fresh_project_sessions(dir, cwd, max_age)
235 .iter()
236 .any(|s| s.host_pid.is_some())
237}
238
239fn pick_by_host(sessions: &[ValueSnapshot], hosts: &[u32]) -> Option<ValueSnapshot> {
240 sessions
241 .iter()
242 .filter_map(|s| Some((hosts.iter().position(|&h| Some(h) == s.host_pid)?, s)))
243 .min_by(|(ra, a), (rb, b)| ra.cmp(rb).then(b.updated_at.cmp(&a.updated_at)))
244 .map(|(_, s)| s.clone())
245}
246
247fn fresh_project_sessions(dir: &Path, cwd: &Path, max_age: Duration) -> Vec<ValueSnapshot> {
249 let canonical = crate::core::pathutil::safe_canonicalize_or_self(cwd);
250 let in_project = |root: &str| canonical.starts_with(root) || cwd.starts_with(root);
251 let (Some(cutoff), Ok(entries)) = (
252 std::time::SystemTime::now().checked_sub(max_age),
253 std::fs::read_dir(dir.join("sessions")),
254 ) else {
255 return Vec::new();
256 };
257 entries
258 .filter_map(Result::ok)
259 .filter(|e| {
261 e.metadata()
262 .and_then(|m| m.modified())
263 .is_ok_and(|t| t >= cutoff)
264 })
265 .filter_map(|e| read_path(&e.path()))
266 .filter(|s| s.is_fresh(max_age) && s.project_root.as_deref().is_some_and(in_project))
267 .collect()
268}
269
270#[cfg(test)]
271mod tests {
272 use super::*;
273
274 fn snap(id: &str) -> ValueSnapshot {
275 ValueSnapshot {
276 schema: SCHEMA,
277 session_id: id.into(),
278 updated_at: Some(Utc::now()),
279 tokens_input: 1_000,
280 tokens_saved: 400,
281 ..ValueSnapshot::default()
282 }
283 }
284
285 #[test]
286 fn project_snapshot_is_found_from_any_subdirectory() {
287 let data = tempfile::tempdir().unwrap();
288 let project = tempfile::tempdir().unwrap();
289 let root = crate::core::pathutil::safe_canonicalize_or_self(project.path());
290 let nested = root.join("src/deep");
291 std::fs::create_dir_all(&nested).unwrap();
292 let mut s = snap("sess-p");
293 s.project_root = Some(root.to_string_lossy().into_owned());
294 write_to(data.path(), &s).unwrap();
295
296 assert_eq!(load_for_dir_in(data.path(), &nested), Some(s.clone()));
297 assert_eq!(load_for_dir_in(data.path(), &root), Some(s));
298 let elsewhere = tempfile::tempdir().unwrap();
299 assert_eq!(load_for_dir_in(data.path(), elsewhere.path()), None);
300 }
301
302 #[test]
303 fn each_agent_reads_its_own_session_not_the_last_writer() {
304 let data = tempfile::tempdir().unwrap();
305 let project = tempfile::tempdir().unwrap();
306 let root = crate::core::pathutil::safe_canonicalize_or_self(project.path());
307 let root_str = root.to_string_lossy().into_owned();
308 let mut ours = snap("ours");
309 ours.project_root = Some(root_str.clone());
310 ours.host_pid = Some(4_100);
311 ours.tokens_saved = 4_200;
312 let mut theirs = snap("theirs");
313 theirs.project_root = Some(root_str);
314 theirs.host_pid = Some(9_900);
315 theirs.tokens_saved = 357_500;
316 write_to(data.path(), &ours).unwrap();
317 write_to(data.path(), &theirs).unwrap(); let max = Duration::from_hours(12);
320 let found = load_for_hosts_in(data.path(), &root, &[77, 4_100, 1_000], max).unwrap();
321 assert_eq!(found.session_id, "ours");
322 assert_eq!(load_for_hosts_in(data.path(), &root, &[5, 6], max), None);
325 assert_eq!(
326 load_for_dir_in(data.path(), &root).unwrap().session_id,
327 "theirs"
328 );
329 assert_eq!(
330 load_own_or_legacy_in(data.path(), &root, &[5, 6], max),
331 None
332 );
333 assert!(host_recorded_in(data.path(), &root, max));
334 let own = load_own_or_legacy_in(data.path(), &root, &[4_100], max).unwrap();
335 assert_eq!(own.session_id, "ours");
336 let elsewhere = tempfile::tempdir().unwrap();
337 assert_eq!(
338 load_for_hosts_in(data.path(), elsewhere.path(), &[4_100], max),
339 None
340 );
341 }
342
343 #[test]
344 fn servers_without_a_host_record_keep_the_project_fallback() {
345 let data = tempfile::tempdir().unwrap();
346 let project = tempfile::tempdir().unwrap();
347 let root = crate::core::pathutil::safe_canonicalize_or_self(project.path());
348 let mut legacy = snap("legacy");
349 legacy.project_root = Some(root.to_string_lossy().into_owned());
350 write_to(data.path(), &legacy).unwrap();
351 let max = Duration::from_hours(12);
352 assert!(!host_recorded_in(data.path(), &root, max));
353 let found = load_own_or_legacy_in(data.path(), &root, &[1_234], max).unwrap();
354 assert_eq!(found.session_id, "legacy");
355 }
356
357 #[test]
358 fn roundtrip_through_session_and_current_file() {
359 let dir = tempfile::tempdir().unwrap();
360 let s = snap("sess-1");
361 write_to(dir.path(), &s).unwrap();
362 assert_eq!(
363 read_path(&session_path(dir.path(), "sess-1").unwrap()),
364 Some(s.clone())
365 );
366 assert_eq!(read_path(¤t_path(dir.path())), Some(s));
367 }
368
369 #[test]
370 fn rejects_path_traversal_ids() {
371 let dir = tempfile::tempdir().unwrap();
372 assert!(session_path(dir.path(), "../evil").is_none());
373 assert!(session_path(dir.path(), "a/b").is_none());
374 assert!(session_path(dir.path(), "").is_none());
375 assert!(write_to(dir.path(), &snap("../x")).is_err());
376 }
377
378 #[test]
379 fn staleness_is_detected() {
380 let mut s = snap("s");
381 assert!(s.is_fresh(Duration::from_mins(1)));
382 s.updated_at = Some(Utc::now() - chrono::Duration::hours(2));
383 assert!(!s.is_fresh(Duration::from_mins(1)));
384 s.updated_at = None;
385 assert!(!s.is_fresh(Duration::from_mins(1)));
386 }
387
388 #[test]
389 fn saved_pct_is_derived_from_measured_counts() {
390 assert_eq!(snap("s").saved_pct(), Some(40.0));
391 assert_eq!(ValueSnapshot::default().saved_pct(), None);
392 }
393
394 #[test]
395 fn unknown_fields_and_missing_fields_are_tolerated() {
396 let s: ValueSnapshot =
397 serde_json::from_str(r#"{"session_id":"x","future_field":1}"#).unwrap();
398 assert_eq!(s.session_id, "x");
399 assert!(s.is_empty());
400 }
401}