Skip to main content

superfluid_daemon/
response_store.rs

1//! Stored responses for the OpenAI `/v1/responses` API: which session each
2//! response ran in, so `previous_response_id` can continue it.
3
4use std::io::Write;
5use std::path::{Path, PathBuf};
6use std::sync::Mutex;
7
8use serde::{Deserialize, Serialize};
9
10use crate::DaemonError;
11
12/// What a response id names. Small, so a scan of every record stays cheap.
13#[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    /// The event a continuation forks at: one past the response's last.
21    pub end_event: u64,
22    /// The response's turn ended in end-of-turn, so a turn appended after
23    /// it renders as a new turn rather than inside this one.
24    pub turn_closed: bool,
25    #[serde(default, skip_serializing_if = "Option::is_none")]
26    pub previous_response_id: Option<String>,
27    /// Digest of what the session renders first (system text, tools, template
28    /// arguments). A continuation with another head is rebuilt, not forked.
29    pub head: String,
30    /// The text-only system message that opens the conversation's first
31    /// input, which leads the rebuilt conversation when no instructions do.
32    #[serde(default, skip_serializing_if = "Option::is_none")]
33    pub root_system: Option<String>,
34    pub created_at: u64,
35    /// Deleted, but kept because a later response's history includes it.
36    #[serde(default)]
37    pub deleted: bool,
38}
39
40/// The response object as returned, and the input items it was given.
41#[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    /// Puts and deletes one at a time, so a delete's scan of what continues
50    /// a response sees every record.
51    lock: Mutex<()>,
52}
53
54pub fn responses_dir_for(sessions_dir: &Path) -> PathBuf {
55    sessions_dir.join("responses")
56}
57
58/// A fresh `resp_` id: 128 random bits, so an id is not guessed from another.
59pub 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
73/// Writes `bytes` so that a crash leaves the old file or the new one, never a
74/// torn one: write aside, flush to disk, rename over, flush the directory.
75fn 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    /// Stores a response. The body lands first: a record never names a
103    /// body that is not on disk.
104    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    /// The record, deleted or not.
114    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    /// The record of a response that has not been deleted.
122    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    /// Deletes a response; one a later response continues is only marked, and goes with its
145    /// deleted ancestors once nothing continues it.
146    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}