use std::{
collections::{BTreeMap, HashMap},
path::{Path, PathBuf},
sync::{
Mutex,
atomic::{AtomicU64, Ordering},
},
};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) struct Conversation {
pub(crate) context_id: Option<String>,
pub(crate) outstanding: Option<Outstanding>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) struct Outstanding {
pub(crate) task_id: String,
pub(crate) waiting_for: Waiting,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) endpoint: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum Waiting {
Agent,
Input,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) enum Remembered {
#[default]
Not,
ByAgent,
ByKey(String),
}
const KEYS_DIR: &str = "keys";
pub(super) struct Book {
by_caller: Mutex<HashMap<String, Conversations>>,
by_key: Mutex<HashMap<String, Conversations>>,
remembered_in: Option<PathBuf>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
struct Conversations {
agents: BTreeMap<String, Conversation>,
}
impl Book {
pub(super) fn new(remembered_in: Option<PathBuf>) -> Self {
Self {
by_caller: Mutex::new(HashMap::new()),
by_key: Mutex::new(HashMap::new()),
remembered_in,
}
}
pub(super) fn get(&self, caller: &str, remote: &str, remembered: &Remembered) -> Conversation {
let remembered = match remembered {
Remembered::ByKey(key) => return self.keyed(key, remote).unwrap_or_default(),
Remembered::ByAgent => true,
Remembered::Not => false,
};
let mut by_caller = self
.by_caller
.lock()
.expect("remote conversation map poisoned");
if !by_caller.contains_key(caller) {
let loaded = self
.file(caller, remembered)
.map(|file| load(&file))
.unwrap_or_default();
by_caller.insert(caller.to_string(), loaded);
}
by_caller
.get(caller)
.and_then(|own| own.agents.get(remote))
.cloned()
.unwrap_or_default()
}
pub(super) fn record(
&self,
caller: &str,
remote: &str,
conversation: Conversation,
remembered: &Remembered,
) -> std::io::Result<()> {
let remembered = match remembered {
Remembered::ByKey(key) => return self.record_keyed(key, remote, conversation),
Remembered::ByAgent => true,
Remembered::Not => false,
};
let mut by_caller = self
.by_caller
.lock()
.expect("remote conversation map poisoned");
let own = by_caller.entry(caller.to_string()).or_default();
own.agents.insert(remote.to_string(), conversation);
match self.file(caller, remembered) {
Some(file) => store(&file, own),
None => Ok(()),
}
}
pub(super) fn keyed(&self, key: &str, remote: &str) -> Option<Conversation> {
match &self.remembered_in {
Some(dir) => load(&keyed_file(dir, key)).agents.remove(remote),
None => self
.by_key
.lock()
.expect("remote conversation map poisoned")
.get(key)?
.agents
.get(remote)
.cloned(),
}
}
fn record_keyed(
&self,
key: &str,
remote: &str,
conversation: Conversation,
) -> std::io::Result<()> {
let mut by_key = self
.by_key
.lock()
.expect("remote conversation map poisoned");
match &self.remembered_in {
Some(dir) => {
let file = keyed_file(dir, key);
let mut own = load(&file);
own.agents.insert(remote.to_string(), conversation);
store(&file, &own)
}
None => {
by_key
.entry(key.to_string())
.or_default()
.agents
.insert(remote.to_string(), conversation);
Ok(())
}
}
}
fn file(&self, caller: &str, remembered: bool) -> Option<PathBuf> {
if !remembered || !is_file_safe(caller) {
return None;
}
Some(self.remembered_in.as_ref()?.join(format!("{caller}.json")))
}
}
fn is_file_safe(id: &str) -> bool {
!id.is_empty()
&& id.len() <= 128
&& id
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
}
fn keyed_file(dir: &Path, key: &str) -> PathBuf {
dir.join(KEYS_DIR)
.join(format!("{}.json", file_name_of(key)))
}
pub(crate) fn file_name_of(key: &str) -> String {
let plain = |c: char| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_';
if !key.is_empty() && key.len() <= 64 && key.chars().all(plain) {
return key.to_string();
}
let readable: String = key
.chars()
.map(|c| c.to_ascii_lowercase())
.map(|c| if plain(c) { c } else { '_' })
.take(64)
.collect();
format!("{readable}~{:016x}", fnv1a(key.as_bytes()))
}
fn fnv1a(bytes: &[u8]) -> u64 {
bytes.iter().fold(0xcbf2_9ce4_8422_2325, |hash, byte| {
(hash ^ u64::from(*byte)).wrapping_mul(0x0100_0000_01b3)
})
}
fn load(file: &Path) -> Conversations {
match std::fs::read(file) {
Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_else(|error| {
eprintln!(
"Warning: ignoring unreadable remote conversation record {}: {error}",
file.display()
);
Conversations::default()
}),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Conversations::default(),
Err(error) => {
eprintln!(
"Warning: ignoring unreadable remote conversation record {}: {error}",
file.display()
);
Conversations::default()
}
}
}
fn store(file: &Path, conversations: &Conversations) -> std::io::Result<()> {
let dir = file.parent().expect("a record file has a directory");
std::fs::create_dir_all(dir)?;
let bytes = serde_json::to_vec_pretty(conversations).map_err(std::io::Error::other)?;
static WRITES: AtomicU64 = AtomicU64::new(0);
let staged = file.with_extension(format!(
"json.{}.{}.tmp",
std::process::id(),
WRITES.fetch_add(1, Ordering::Relaxed)
));
std::fs::write(&staged, bytes)?;
std::fs::rename(&staged, file).inspect_err(|_| {
let _ = std::fs::remove_file(&staged);
})
}
pub(crate) fn forget(dir: &Path, agent_id: &str) -> Result<(), (PathBuf, std::io::Error)> {
if !is_file_safe(agent_id) {
return Ok(());
}
let path = dir.join(format!("{agent_id}.json"));
match std::fs::remove_file(&path) {
Ok(()) => Ok(()),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(source) => Err((path, source)),
}
}