use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
const TTL_SECS: u64 = 1800;
const MIN_INTERVAL_SECS: u64 = 300;
const CARRIED_WINDOW_SECS: u64 = TTL_SECS;
const CARRIED_REFRESH_SECS: u64 = 60;
const STORE_DIR: &str = "compact_directives";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CompactDirective {
pub created_unix: u64,
pub keep_recent_turns: usize,
pub summary: String,
pub boundary_fingerprint: Option<String>,
pub original_handle: Option<String>,
}
#[cfg(not(any(test, feature = "test-support")))]
fn now_unix() -> u64 {
system_now()
}
#[cfg(any(test, feature = "test-support"))]
fn now_unix() -> u64 {
system_now().saturating_add(TEST_CLOCK_AHEAD.load(std::sync::atomic::Ordering::SeqCst))
}
fn system_now() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
#[cfg(any(test, feature = "test-support"))]
static TEST_CLOCK_AHEAD: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
#[cfg(any(test, feature = "test-support"))]
pub struct TestClock;
#[cfg(any(test, feature = "test-support"))]
impl TestClock {
#[must_use]
pub fn past_min_interval() -> Self {
TEST_CLOCK_AHEAD.fetch_add(MIN_INTERVAL_SECS + 1, std::sync::atomic::Ordering::SeqCst);
Self
}
#[must_use]
pub fn past_ttl() -> Self {
TEST_CLOCK_AHEAD.fetch_add(TTL_SECS + 1, std::sync::atomic::Ordering::SeqCst);
Self
}
}
#[cfg(any(test, feature = "test-support"))]
impl Drop for TestClock {
fn drop(&mut self) {
TEST_CLOCK_AHEAD.store(0, std::sync::atomic::Ordering::SeqCst);
}
}
fn session_path(session: &str, suffix: &str) -> Option<PathBuf> {
let session = crate::core::activity::session_id(session)?;
let dir = crate::core::data_dir::lean_ctx_data_dir()
.ok()?
.join(STORE_DIR);
let key = blake3::hash(session.as_bytes()).to_hex();
Some(dir.join(format!("{}.{suffix}", &key[..32])))
}
fn directive_path(session: &str) -> Option<PathBuf> {
session_path(session, "json")
}
fn carried_path(session: &str) -> Option<PathBuf> {
session_path(session, "carried")
}
fn write_atomic(path: &std::path::Path, contents: &str) -> Result<(), String> {
use std::io::Write as _;
static WRITES: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let dir = path.parent().ok_or_else(|| "no store dir".to_string())?;
let name = path
.file_name()
.and_then(|n| n.to_str())
.ok_or_else(|| "no store file name".to_string())?;
std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
let n = WRITES.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let tmp = dir.join(format!("{name}.tmp{}.{n}", std::process::id()));
let mut file = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&tmp)
.map_err(|e| e.to_string())?;
let written = file.write_all(contents.as_bytes()).and_then(|()| {
drop(file);
crate::core::atomic_fs::replace_file(&tmp, path)
});
written.map_err(|e| {
let _ = std::fs::remove_file(&tmp);
e.to_string()
})
}
pub fn create(session: &str, keep_recent_turns: usize, summary: String) -> Result<(), String> {
if let Some(existing) = load_active(session)
&& now_unix().saturating_sub(existing.created_unix) < MIN_INTERVAL_SECS
{
return Err(format!(
"a compaction from {}s ago is still active — one compact per {}s \
(anti-obsession guard); use ctx_session action=\"restore\" first if needed",
now_unix().saturating_sub(existing.created_unix),
MIN_INTERVAL_SECS
));
}
let directive = CompactDirective {
created_unix: now_unix(),
keep_recent_turns,
summary,
boundary_fingerprint: None,
original_handle: None,
};
save(session, &directive)
}
pub fn save(session: &str, directive: &CompactDirective) -> Result<(), String> {
let mut admitted = directive.clone();
admitted.summary =
crate::core::session::admission::admit_storable_tool_text(&directive.summary)?;
write_admitted(session, &admitted)
}
fn write_admitted(session: &str, directive: &CompactDirective) -> Result<(), String> {
let path =
directive_path(session).ok_or_else(|| "no app session id or data dir".to_string())?;
let json = serde_json::to_string(directive).map_err(|e| e.to_string())?;
write_atomic(&path, &json)
}
pub fn load_active(session: &str) -> Option<CompactDirective> {
let path = directive_path(session)?;
let raw = std::fs::read_to_string(&path).ok()?;
let directive: CompactDirective = serde_json::from_str(&raw).ok()?;
if now_unix().saturating_sub(directive.created_unix) > TTL_SECS {
let _ = std::fs::remove_file(&path);
return None;
}
let original_summary = directive.summary.clone();
let Ok(admitted_summary) =
crate::core::session::admission::admit_storable_tool_text(&original_summary)
else {
let _ = std::fs::remove_file(path);
return None;
};
let mut admitted = directive;
admitted.summary = admitted_summary;
if admitted.summary != original_summary {
write_admitted(session, &admitted).ok()?;
}
Some(admitted)
}
pub fn clear(session: &str) -> bool {
directive_path(session).is_some_and(|path| std::fs::remove_file(path).is_ok())
}
pub fn note_carried(session: &str) {
let Some(path) = carried_path(session) else {
return;
};
let now = now_unix();
let fresh =
read_carried(&path).is_some_and(|seen| seen <= now && now - seen < CARRIED_REFRESH_SECS);
if !fresh {
let _ = write_atomic(&path, &now.to_string());
}
}
pub fn is_carried(session: &str) -> bool {
carried_path(session)
.and_then(|path| read_carried(&path))
.is_some_and(|seen| now_unix().saturating_sub(seen) <= CARRIED_WINDOW_SECS)
}
fn read_carried(path: &std::path::Path) -> Option<u64> {
std::fs::read_to_string(path).ok()?.trim().parse().ok()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn directive_roundtrip_rate_limit_and_clear() {
let _dir = crate::core::data_dir::isolated_data_dir();
let a = "session-a";
assert!(load_active(a).is_none());
create(a, 6, "summary one".into()).expect("first create succeeds");
let d = load_active(a).expect("directive active");
assert_eq!(d.keep_recent_turns, 6);
assert!(d.boundary_fingerprint.is_none());
let err = create(a, 4, "summary two".into()).expect_err("rate-limited");
assert!(err.contains("anti-obsession"), "{err}");
assert!(clear(a));
assert!(load_active(a).is_none());
create(a, 4, "summary three".into()).expect("create after clear");
clear(a);
}
#[test]
fn directives_belong_to_one_session_and_need_a_valid_id() {
let _dir = crate::core::data_dir::isolated_data_dir();
create("session-a", 4, "for a".into()).expect("a arms");
assert!(load_active("session-b").is_none(), "b sees nothing");
create("session-b", 4, "for b".into()).expect("b's own guard");
assert!(!clear("session-c"));
assert_eq!(load_active("session-a").unwrap().summary, "for a");
for bad in ["", " ", "bad id", "../x"] {
assert!(create(bad, 4, "x".into()).is_err(), "{bad:?}");
assert!(load_active(bad).is_none());
assert!(!clear(bad));
note_carried(bad);
assert!(!is_carried(bad));
}
}
#[test]
fn expired_directive_is_ignored_and_removed() {
let _dir = crate::core::data_dir::isolated_data_dir();
let stale = CompactDirective {
created_unix: now_unix() - TTL_SECS - 10,
keep_recent_turns: 6,
summary: "old".into(),
boundary_fingerprint: None,
original_handle: None,
};
save("session-a", &stale).unwrap();
assert!(
load_active("session-a").is_none(),
"expired directive must not apply"
);
assert!(load_active("session-a").is_none());
}
#[test]
fn concurrent_writes_of_one_session_keep_both_files_whole() {
let _dir = crate::core::data_dir::isolated_data_dir();
let session = "session-concurrent";
let directive = CompactDirective {
created_unix: now_unix(),
keep_recent_turns: 4,
summary: "concurrent summary".into(),
boundary_fingerprint: Some("0123456789abcdef".into()),
original_handle: None,
};
let json = serde_json::to_string(&directive).unwrap();
let (directive_file, carried_file) = (
directive_path(session).unwrap(),
carried_path(session).unwrap(),
);
std::thread::scope(|scope| {
for _ in 0..8 {
scope.spawn(|| {
for _ in 0..50 {
write_atomic(&directive_file, &json).expect("directive write");
write_atomic(&carried_file, &now_unix().to_string())
.expect("carried write");
save(session, &directive).expect("save");
note_carried(session);
}
});
}
});
let stored = load_active(session).expect("the directive parses");
assert_eq!(stored.summary, "concurrent summary");
assert!(is_carried(session), "the carried note parses");
let leftovers: Vec<_> = std::fs::read_dir(directive_file.parent().unwrap())
.unwrap()
.filter_map(Result::ok)
.map(|e| e.file_name())
.filter(|n| n.to_string_lossy().contains(".tmp"))
.collect();
assert!(leftovers.is_empty(), "no temp files left: {leftovers:?}");
}
#[test]
fn failed_writes_leave_no_temp_files() {
let _dir = crate::core::data_dir::isolated_data_dir();
let carried = carried_path("session-fail").unwrap();
std::fs::create_dir_all(carried.join("blocker")).unwrap();
for _ in 0..3 {
assert!(write_atomic(&carried, "1").is_err(), "the write must fail");
note_carried("session-fail");
}
let leftovers: Vec<_> = std::fs::read_dir(carried.parent().unwrap())
.unwrap()
.filter_map(Result::ok)
.map(|e| e.file_name())
.filter(|n| n.to_string_lossy().contains(".tmp"))
.collect();
assert!(leftovers.is_empty(), "no temp files left: {leftovers:?}");
assert!(!is_carried("session-fail"));
}
#[test]
fn carried_note_lasts_the_window() {
let _dir = crate::core::data_dir::isolated_data_dir();
assert!(!is_carried("session-a"));
note_carried("session-a");
assert!(is_carried("session-a"));
assert!(!is_carried("session-b"));
let _later = TestClock::past_ttl();
assert!(!is_carried("session-a"), "an old note no longer counts");
}
}