lean_ctx/core/context_ledger/
persistence.rs1use std::path::Path;
6
7use super::{ContextLedger, LedgerEntry};
8use crate::core::context_field::ContextState;
9
10impl ContextLedger {
11 pub fn read_entry(&self, path: &str) -> Option<LedgerEntry> {
12 let path = crate::core::pathutil::normalize_tool_path(path);
13 self.entries
14 .iter()
15 .find(|entry| entry.path == path)
16 .cloned()
17 }
18
19 pub async fn persist_read(entry: Option<LedgerEntry>) -> Result<(), String> {
22 let entry = entry.ok_or("completed read has no ledger entry")?;
23 tokio::task::spawn_blocking(move || {
24 let path = super::helpers::ledger_path("default")?;
25 persist_entry_at(&path, entry)
26 })
27 .await
28 .map_err(|error| format!("ledger persistence worker failed: {error}"))?
29 }
30}
31
32fn persist_entry_at(path: &Path, mut entry: LedgerEntry) -> Result<(), String> {
33 if let Some(parent) = path.parent() {
34 crate::config_io::ensure_dir(parent)?;
35 }
36 const LEDGER_LOCK_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
42 let _lock = crate::core::agents::FileLock::acquire_with_timeout(
43 &path.with_extension("json.lock"),
44 LEDGER_LOCK_TIMEOUT,
45 )?;
46 let mut current: ContextLedger = match std::fs::symlink_metadata(path) {
47 Ok(meta) => {
48 if !meta.is_file() || meta.len() > 8 * 1024 * 1024 {
49 return Err("ledger must be a regular file of at most 8 MiB".into());
50 }
51 let bytes = std::fs::read(path).map_err(|error| format!("read ledger: {error}"))?;
52 serde_json::from_slice(&bytes).map_err(|error| format!("decode ledger: {error}"))?
53 }
54 Err(error) if error.kind() == std::io::ErrorKind::NotFound => ContextLedger::new(),
55 Err(error) => return Err(format!("inspect ledger: {error}")),
56 };
57 if let Some(previous) = current
58 .entries
59 .iter_mut()
60 .find(|old| old.path == entry.path)
61 {
62 previous.mode = entry.mode;
65 previous.original_tokens = entry.original_tokens;
66 previous.sent_tokens = entry.sent_tokens;
67 previous.timestamp = entry.timestamp;
68 previous.access_count = previous.access_count.saturating_add(1);
69 previous.active_view = entry.active_view;
70 previous.phi = entry.phi;
71 if previous.id.is_none() {
72 previous.id = entry.id;
73 }
74 if previous.state.is_none() || previous.state == Some(ContextState::Candidate) {
75 previous.state = Some(ContextState::Included);
76 }
77 } else {
78 entry.access_count = 1;
81 current.entries.push(entry);
82 }
83 current.total_tokens_sent = current.entries.iter().map(|item| item.sent_tokens).sum();
84 current.total_tokens_saved = current
85 .entries
86 .iter()
87 .map(|item| item.original_tokens.saturating_sub(item.sent_tokens))
88 .sum();
89 let json =
90 serde_json::to_string(¤t).map_err(|error| format!("encode ledger: {error}"))?;
91 crate::config_io::write_atomic(path, &json)
92}
93
94#[cfg(test)]
95mod tests {
96 use super::*;
97
98 #[test]
99 fn stale_readers_preserve_each_other_and_reset() {
100 let temp = tempfile::tempdir().unwrap();
101 let path = temp.path().join("context_ledger.json");
102 let mut first = ContextLedger::new();
103 first.record("/first.txt", "raw", 10, 8);
104 let mut second = ContextLedger::new();
105 second.record("/second.txt", "raw", 20, 12);
106 let a = first.read_entry("/first.txt").unwrap();
107 let b = second.read_entry("/second.txt").unwrap();
108 persist_entry_at(&path, a.clone()).unwrap();
109 persist_entry_at(&path, b.clone()).unwrap();
110 let mut saved: ContextLedger =
111 serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
112 assert_eq!(saved.entries.len(), 2);
113 assert_eq!(
114 (saved.total_tokens_sent, saved.total_tokens_saved),
115 (20, 10)
116 );
117 saved.entries[0].state = Some(ContextState::Stale);
118 saved.entries[0].source_hash = Some("concurrent-source-digest".into());
119 saved.entries[0].kind = Some(crate::core::context_field::ContextKind::Shell);
120 std::fs::write(&path, serde_json::to_vec(&saved).unwrap()).unwrap();
121 persist_entry_at(&path, a).unwrap();
122 let saved: ContextLedger = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
123 assert_eq!(saved.entries[0].state, Some(ContextState::Stale));
124 assert_eq!(
125 saved.entries[0].source_hash.as_deref(),
126 Some("concurrent-source-digest")
127 );
128 assert_eq!(
129 saved.entries[0].kind,
130 Some(crate::core::context_field::ContextKind::Shell)
131 );
132 assert_eq!(saved.entries[0].access_count, 2);
133 std::fs::write(&path, serde_json::to_vec(&ContextLedger::new()).unwrap()).unwrap();
134 persist_entry_at(&path, b).unwrap();
135 let saved: ContextLedger = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
136 assert_eq!(saved.entries.len(), 1);
137 assert_eq!(saved.entries[0].path, "/second.txt");
138 assert_eq!((saved.total_tokens_sent, saved.total_tokens_saved), (12, 8));
139 }
140
141 #[test]
142 fn corrupt_or_locked_ledger_is_not_replaced() {
143 let temp = tempfile::tempdir().unwrap();
144 let path = temp.path().join("context_ledger.json");
145 let mut ledger = ContextLedger::new();
146 ledger.record("/read.txt", "raw", 10, 8);
147 let entry = ledger.read_entry("/read.txt").unwrap();
148 std::fs::write(&path, b"corrupt").unwrap();
149 assert!(persist_entry_at(&path, entry.clone()).is_err());
150 assert_eq!(std::fs::read(&path).unwrap(), b"corrupt");
151 let empty = serde_json::to_vec(&ContextLedger::new()).unwrap();
152 std::fs::write(&path, &empty).unwrap();
153 let _lock =
154 crate::core::agents::FileLock::acquire(&path.with_extension("json.lock")).unwrap();
155 assert!(persist_entry_at(&path, entry).is_err());
156 assert_eq!(std::fs::read(&path).unwrap(), empty);
157 }
158}