use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use serde::{Deserialize, Serialize};
use crate::DaemonError;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ResponseRecord {
pub id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub owner: Option<String>,
pub model: String,
pub session: u64,
pub end_event: u64,
pub turn_closed: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub previous_response_id: Option<String>,
pub head: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub root_system: Option<String>,
pub created_at: u64,
#[serde(default)]
pub deleted: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ResponseBody {
pub response: serde_json::Value,
pub input_items: Vec<serde_json::Value>,
}
pub struct ResponseStore {
dir: PathBuf,
lock: Mutex<()>,
}
pub fn responses_dir_for(sessions_dir: &Path) -> PathBuf {
sessions_dir.join("responses")
}
pub fn new_response_id() -> Result<String, DaemonError> {
use std::io::Read;
let mut buf = [0u8; 16];
std::fs::File::open("/dev/urandom")
.and_then(|mut f| f.read_exact(&mut buf))
.map_err(DaemonError::Io)?;
Ok(format!("resp_{}", buf.iter().map(|b| format!("{b:02x}")).collect::<String>()))
}
fn valid_id(id: &str) -> bool {
id.strip_prefix("resp_")
.is_some_and(|h| h.len() == 32 && h.bytes().all(|b| b.is_ascii_hexdigit()))
}
fn write_durable(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
let tmp = path.with_extension("tmp");
let mut f = std::fs::File::create(&tmp)?;
f.write_all(bytes)?;
crate::wal::durable_flush(&f)?;
drop(f);
std::fs::rename(&tmp, path)?;
if let Some(dir) = path.parent() {
crate::wal::durable_flush(&std::fs::File::open(dir)?)?;
}
Ok(())
}
impl ResponseStore {
pub fn new(dir: PathBuf) -> ResponseStore {
let _ = std::fs::create_dir_all(&dir);
ResponseStore { dir, lock: Mutex::new(()) }
}
fn meta_path(&self, id: &str) -> PathBuf {
self.dir.join(format!("{id}.json"))
}
fn body_path(&self, id: &str) -> PathBuf {
self.dir.join(format!("{id}.items.json"))
}
pub fn put(&self, record: &ResponseRecord, body: &ResponseBody) -> Result<(), DaemonError> {
if !valid_id(&record.id) {
return Err(DaemonError::Protocol("malformed response id"));
}
let _one = self.lock.lock().unwrap_or_else(|e| e.into_inner());
write_durable(&self.body_path(&record.id), &encode(body)?).map_err(DaemonError::Io)?;
write_durable(&self.meta_path(&record.id), &encode(record)?).map_err(DaemonError::Io)
}
pub fn record(&self, id: &str) -> Option<ResponseRecord> {
if !valid_id(id) {
return None;
}
serde_json::from_slice(&std::fs::read(self.meta_path(id)).ok()?).ok()
}
pub fn get(&self, id: &str) -> Option<ResponseRecord> {
self.record(id).filter(|r| !r.deleted)
}
pub fn body(&self, id: &str) -> Option<ResponseBody> {
if !valid_id(id) {
return None;
}
serde_json::from_slice(&std::fs::read(self.body_path(id)).ok()?).ok()
}
fn records(&self) -> Vec<ResponseRecord> {
let Ok(rd) = std::fs::read_dir(&self.dir) else { return Vec::new() };
rd.flatten()
.filter_map(|e| {
let name = e.file_name().into_string().ok()?;
let id = name.strip_suffix(".json").filter(|id| valid_id(id))?;
self.record(id)
})
.collect()
}
pub fn delete(&self, id: &str) -> Result<bool, DaemonError> {
let _one = self.lock.lock().unwrap_or_else(|e| e.into_inner());
let Some(mut rec) = self.get(id) else { return Ok(false) };
let records = self.records();
let referenced = |id: &str| records.iter().any(|r| r.previous_response_id.as_deref() == Some(id) && r.id != id);
if referenced(id) {
rec.deleted = true;
write_durable(&self.meta_path(id), &encode(&rec)?).map_err(DaemonError::Io)?;
return Ok(true);
}
let mut gone: Vec<String> = Vec::new();
let mut next = Some(rec);
while let Some(r) = next.take() {
let _ = std::fs::remove_file(self.meta_path(&r.id));
let _ = std::fs::remove_file(self.body_path(&r.id));
gone.push(r.id.clone());
next = r
.previous_response_id
.as_deref()
.and_then(|p| self.record(p))
.filter(|p| p.deleted)
.filter(|p| !records.iter().any(|c| c.previous_response_id.as_deref() == Some(&p.id) && !gone.contains(&c.id)));
}
Ok(true)
}
}
fn encode<T: Serialize>(v: &T) -> Result<Vec<u8>, DaemonError> {
serde_json::to_vec(v).map_err(|_| DaemonError::Protocol("response record encode"))
}
#[cfg(test)]
mod tests {
use super::*;
fn store() -> ResponseStore {
let dir = std::env::temp_dir().join(format!(
"superfluid-responses-store-{}-{}",
std::process::id(),
new_response_id().unwrap()
));
ResponseStore::new(dir)
}
fn rec(id: &str, prev: Option<&str>) -> ResponseRecord {
ResponseRecord {
id: id.into(),
owner: None,
model: "m".into(),
session: 1,
end_event: 3,
turn_closed: true,
previous_response_id: prev.map(str::to_string),
head: "h".into(),
root_system: None,
created_at: 0,
deleted: false,
}
}
fn body() -> ResponseBody {
ResponseBody { response: serde_json::json!({"object": "response"}), input_items: vec![] }
}
#[test]
fn ids_are_random_and_only_well_formed_ones_are_read() {
let (a, b) = (new_response_id().unwrap(), new_response_id().unwrap());
assert_ne!(a, b);
assert!(valid_id(&a), "{a}");
for bad in ["resp_../../etc/passwd", "resp_", "file-abc", &a[..20]] {
assert!(!valid_id(bad), "{bad}");
}
}
#[test]
fn a_continued_response_is_kept_deleted_until_nothing_continues_it() {
let s = store();
let (a, b) = (new_response_id().unwrap(), new_response_id().unwrap());
s.put(&rec(&a, None), &body()).unwrap();
s.put(&rec(&b, Some(&a)), &body()).unwrap();
assert!(s.delete(&a).unwrap());
assert!(s.get(&a).is_none(), "deleted for callers");
assert!(s.record(&a).is_some_and(|r| r.deleted) && s.body(&a).is_some(), "kept for b's history");
assert!(!s.delete(&a).unwrap(), "a deleted response is not deleted twice");
assert!(s.delete(&b).unwrap());
assert!(s.record(&a).is_none() && s.record(&b).is_none(), "both gone once unreferenced");
assert!(s.body(&a).is_none());
}
}