use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use tokio::sync::watch;
use tracing::warn;
use uuid::Uuid;
use crate::agent::state::AgentSnapshot;
pub const HOOK_ALIVE_WINDOW: Duration = Duration::from_secs(60);
pub const MAX_HOOK_BODY_BYTES: usize = 1024;
pub const MAX_HOOK_ENTRIES: usize = 256;
#[derive(Debug, Clone)]
pub struct HookEntry {
pub snapshot: AgentSnapshot,
pub received_at: Instant,
}
struct Inner {
by_token: Mutex<HashMap<String, String>>,
entries: Mutex<HashMap<String, HookEntry>>,
doorbell: watch::Sender<u64>,
}
#[derive(Clone)]
pub struct AgentEventStore {
inner: Arc<Inner>,
}
impl AgentEventStore {
pub fn new() -> Self {
Self {
inner: Arc::new(Inner {
by_token: Mutex::new(HashMap::new()),
entries: Mutex::new(HashMap::new()),
doorbell: watch::channel(0).0,
}),
}
}
pub fn register(&self, key: &str) -> String {
let token = Uuid::new_v4().simple().to_string();
let mut by_token = self.inner.by_token.lock().unwrap();
by_token.retain(|_, k| k != key);
by_token.insert(token.clone(), key.to_string());
token
}
pub fn unregister(&self, key: &str) {
self.inner.entries.lock().unwrap().remove(key);
self.inner.by_token.lock().unwrap().retain(|_, k| k != key);
}
pub fn key_for_token(&self, token: &str) -> Option<String> {
self.inner.by_token.lock().unwrap().get(token).cloned()
}
pub fn record(&self, key: &str, snapshot: AgentSnapshot) -> bool {
let mut entries = self.inner.entries.lock().unwrap();
if entries
.get(key)
.is_some_and(|e| e.snapshot.agent_nonce.as_deref() == snapshot.agent_nonce.as_deref())
{
return false;
}
if entries.len() >= MAX_HOOK_ENTRIES
&& let Some(oldest) =
entries.iter().min_by_key(|(_, e)| e.received_at).map(|(k, _)| k.clone())
{
warn!("agent hook store full ({}), evicting oldest: {oldest}", entries.len());
entries.remove(&oldest);
}
entries.insert(key.to_string(), HookEntry { snapshot, received_at: Instant::now() });
drop(entries);
self.inner.doorbell.send_modify(|c| *c += 1);
true
}
pub fn snapshot(&self, key: &str) -> Option<HookEntry> {
self.inner.entries.lock().unwrap().get(key).cloned()
}
pub fn fresh_snapshot(&self, key: &str) -> Option<AgentSnapshot> {
self.snapshot(key)
.filter(|e| e.received_at.elapsed() < HOOK_ALIVE_WINDOW)
.map(|e| e.snapshot)
}
pub fn subscribe(&self) -> watch::Receiver<u64> {
self.inner.doorbell.subscribe()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::agent::state::{AgentKind, AgentState};
fn snap(event: &str, nonce: &str) -> AgentSnapshot {
AgentSnapshot {
agent_kind: AgentKind::Claude,
agent_state: AgentState::Running,
attention_reason: None,
agent_event: Some(event.to_string()),
agent_nonce: Some(nonce.to_string()),
}
}
#[test]
fn record_and_read_back() {
let store = AgentEventStore::new();
let token = store.register("s1");
assert_eq!(store.key_for_token(&token).as_deref(), Some("s1"));
assert!(store.record("s1", snap("PreToolUse", "1.1")));
let entry = store.snapshot("s1").expect("entry");
assert_eq!(entry.snapshot.agent_event.as_deref(), Some("PreToolUse"));
assert!(store.fresh_snapshot("s1").is_some());
}
#[test]
fn duplicate_nonce_is_dropped() {
let store = AgentEventStore::new();
store.register("s1");
assert!(store.record("s1", snap("PreToolUse", "1.1")));
assert!(!store.record("s1", snap("PostToolUse", "1.1")));
assert_eq!(
store.snapshot("s1").unwrap().snapshot.agent_event.as_deref(),
Some("PreToolUse")
);
assert!(store.record("s1", snap("PostToolUse", "1.2")));
assert_eq!(
store.snapshot("s1").unwrap().snapshot.agent_event.as_deref(),
Some("PostToolUse")
);
}
#[test]
fn unregister_clears_token_and_entry() {
let store = AgentEventStore::new();
let token = store.register("s1");
store.record("s1", snap("Stop", "2.1"));
store.unregister("s1");
assert!(store.key_for_token(&token).is_none());
assert!(store.snapshot("s1").is_none());
}
#[test]
fn capacity_bound_evicts_oldest() {
let store = AgentEventStore::new();
for i in 0..MAX_HOOK_ENTRIES {
store.register(&format!("s{i}"));
store.record(&format!("s{i}"), snap("PreToolUse", &format!("{i}.1")));
std::thread::sleep(Duration::from_micros(50));
}
assert_eq!(store.inner.entries.lock().unwrap().len(), MAX_HOOK_ENTRIES);
store.register("overflow");
store.record("overflow", snap("PreToolUse", "999.1"));
let entries = store.inner.entries.lock().unwrap();
assert_eq!(entries.len(), MAX_HOOK_ENTRIES);
assert!(entries.contains_key("overflow"));
assert!(!entries.contains_key("s0"));
}
#[test]
fn doorbell_notifies_subscribers() {
let store = AgentEventStore::new();
let rx = store.subscribe();
store.register("s1");
store.record("s1", snap("Stop", "3.1"));
rx.has_changed().expect("doorbell should have fired");
}
}