superfluid_daemon/
response_store.rs1use std::io::Write;
5use std::path::{Path, PathBuf};
6use std::sync::Mutex;
7
8use serde::{Deserialize, Serialize};
9
10use crate::DaemonError;
11
12#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
14pub struct ResponseRecord {
15 pub id: String,
16 #[serde(default, skip_serializing_if = "Option::is_none")]
17 pub owner: Option<String>,
18 pub model: String,
19 pub session: u64,
20 pub end_event: u64,
22 pub turn_closed: bool,
25 #[serde(default, skip_serializing_if = "Option::is_none")]
26 pub previous_response_id: Option<String>,
27 pub head: String,
30 #[serde(default, skip_serializing_if = "Option::is_none")]
33 pub root_system: Option<String>,
34 pub created_at: u64,
35 #[serde(default)]
37 pub deleted: bool,
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct ResponseBody {
43 pub response: serde_json::Value,
44 pub input_items: Vec<serde_json::Value>,
45}
46
47pub struct ResponseStore {
48 dir: PathBuf,
49 lock: Mutex<()>,
52}
53
54pub fn responses_dir_for(sessions_dir: &Path) -> PathBuf {
55 sessions_dir.join("responses")
56}
57
58pub fn new_response_id() -> Result<String, DaemonError> {
60 use std::io::Read;
61 let mut buf = [0u8; 16];
62 std::fs::File::open("/dev/urandom")
63 .and_then(|mut f| f.read_exact(&mut buf))
64 .map_err(DaemonError::Io)?;
65 Ok(format!("resp_{}", buf.iter().map(|b| format!("{b:02x}")).collect::<String>()))
66}
67
68fn valid_id(id: &str) -> bool {
69 id.strip_prefix("resp_")
70 .is_some_and(|h| h.len() == 32 && h.bytes().all(|b| b.is_ascii_hexdigit()))
71}
72
73fn write_durable(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
76 let tmp = path.with_extension("tmp");
77 let mut f = std::fs::File::create(&tmp)?;
78 f.write_all(bytes)?;
79 crate::wal::durable_flush(&f)?;
80 drop(f);
81 std::fs::rename(&tmp, path)?;
82 if let Some(dir) = path.parent() {
83 crate::wal::durable_flush(&std::fs::File::open(dir)?)?;
84 }
85 Ok(())
86}
87
88impl ResponseStore {
89 pub fn new(dir: PathBuf) -> ResponseStore {
90 let _ = std::fs::create_dir_all(&dir);
91 ResponseStore { dir, lock: Mutex::new(()) }
92 }
93
94 fn meta_path(&self, id: &str) -> PathBuf {
95 self.dir.join(format!("{id}.json"))
96 }
97
98 fn body_path(&self, id: &str) -> PathBuf {
99 self.dir.join(format!("{id}.items.json"))
100 }
101
102 pub fn put(&self, record: &ResponseRecord, body: &ResponseBody) -> Result<(), DaemonError> {
105 if !valid_id(&record.id) {
106 return Err(DaemonError::Protocol("malformed response id"));
107 }
108 let _one = self.lock.lock().unwrap_or_else(|e| e.into_inner());
109 write_durable(&self.body_path(&record.id), &encode(body)?).map_err(DaemonError::Io)?;
110 write_durable(&self.meta_path(&record.id), &encode(record)?).map_err(DaemonError::Io)
111 }
112
113 pub fn record(&self, id: &str) -> Option<ResponseRecord> {
115 if !valid_id(id) {
116 return None;
117 }
118 serde_json::from_slice(&std::fs::read(self.meta_path(id)).ok()?).ok()
119 }
120
121 pub fn get(&self, id: &str) -> Option<ResponseRecord> {
123 self.record(id).filter(|r| !r.deleted)
124 }
125
126 pub fn body(&self, id: &str) -> Option<ResponseBody> {
127 if !valid_id(id) {
128 return None;
129 }
130 serde_json::from_slice(&std::fs::read(self.body_path(id)).ok()?).ok()
131 }
132
133 fn records(&self) -> Vec<ResponseRecord> {
134 let Ok(rd) = std::fs::read_dir(&self.dir) else { return Vec::new() };
135 rd.flatten()
136 .filter_map(|e| {
137 let name = e.file_name().into_string().ok()?;
138 let id = name.strip_suffix(".json").filter(|id| valid_id(id))?;
139 self.record(id)
140 })
141 .collect()
142 }
143
144 pub fn delete(&self, id: &str) -> Result<bool, DaemonError> {
147 let _one = self.lock.lock().unwrap_or_else(|e| e.into_inner());
148 let Some(mut rec) = self.get(id) else { return Ok(false) };
149 let records = self.records();
150 let referenced = |id: &str| records.iter().any(|r| r.previous_response_id.as_deref() == Some(id) && r.id != id);
151 if referenced(id) {
152 rec.deleted = true;
153 write_durable(&self.meta_path(id), &encode(&rec)?).map_err(DaemonError::Io)?;
154 return Ok(true);
155 }
156 let mut gone: Vec<String> = Vec::new();
157 let mut next = Some(rec);
158 while let Some(r) = next.take() {
159 let _ = std::fs::remove_file(self.meta_path(&r.id));
160 let _ = std::fs::remove_file(self.body_path(&r.id));
161 gone.push(r.id.clone());
162 next = r
163 .previous_response_id
164 .as_deref()
165 .and_then(|p| self.record(p))
166 .filter(|p| p.deleted)
167 .filter(|p| !records.iter().any(|c| c.previous_response_id.as_deref() == Some(&p.id) && !gone.contains(&c.id)));
168 }
169 Ok(true)
170 }
171}
172
173fn encode<T: Serialize>(v: &T) -> Result<Vec<u8>, DaemonError> {
174 serde_json::to_vec(v).map_err(|_| DaemonError::Protocol("response record encode"))
175}
176
177#[cfg(test)]
178mod tests {
179 use super::*;
180
181 fn store() -> ResponseStore {
182 let dir = std::env::temp_dir().join(format!(
183 "superfluid-responses-store-{}-{}",
184 std::process::id(),
185 new_response_id().unwrap()
186 ));
187 ResponseStore::new(dir)
188 }
189
190 fn rec(id: &str, prev: Option<&str>) -> ResponseRecord {
191 ResponseRecord {
192 id: id.into(),
193 owner: None,
194 model: "m".into(),
195 session: 1,
196 end_event: 3,
197 turn_closed: true,
198 previous_response_id: prev.map(str::to_string),
199 head: "h".into(),
200 root_system: None,
201 created_at: 0,
202 deleted: false,
203 }
204 }
205
206 fn body() -> ResponseBody {
207 ResponseBody { response: serde_json::json!({"object": "response"}), input_items: vec![] }
208 }
209
210 #[test]
211 fn ids_are_random_and_only_well_formed_ones_are_read() {
212 let (a, b) = (new_response_id().unwrap(), new_response_id().unwrap());
213 assert_ne!(a, b);
214 assert!(valid_id(&a), "{a}");
215 for bad in ["resp_../../etc/passwd", "resp_", "file-abc", &a[..20]] {
216 assert!(!valid_id(bad), "{bad}");
217 }
218 }
219
220 #[test]
221 fn a_continued_response_is_kept_deleted_until_nothing_continues_it() {
222 let s = store();
223 let (a, b) = (new_response_id().unwrap(), new_response_id().unwrap());
224 s.put(&rec(&a, None), &body()).unwrap();
225 s.put(&rec(&b, Some(&a)), &body()).unwrap();
226 assert!(s.delete(&a).unwrap());
227 assert!(s.get(&a).is_none(), "deleted for callers");
228 assert!(s.record(&a).is_some_and(|r| r.deleted) && s.body(&a).is_some(), "kept for b's history");
229 assert!(!s.delete(&a).unwrap(), "a deleted response is not deleted twice");
230 assert!(s.delete(&b).unwrap());
231 assert!(s.record(&a).is_none() && s.record(&b).is_none(), "both gone once unreferenced");
232 assert!(s.body(&a).is_none());
233 }
234}