1use std::path::PathBuf;
21use std::time::{SystemTime, UNIX_EPOCH};
22
23use serde::{Deserialize, Serialize};
24
25const TTL_SECS: u64 = 1800;
27
28const MIN_INTERVAL_SECS: u64 = 300;
31
32const CARRIED_WINDOW_SECS: u64 = TTL_SECS;
35
36const CARRIED_REFRESH_SECS: u64 = 60;
38
39const STORE_DIR: &str = "compact_directives";
40
41#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct CompactDirective {
43 pub created_unix: u64,
44 pub keep_recent_turns: usize,
46 pub summary: String,
48 pub boundary_fingerprint: Option<String>,
52 pub original_handle: Option<String>,
55}
56
57#[cfg(not(any(test, feature = "test-support")))]
58fn now_unix() -> u64 {
59 system_now()
60}
61
62#[cfg(any(test, feature = "test-support"))]
63fn now_unix() -> u64 {
64 system_now().saturating_add(TEST_CLOCK_AHEAD.load(std::sync::atomic::Ordering::SeqCst))
65}
66
67fn system_now() -> u64 {
68 SystemTime::now()
69 .duration_since(UNIX_EPOCH)
70 .map(|d| d.as_secs())
71 .unwrap_or(0)
72}
73
74#[cfg(any(test, feature = "test-support"))]
76static TEST_CLOCK_AHEAD: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
77
78#[cfg(any(test, feature = "test-support"))]
82pub struct TestClock;
83
84#[cfg(any(test, feature = "test-support"))]
85impl TestClock {
86 #[must_use]
89 pub fn past_min_interval() -> Self {
90 TEST_CLOCK_AHEAD.fetch_add(MIN_INTERVAL_SECS + 1, std::sync::atomic::Ordering::SeqCst);
91 Self
92 }
93
94 #[must_use]
96 pub fn past_ttl() -> Self {
97 TEST_CLOCK_AHEAD.fetch_add(TTL_SECS + 1, std::sync::atomic::Ordering::SeqCst);
98 Self
99 }
100}
101
102#[cfg(any(test, feature = "test-support"))]
103impl Drop for TestClock {
104 fn drop(&mut self) {
105 TEST_CLOCK_AHEAD.store(0, std::sync::atomic::Ordering::SeqCst);
106 }
107}
108
109fn session_path(session: &str, suffix: &str) -> Option<PathBuf> {
113 let session = crate::core::activity::session_id(session)?;
114 let dir = crate::core::data_dir::lean_ctx_data_dir()
115 .ok()?
116 .join(STORE_DIR);
117 let key = blake3::hash(session.as_bytes()).to_hex();
118 Some(dir.join(format!("{}.{suffix}", &key[..32])))
119}
120
121fn directive_path(session: &str) -> Option<PathBuf> {
122 session_path(session, "json")
123}
124
125fn carried_path(session: &str) -> Option<PathBuf> {
126 session_path(session, "carried")
127}
128
129fn write_atomic(path: &std::path::Path, contents: &str) -> Result<(), String> {
140 use std::io::Write as _;
141 static WRITES: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
142 let dir = path.parent().ok_or_else(|| "no store dir".to_string())?;
143 let name = path
144 .file_name()
145 .and_then(|n| n.to_str())
146 .ok_or_else(|| "no store file name".to_string())?;
147 std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
148 let n = WRITES.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
149 let tmp = dir.join(format!("{name}.tmp{}.{n}", std::process::id()));
150 let mut file = std::fs::OpenOptions::new()
153 .write(true)
154 .create_new(true)
155 .open(&tmp)
156 .map_err(|e| e.to_string())?;
157 let written = file.write_all(contents.as_bytes()).and_then(|()| {
158 drop(file);
159 crate::core::atomic_fs::replace_file(&tmp, path)
160 });
161 written.map_err(|e| {
162 let _ = std::fs::remove_file(&tmp);
163 e.to_string()
164 })
165}
166
167pub fn create(session: &str, keep_recent_turns: usize, summary: String) -> Result<(), String> {
172 if let Some(existing) = load_active(session)
173 && now_unix().saturating_sub(existing.created_unix) < MIN_INTERVAL_SECS
174 {
175 return Err(format!(
176 "a compaction from {}s ago is still active — one compact per {}s \
177 (anti-obsession guard); use ctx_session action=\"restore\" first if needed",
178 now_unix().saturating_sub(existing.created_unix),
179 MIN_INTERVAL_SECS
180 ));
181 }
182 let directive = CompactDirective {
183 created_unix: now_unix(),
184 keep_recent_turns,
185 summary,
186 boundary_fingerprint: None,
187 original_handle: None,
188 };
189 save(session, &directive)
190}
191
192pub fn save(session: &str, directive: &CompactDirective) -> Result<(), String> {
193 let mut admitted = directive.clone();
194 admitted.summary =
195 crate::core::session::admission::admit_storable_tool_text(&directive.summary)?;
196 write_admitted(session, &admitted)
197}
198
199fn write_admitted(session: &str, directive: &CompactDirective) -> Result<(), String> {
200 let path =
201 directive_path(session).ok_or_else(|| "no app session id or data dir".to_string())?;
202 let json = serde_json::to_string(directive).map_err(|e| e.to_string())?;
203 write_atomic(&path, &json)
204}
205
206pub fn load_active(session: &str) -> Option<CompactDirective> {
208 let path = directive_path(session)?;
209 let raw = std::fs::read_to_string(&path).ok()?;
210 let directive: CompactDirective = serde_json::from_str(&raw).ok()?;
211 if now_unix().saturating_sub(directive.created_unix) > TTL_SECS {
212 let _ = std::fs::remove_file(&path);
213 return None;
214 }
215 let original_summary = directive.summary.clone();
216 let Ok(admitted_summary) =
217 crate::core::session::admission::admit_storable_tool_text(&original_summary)
218 else {
219 let _ = std::fs::remove_file(path);
220 return None;
221 };
222 let mut admitted = directive;
223 admitted.summary = admitted_summary;
224 if admitted.summary != original_summary {
225 write_admitted(session, &admitted).ok()?;
226 }
227 Some(admitted)
228}
229
230pub fn clear(session: &str) -> bool {
233 directive_path(session).is_some_and(|path| std::fs::remove_file(path).is_ok())
234}
235
236pub fn note_carried(session: &str) {
239 let Some(path) = carried_path(session) else {
240 return;
241 };
242 let now = now_unix();
243 let fresh =
244 read_carried(&path).is_some_and(|seen| seen <= now && now - seen < CARRIED_REFRESH_SECS);
245 if !fresh {
246 let _ = write_atomic(&path, &now.to_string());
247 }
248}
249
250pub fn is_carried(session: &str) -> bool {
252 carried_path(session)
253 .and_then(|path| read_carried(&path))
254 .is_some_and(|seen| now_unix().saturating_sub(seen) <= CARRIED_WINDOW_SECS)
255}
256
257fn read_carried(path: &std::path::Path) -> Option<u64> {
258 std::fs::read_to_string(path).ok()?.trim().parse().ok()
259}
260
261#[cfg(test)]
262mod tests {
263 use super::*;
264
265 #[test]
266 fn directive_roundtrip_rate_limit_and_clear() {
267 let _dir = crate::core::data_dir::isolated_data_dir();
268 let a = "session-a";
269 assert!(load_active(a).is_none());
270
271 create(a, 6, "summary one".into()).expect("first create succeeds");
272 let d = load_active(a).expect("directive active");
273 assert_eq!(d.keep_recent_turns, 6);
274 assert!(d.boundary_fingerprint.is_none());
275
276 let err = create(a, 4, "summary two".into()).expect_err("rate-limited");
278 assert!(err.contains("anti-obsession"), "{err}");
279
280 assert!(clear(a));
281 assert!(load_active(a).is_none());
282 create(a, 4, "summary three".into()).expect("create after clear");
284 clear(a);
285 }
286
287 #[test]
288 fn directives_belong_to_one_session_and_need_a_valid_id() {
289 let _dir = crate::core::data_dir::isolated_data_dir();
290 create("session-a", 4, "for a".into()).expect("a arms");
291 assert!(load_active("session-b").is_none(), "b sees nothing");
292 create("session-b", 4, "for b".into()).expect("b's own guard");
293 assert!(!clear("session-c"));
294 assert_eq!(load_active("session-a").unwrap().summary, "for a");
295 for bad in ["", " ", "bad id", "../x"] {
296 assert!(create(bad, 4, "x".into()).is_err(), "{bad:?}");
297 assert!(load_active(bad).is_none());
298 assert!(!clear(bad));
299 note_carried(bad);
300 assert!(!is_carried(bad));
301 }
302 }
303
304 #[test]
305 fn expired_directive_is_ignored_and_removed() {
306 let _dir = crate::core::data_dir::isolated_data_dir();
307 let stale = CompactDirective {
308 created_unix: now_unix() - TTL_SECS - 10,
309 keep_recent_turns: 6,
310 summary: "old".into(),
311 boundary_fingerprint: None,
312 original_handle: None,
313 };
314 save("session-a", &stale).unwrap();
315 assert!(
316 load_active("session-a").is_none(),
317 "expired directive must not apply"
318 );
319 assert!(load_active("session-a").is_none());
320 }
321
322 #[test]
325 fn concurrent_writes_of_one_session_keep_both_files_whole() {
326 let _dir = crate::core::data_dir::isolated_data_dir();
327 let session = "session-concurrent";
328 let directive = CompactDirective {
329 created_unix: now_unix(),
330 keep_recent_turns: 4,
331 summary: "concurrent summary".into(),
332 boundary_fingerprint: Some("0123456789abcdef".into()),
333 original_handle: None,
334 };
335 let json = serde_json::to_string(&directive).unwrap();
336 let (directive_file, carried_file) = (
337 directive_path(session).unwrap(),
338 carried_path(session).unwrap(),
339 );
340 std::thread::scope(|scope| {
341 for _ in 0..8 {
342 scope.spawn(|| {
343 for _ in 0..50 {
344 write_atomic(&directive_file, &json).expect("directive write");
345 write_atomic(&carried_file, &now_unix().to_string())
346 .expect("carried write");
347 save(session, &directive).expect("save");
348 note_carried(session);
349 }
350 });
351 }
352 });
353 let stored = load_active(session).expect("the directive parses");
354 assert_eq!(stored.summary, "concurrent summary");
355 assert!(is_carried(session), "the carried note parses");
356 let leftovers: Vec<_> = std::fs::read_dir(directive_file.parent().unwrap())
357 .unwrap()
358 .filter_map(Result::ok)
359 .map(|e| e.file_name())
360 .filter(|n| n.to_string_lossy().contains(".tmp"))
361 .collect();
362 assert!(leftovers.is_empty(), "no temp files left: {leftovers:?}");
363 }
364
365 #[test]
369 fn failed_writes_leave_no_temp_files() {
370 let _dir = crate::core::data_dir::isolated_data_dir();
371 let carried = carried_path("session-fail").unwrap();
372 std::fs::create_dir_all(carried.join("blocker")).unwrap();
374 for _ in 0..3 {
375 assert!(write_atomic(&carried, "1").is_err(), "the write must fail");
376 note_carried("session-fail");
377 }
378 let leftovers: Vec<_> = std::fs::read_dir(carried.parent().unwrap())
379 .unwrap()
380 .filter_map(Result::ok)
381 .map(|e| e.file_name())
382 .filter(|n| n.to_string_lossy().contains(".tmp"))
383 .collect();
384 assert!(leftovers.is_empty(), "no temp files left: {leftovers:?}");
385 assert!(!is_carried("session-fail"));
386 }
387
388 #[test]
389 fn carried_note_lasts_the_window() {
390 let _dir = crate::core::data_dir::isolated_data_dir();
391 assert!(!is_carried("session-a"));
392 note_carried("session-a");
393 assert!(is_carried("session-a"));
394 assert!(!is_carried("session-b"));
395 let _later = TestClock::past_ttl();
396 assert!(!is_carried("session-a"), "an old note no longer counts");
397 }
398}