use chrono::Utc;
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};
use tracing::{info, warn};
use crate::candidate::MemoryCandidate;
use crate::claude_payload::NormalizedHookEvent;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RetryArtifact {
pub agent: String,
pub event_name: String,
pub normalized_event: NormalizedHookEvent,
pub candidates: Vec<MemoryCandidate>,
pub error: String,
pub created_at: String,
}
pub struct RetryBuffer {
buffer_path: PathBuf,
}
impl RetryBuffer {
pub fn new() -> Self {
Self {
buffer_path: Self::buffer_path(),
}
}
pub fn buffer_path() -> PathBuf {
if let Some(dir) = dirs::state_dir() {
dir.join("nexus-memory-system").join("pending-enrichment")
} else {
std::env::var("HOME")
.map(|h| {
PathBuf::from(h).join(".local/state/nexus-memory-system/pending-enrichment")
})
.unwrap_or_else(|_| PathBuf::from(".nexus-pending-enrichment"))
}
}
pub fn write_failed(
&self,
event: &NormalizedHookEvent,
candidates: &[MemoryCandidate],
error: &str,
) -> std::io::Result<PathBuf> {
fs::create_dir_all(&self.buffer_path)?;
let timestamp = Utc::now().to_rfc3339().replace(':', "-");
let uuid = uuid::Uuid::new_v4();
let filename = format!("{}_{}.json", timestamp, uuid);
let file_path = self.buffer_path.join(&filename);
let artifact = RetryArtifact {
agent: event.agent.clone(),
event_name: event.event_name.clone(),
normalized_event: event.clone(),
candidates: candidates.to_vec(),
error: error.to_string(),
created_at: Utc::now().to_rfc3339(),
};
let json = serde_json::to_string_pretty(&artifact)?;
fs::write(&file_path, json)?;
warn!(
"Buffered failed enrichment for agent={}, event={}: {}",
event.agent,
event.event_name,
file_path.display()
);
Ok(file_path)
}
pub fn list_pending(&self) -> std::io::Result<Vec<PathBuf>> {
if !self.buffer_path.exists() {
return Ok(Vec::new());
}
let entries = fs::read_dir(&self.buffer_path)?;
let mut files = Vec::new();
for entry in entries {
let entry = entry?;
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) == Some("json") {
files.push(path);
}
}
files.sort();
Ok(files)
}
pub fn read_pending(path: &Path) -> std::io::Result<RetryArtifact> {
let contents = fs::read_to_string(path)?;
let artifact: RetryArtifact = serde_json::from_str(&contents)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
Ok(artifact)
}
pub fn remove_pending(path: &Path) -> std::io::Result<()> {
fs::remove_file(path)?;
info!("Removed buffered enrichment artifact: {}", path.display());
Ok(())
}
pub fn pending_count(&self) -> std::io::Result<usize> {
Ok(self.list_pending()?.len())
}
pub fn clear_all(&self) -> std::io::Result<usize> {
let files = self.list_pending()?;
let count = files.len();
for file in files {
fs::remove_file(&file)?;
}
info!("Cleared {} buffered enrichment artifacts", count);
Ok(count)
}
}
impl Default for RetryBuffer {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn make_test_event() -> NormalizedHookEvent {
NormalizedHookEvent {
agent: "test-agent".to_string(),
event_name: "test_event".to_string(),
observed_at: Utc::now(),
session_id: Some("session-123".to_string()),
turn_id: Some("turn-456".to_string()),
cwd: Some("/home/user/project".to_string()),
tool_name: Some("test_tool".to_string()),
tool_input: None,
tool_response_text: None,
assistant_message_text: None,
user_message_text: None,
raw_payload: json!({}),
}
}
fn make_test_candidates() -> Vec<MemoryCandidate> {
vec![MemoryCandidate {
candidate_id: "test-1".to_string(),
source_event_name: "test_event".to_string(),
source_agent: "test-agent".to_string(),
signal_score: 0.8,
provisional_category: Some("preferences".to_string()),
memory_text: "Test memory".to_string(),
evidence: json!({"key": "value"}),
labels: vec!["test".to_string()],
}]
}
#[test]
fn test_buffer_path() {
let path = RetryBuffer::buffer_path();
assert!(path.ends_with("pending-enrichment"));
}
#[test]
fn test_retry_artifact_serialization() {
let event = make_test_event();
let candidates = make_test_candidates();
let artifact = RetryArtifact {
agent: event.agent.clone(),
event_name: event.event_name.clone(),
normalized_event: event,
candidates,
error: "Test error".to_string(),
created_at: Utc::now().to_rfc3339(),
};
let serialized = serde_json::to_string(&artifact).unwrap();
let deserialized: RetryArtifact = serde_json::from_str(&serialized).unwrap();
assert_eq!(deserialized.agent, "test-agent");
assert_eq!(deserialized.event_name, "test_event");
assert_eq!(deserialized.candidates.len(), 1);
assert_eq!(deserialized.error, "Test error");
}
#[test]
fn test_write_and_list_pending() {
let temp_dir = std::env::temp_dir().join("nexus-test-retry-buffer");
fs::create_dir_all(&temp_dir).unwrap();
let event = make_test_event();
let candidates = make_test_candidates();
let artifact = RetryArtifact {
agent: event.agent.clone(),
event_name: event.event_name.clone(),
normalized_event: event,
candidates,
error: "Test error".to_string(),
created_at: Utc::now().to_rfc3339(),
};
let json = serde_json::to_string_pretty(&artifact).unwrap();
assert!(json.contains("test-agent"));
assert!(json.contains("Test error"));
fs::remove_dir_all(temp_dir).ok();
}
#[test]
fn test_default() {
let buffer = RetryBuffer::default();
assert_eq!(buffer.buffer_path, RetryBuffer::buffer_path());
}
}