Skip to main content

ai_crew_sync/
spool.rs

1//! The proxy's durable reference spool.
2//!
3//! `delivered` on this bus means a reference reached a process that can
4//! still find it after a restart. That claim needs something on disk, and
5//! this is it: an append-only JSON-lines file, 0600 inside the 0700 state
6//! directory, fsynced **before** the bus is told anything was delivered.
7//!
8//! The order is the whole point. Append and fsync, then confirm. A crash
9//! before the confirm costs a redelivery, which is idempotent; a confirm
10//! before the append would cost the reference itself — the bus would record
11//! a delivery that no process can prove.
12//!
13//! It holds references, never bodies: nothing private is written to a
14//! developer's disk by this file, and a body is fetched with a current
15//! access check at the moment it is read.
16
17use std::{
18    io::Write,
19    path::{Path, PathBuf},
20};
21
22use anyhow::Context;
23use serde::{Deserialize, Serialize};
24
25/// One spooled reference.
26#[derive(Clone, Debug, Deserialize, Serialize)]
27pub struct Entry {
28    pub delivery_id: String,
29    pub message_id: String,
30    pub conversation_id: String,
31    #[serde(default)]
32    pub seq: i64,
33    #[serde(default)]
34    pub from_address: String,
35    #[serde(default)]
36    pub created_at: String,
37    /// The bus has been told this one is held durably.
38    #[serde(default)]
39    pub confirmed: bool,
40    pub spooled_at: String,
41}
42
43/// How long a confirmed entry is kept before the file is compacted. Long
44/// enough to recognise a late redelivery, short enough that the file is a
45/// spool and not an archive.
46pub const KEEP_HOURS: i64 = 48;
47
48pub fn spool_path(dir: &Path, session: &str) -> PathBuf {
49    dir.join("inbox")
50        .join(format!("{}.jsonl", crate::context::binding_key(session)))
51}
52
53fn ensure_dir(path: &Path) -> anyhow::Result<()> {
54    if let Some(dir) = path.parent() {
55        std::fs::create_dir_all(dir).with_context(|| format!("creating {}", dir.display()))?;
56        #[cfg(unix)]
57        {
58            use std::os::unix::fs::PermissionsExt;
59            let _ = std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700));
60        }
61    }
62    Ok(())
63}
64
65/// Append references and **fsync**. Returns the entries actually written:
66/// one already spooled is not written twice, so a redelivery is recognised
67/// rather than duplicated.
68pub fn append(path: &Path, entries: &[Entry]) -> anyhow::Result<Vec<Entry>> {
69    ensure_dir(path)?;
70    let existing = read(path);
71    let mut fresh = Vec::new();
72    for entry in entries {
73        if existing.iter().any(|e| e.delivery_id == entry.delivery_id)
74            || fresh
75                .iter()
76                .any(|e: &Entry| e.delivery_id == entry.delivery_id)
77        {
78            continue;
79        }
80        fresh.push(entry.clone());
81    }
82    if fresh.is_empty() {
83        return Ok(fresh);
84    }
85    // A process killed mid-write leaves a line with no newline. Appending
86    // straight onto it would glue the next entry to that fragment, fsync
87    // happily, and lose both on the next read — after the bus had already
88    // been told they were held.
89    repair_tail(path)?;
90    let mut file = std::fs::OpenOptions::new()
91        .create(true)
92        .append(true)
93        .open(path)
94        .with_context(|| format!("opening {}", path.display()))?;
95    #[cfg(unix)]
96    {
97        use std::os::unix::fs::PermissionsExt;
98        let _ = file.set_permissions(std::fs::Permissions::from_mode(0o600));
99    }
100    for entry in &fresh {
101        let line = serde_json::to_string(entry)?;
102        writeln!(file, "{line}")?;
103    }
104    // The durability claim is this call. Without it, "delivered" is a hope.
105    file.sync_all().context("fsync of the inbox spool")?;
106    Ok(fresh)
107}
108
109/// Terminate an interrupted final line before anything is appended.
110fn repair_tail(path: &Path) -> anyhow::Result<()> {
111    let Ok(text) = std::fs::read(path) else {
112        return Ok(());
113    };
114    if text.is_empty() || text.ends_with(b"\n") {
115        return Ok(());
116    }
117    let mut file = std::fs::OpenOptions::new()
118        .append(true)
119        .open(path)
120        .with_context(|| format!("opening {}", path.display()))?;
121    file.write_all(b"\n")?;
122    file.sync_all()?;
123    Ok(())
124}
125
126/// Every entry the spool still holds. A malformed line is skipped rather
127/// than failing the read: a truncated write from a killed process must not
128/// make the whole spool unreadable.
129pub fn read(path: &Path) -> Vec<Entry> {
130    let Ok(text) = std::fs::read_to_string(path) else {
131        return Vec::new();
132    };
133    text.lines()
134        .filter(|l| !l.trim().is_empty())
135        .filter_map(|l| serde_json::from_str::<Entry>(l).ok())
136        .collect()
137}
138
139/// Rewrite the spool with these entries, atomically, dropping confirmed
140/// ones older than [`KEEP_HOURS`].
141pub fn rewrite(path: &Path, entries: &[Entry]) -> anyhow::Result<()> {
142    ensure_dir(path)?;
143    let cutoff = chrono::Utc::now() - chrono::Duration::hours(KEEP_HOURS);
144    let kept: Vec<String> = entries
145        .iter()
146        .filter(|e| {
147            if !e.confirmed {
148                return true;
149            }
150            match chrono::DateTime::parse_from_rfc3339(&e.spooled_at) {
151                Ok(at) => at.with_timezone(&chrono::Utc) > cutoff,
152                Err(_) => true,
153            }
154        })
155        .filter_map(|e| serde_json::to_string(e).ok())
156        .collect();
157    crate::context::write_private(path, &format!("{}\n", kept.join("\n")))
158}
159
160/// Entries the bus has not been told about. These are what a reconnect
161/// confirms before asking for anything new.
162pub fn unconfirmed(entries: &[Entry]) -> Vec<String> {
163    entries
164        .iter()
165        .filter(|e| !e.confirmed)
166        .map(|e| e.delivery_id.clone())
167        .collect()
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173
174    fn entry(id: &str) -> Entry {
175        Entry {
176            delivery_id: id.to_owned(),
177            message_id: "m".into(),
178            conversation_id: "c".into(),
179            seq: 1,
180            from_address: "dani/review".into(),
181            created_at: "2026-09-20T00:00:00Z".into(),
182            confirmed: false,
183            spooled_at: chrono::Utc::now().to_rfc3339(),
184        }
185    }
186
187    #[test]
188    fn a_redelivered_reference_is_not_spooled_twice() {
189        let dir = std::env::temp_dir().join(format!("acs-spool-{}", uuid::Uuid::new_v4()));
190        let path = dir.join("s.jsonl");
191        assert_eq!(append(&path, &[entry("a"), entry("b")]).unwrap().len(), 2);
192        assert_eq!(
193            append(&path, &[entry("b"), entry("c")]).unwrap().len(),
194            1,
195            "'b' was already held; only 'c' is new"
196        );
197        assert_eq!(read(&path).len(), 3);
198        let _ = std::fs::remove_dir_all(&dir);
199    }
200
201    #[test]
202    fn a_truncated_line_does_not_lose_the_rest() {
203        let dir = std::env::temp_dir().join(format!("acs-spool-{}", uuid::Uuid::new_v4()));
204        let path = dir.join("s.jsonl");
205        append(&path, &[entry("a")]).unwrap();
206        {
207            // No newline: a process killed mid-write leaves exactly this.
208            use std::io::Write as _;
209            let mut f = std::fs::OpenOptions::new()
210                .append(true)
211                .open(&path)
212                .unwrap();
213            write!(f, "{{\"delivery_id\": \"hal").unwrap();
214        }
215        append(&path, &[entry("b")]).unwrap();
216        let held = read(&path);
217        assert_eq!(held.len(), 2);
218        assert!(held.iter().any(|e| e.delivery_id == "b"));
219        let _ = std::fs::remove_dir_all(&dir);
220    }
221
222    #[test]
223    fn compaction_keeps_what_the_bus_has_not_been_told() {
224        let dir = std::env::temp_dir().join(format!("acs-spool-{}", uuid::Uuid::new_v4()));
225        let path = dir.join("s.jsonl");
226        let mut old = entry("old");
227        old.confirmed = true;
228        old.spooled_at =
229            (chrono::Utc::now() - chrono::Duration::hours(KEEP_HOURS + 1)).to_rfc3339();
230        let mut pending = entry("pending");
231        pending.spooled_at = old.spooled_at.clone();
232        rewrite(&path, &[old, pending]).unwrap();
233        let held = read(&path);
234        assert_eq!(held.len(), 1);
235        assert_eq!(
236            held[0].delivery_id, "pending",
237            "an unconfirmed entry is never dropped, however old"
238        );
239        let _ = std::fs::remove_dir_all(&dir);
240    }
241}