Skip to main content

prospero_core/
store.rs

1//! Durable history for fleet events.
2//!
3//! Caliban exposes only live state, so Prospero persists a normalized event log
4//! to satisfy "observe = live + history". The [`Store`] trait abstracts the
5//! backend; [`JsonlStore`] is the first-stab append-only implementation (a
6//! sqlite-backed `Store` can drop in later without touching callers).
7
8use std::io::{BufRead, BufReader, Write};
9use std::path::{Path, PathBuf};
10use std::sync::Mutex;
11
12use async_trait::async_trait;
13
14use crate::error::{CoreError, Result};
15use crate::event::FleetEvent;
16
17/// Map a sqlx error from an event `append` into a [`CoreError`]. A unique
18/// constraint violation on `(stream_key, seq)` means a concurrent writer
19/// (another replica) took this seq, surfaced as [`CoreError::SeqConflict`] so
20/// the emitter can re-seed from the durable high-water and retry instead of
21/// dropping the event. Shared by the sqlite and postgres backends. (#49)
22pub(crate) fn map_append_error(e: sqlx::Error) -> CoreError {
23    if e.as_database_error()
24        .is_some_and(|d| d.is_unique_violation())
25    {
26        CoreError::SeqConflict
27    } else {
28        CoreError::Store(format!("append: {e}"))
29    }
30}
31
32/// One aggregate row: everything one workspace did on one UTC day.
33///
34/// The store computes these; nothing replays the log to build them. Cost and
35/// turns come from `agent_finished` events, the outcome counts from terminal
36/// `status_changed` transitions — so a workspace whose agents were all killed
37/// legitimately reports outcomes with zero cost. See [`Store::usage`].
38#[derive(Debug, Clone, PartialEq)]
39pub struct UsageRow {
40    /// Workspace name (the event's `repo`).
41    pub workspace: String,
42    /// UTC day, `YYYY-MM-DD`, sliced from the RFC-3339 timestamp.
43    pub day: String,
44    /// Summed run cost in USD.
45    pub cost_usd: f64,
46    /// Summed turns.
47    pub turns: u64,
48    /// Agents that reached `done`.
49    pub done: u64,
50    /// Agents that reached `failed`.
51    pub failed: u64,
52    /// Agents that reached `killed`.
53    pub killed: u64,
54    /// Agents that reached `crashed`.
55    pub crashed: u64,
56}
57
58/// The UTC day (`YYYY-MM-DD`) an RFC-3339 timestamp falls on, or `None` if the
59/// string is too short to carry one. Timestamps are stored as written, so this
60/// slices rather than parses — the same assumption [`Store::prune`] makes.
61pub(crate) fn day_of(ts: &str) -> Option<&str> {
62    ts.get(..10)
63}
64
65/// Fold events into per-(workspace, day) aggregates. Shared by the backends
66/// that cannot push this into SQL; the sqlite and Postgres stores compute the
67/// identical shape in the database instead.
68pub(crate) fn aggregate_usage<'a>(events: impl Iterator<Item = &'a FleetEvent>) -> Vec<UsageRow> {
69    use crate::event::EventKind;
70    use crate::model::AgentStatus;
71    use std::collections::BTreeMap;
72
73    let mut rows: BTreeMap<(String, String), UsageRow> = BTreeMap::new();
74    for e in events {
75        let Some(day) = day_of(&e.ts) else { continue };
76        // Only the two event kinds below open a row. Otherwise a workspace that
77        // merely emitted output would appear as a zero-cost, zero-outcome row.
78        let interesting = match &e.kind {
79            EventKind::AgentFinished { .. } => true,
80            EventKind::StatusChanged { to, .. } => to.is_terminal(),
81            _ => false,
82        };
83        if !interesting {
84            continue;
85        }
86        let row = rows
87            .entry((e.repo.clone(), day.to_string()))
88            .or_insert_with(|| UsageRow {
89                workspace: e.repo.clone(),
90                day: day.to_string(),
91                cost_usd: 0.0,
92                turns: 0,
93                done: 0,
94                failed: 0,
95                killed: 0,
96                crashed: 0,
97            });
98        match &e.kind {
99            EventKind::AgentFinished {
100                cost_usd, turns, ..
101            } => {
102                row.cost_usd += cost_usd;
103                row.turns += u64::from(*turns);
104            }
105            EventKind::StatusChanged { to, .. } => match to {
106                AgentStatus::Done => row.done += 1,
107                AgentStatus::Failed => row.failed += 1,
108                AgentStatus::Killed => row.killed += 1,
109                AgentStatus::Crashed => row.crashed += 1,
110                _ => {}
111            },
112            _ => {}
113        }
114    }
115    rows.into_values().collect()
116}
117
118/// A durable, append-only event log keyed by stream.
119#[async_trait]
120pub trait Store: Send + Sync {
121    /// Append one event to durable storage.
122    async fn append(&self, event: &FleetEvent) -> Result<()>;
123
124    /// Replay events for one stream with `seq >= from_seq`, in `seq` order.
125    async fn replay(&self, stream_key: &str, from_seq: u64) -> Result<Vec<FleetEvent>>;
126
127    /// The highest `seq` ever persisted for `stream_key` (0 if none). Used to
128    /// resume that stream's sequence counter across daemon restarts.
129    async fn high_water(&self, stream_key: &str) -> Result<u64>;
130
131    /// Whether the backend can currently accept writes. A cheap, non-destructive
132    /// probe used by the readiness endpoint.
133    async fn writable(&self) -> bool;
134
135    /// Delete events with `ts < before_ts` (RFC-3339, lexically ordered).
136    /// Returns the number removed. Backs age-based retention (#4).
137    async fn prune(&self, before_ts: &str) -> Result<u64>;
138
139    /// Aggregate cost, turns, and terminal outcomes per (workspace, UTC day)
140    /// over `[since, until)` (RFC-3339, lexically ordered like [`Store::prune`]).
141    ///
142    /// Deliberately **not** a default method: a backend that silently returned
143    /// nothing here would make the usage endpoint report zero spend rather than
144    /// fail, which is worse than a compile error. Every backend implements it,
145    /// and `testkit::store_usage_conformance` holds them to identical semantics.
146    async fn usage(&self, since: &str, until: &str) -> Result<Vec<UsageRow>>;
147}
148
149/// Delete events older than `max_age` from `store`, returning the count
150/// removed. The daemon's age-based retention policy (#4), factored out of
151/// [`crate::fleet::FleetManager::prune_older_than`] so the k8s arm — which
152/// builds no `FleetManager` (#83) — can prune off the shared store too.
153pub async fn prune_store_older_than(
154    store: &dyn Store,
155    max_age: std::time::Duration,
156) -> Result<u64> {
157    let max = chrono::Duration::from_std(max_age).unwrap_or_else(|_| chrono::Duration::zero());
158    let before = (chrono::Utc::now() - max).to_rfc3339();
159    store.prune(&before).await
160}
161
162/// Append-only JSON-lines store. All events go to a single `events.jsonl`;
163/// replay filters by stream key. Simple and debuggable for the first stab; rotation
164/// and per-agent sharding are deferred.
165pub struct JsonlStore {
166    path: PathBuf,
167    // Serialize writes so concurrent appends don't interleave partial lines.
168    write_lock: Mutex<()>,
169}
170
171impl JsonlStore {
172    /// Open (creating parent dirs) an append-only store at `dir/events.jsonl`.
173    pub fn open(dir: &Path) -> Result<Self> {
174        std::fs::create_dir_all(dir)?;
175        Ok(Self {
176            path: dir.join("events.jsonl"),
177            write_lock: Mutex::new(()),
178        })
179    }
180
181    /// The backing file path.
182    pub fn path(&self) -> &Path {
183        &self.path
184    }
185
186    fn read_all(&self) -> Result<Vec<FleetEvent>> {
187        let file = match std::fs::File::open(&self.path) {
188            Ok(f) => f,
189            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
190            Err(e) => return Err(e.into()),
191        };
192        let reader = BufReader::new(file);
193        let mut out = Vec::new();
194        for line in reader.lines() {
195            let line = line?;
196            if line.trim().is_empty() {
197                continue;
198            }
199            // Tolerate a corrupt/torn trailing line rather than failing replay.
200            match serde_json::from_str::<FleetEvent>(&line) {
201                Ok(ev) => out.push(ev),
202                Err(_) => {
203                    tracing::warn!(target: "prospero_store", "skipping unparseable event line");
204                    continue;
205                }
206            }
207        }
208        Ok(out)
209    }
210}
211
212#[async_trait]
213impl Store for JsonlStore {
214    async fn append(&self, event: &FleetEvent) -> Result<()> {
215        let mut line = serde_json::to_string(event)?;
216        line.push('\n');
217        let _guard = self
218            .write_lock
219            .lock()
220            .map_err(|_| CoreError::Store("event store write lock poisoned".into()))?;
221        let mut file = std::fs::OpenOptions::new()
222            .create(true)
223            .append(true)
224            .open(&self.path)?;
225        file.write_all(line.as_bytes())?;
226        Ok(())
227    }
228
229    async fn replay(&self, stream_key: &str, from_seq: u64) -> Result<Vec<FleetEvent>> {
230        let mut events: Vec<FleetEvent> = self
231            .read_all()?
232            .into_iter()
233            .filter(|e| e.stream_key() == stream_key && e.seq >= from_seq)
234            .collect();
235        events.sort_by_key(|e| e.seq);
236        Ok(events)
237    }
238
239    async fn high_water(&self, stream_key: &str) -> Result<u64> {
240        Ok(self
241            .read_all()?
242            .iter()
243            .filter(|e| e.stream_key() == stream_key)
244            .map(|e| e.seq)
245            .max()
246            .unwrap_or(0))
247    }
248
249    async fn writable(&self) -> bool {
250        // Non-destructive: opening for create+append touches no existing data,
251        // and exercises the same path `append` takes.
252        std::fs::OpenOptions::new()
253            .create(true)
254            .append(true)
255            .open(&self.path)
256            .is_ok()
257    }
258
259    async fn prune(&self, before_ts: &str) -> Result<u64> {
260        let _guard = self
261            .write_lock
262            .lock()
263            .map_err(|_| CoreError::Store("event store write lock poisoned".into()))?;
264        let all = self.read_all()?;
265        let before = all.len();
266        let kept: Vec<FleetEvent> = all
267            .into_iter()
268            .filter(|e| e.ts.as_str() >= before_ts)
269            .collect();
270        let removed = (before - kept.len()) as u64;
271        if removed == 0 {
272            return Ok(0);
273        }
274        let mut body = String::new();
275        for e in &kept {
276            body.push_str(&serde_json::to_string(e)?);
277            body.push('\n');
278        }
279        std::fs::write(&self.path, body)?;
280        Ok(removed)
281    }
282
283    async fn usage(&self, since: &str, until: &str) -> Result<Vec<UsageRow>> {
284        let all = self.read_all()?;
285        Ok(aggregate_usage(all.iter().filter(|e| {
286            e.ts.as_str() >= since && e.ts.as_str() < until
287        })))
288    }
289}
290
291#[cfg(test)]
292mod tests {
293    use super::*;
294    use crate::event::{EventKind, OutputStream};
295
296    fn ev(seq: u64, agent: &str, chunk: &str) -> FleetEvent {
297        FleetEvent {
298            seq,
299            ts: "t".into(),
300            repo: "r".into(),
301            agent_id: agent.into(),
302            kind: EventKind::Output {
303                stream: OutputStream::Stdout,
304                chunk: chunk.into(),
305            },
306        }
307    }
308
309    #[tokio::test]
310    async fn prune_store_older_than_removes_only_aged_events() {
311        let dir = tempfile::tempdir().unwrap();
312        let store = JsonlStore::open(dir.path()).unwrap();
313        // One event ~2h old, one ~now (real RFC3339-UTC so lexical == chrono).
314        let mut e_old = ev(1, "a", "old");
315        e_old.ts = (chrono::Utc::now() - chrono::Duration::hours(2)).to_rfc3339();
316        let mut e_new = ev(2, "a", "new");
317        e_new.ts = chrono::Utc::now().to_rfc3339();
318        store.append(&e_old).await.unwrap();
319        store.append(&e_new).await.unwrap();
320
321        // Prune everything older than 1h ⇒ only the 2h-old event goes.
322        let removed = prune_store_older_than(&store, std::time::Duration::from_secs(3600))
323            .await
324            .unwrap();
325        assert_eq!(removed, 1);
326        let left = store.replay("a", 0).await.unwrap();
327        assert_eq!(left.len(), 1);
328        assert_eq!(left[0].seq, 2);
329    }
330
331    #[tokio::test]
332    async fn append_and_replay_filters_by_agent_and_seq() {
333        let dir = tempfile::tempdir().unwrap();
334        let store = JsonlStore::open(dir.path()).unwrap();
335        store.append(&ev(1, "a", "one")).await.unwrap();
336        store.append(&ev(2, "b", "two")).await.unwrap();
337        store.append(&ev(3, "a", "three")).await.unwrap();
338
339        let a_events = store.replay("a", 0).await.unwrap();
340        assert_eq!(a_events.len(), 2);
341        assert_eq!(a_events[0].seq, 1);
342        assert_eq!(a_events[1].seq, 3);
343
344        let from2 = store.replay("a", 3).await.unwrap();
345        assert_eq!(from2.len(), 1);
346        assert_eq!(from2[0].seq, 3);
347    }
348
349    #[tokio::test]
350    async fn high_water_recovers_max_seq_across_reopen() {
351        let dir = tempfile::tempdir().unwrap();
352        {
353            let store = JsonlStore::open(dir.path()).unwrap();
354            store.append(&ev(5, "a", "x")).await.unwrap();
355            store.append(&ev(9, "a", "y")).await.unwrap();
356        }
357        let reopened = JsonlStore::open(dir.path()).unwrap();
358        assert_eq!(reopened.high_water("a").await.unwrap(), 9);
359    }
360
361    #[tokio::test]
362    async fn high_water_is_zero_when_empty() {
363        let dir = tempfile::tempdir().unwrap();
364        let store = JsonlStore::open(dir.path()).unwrap();
365        assert_eq!(store.high_water("a").await.unwrap(), 0);
366    }
367
368    #[cfg(unix)]
369    #[tokio::test]
370    async fn writable_reflects_store_permissions() {
371        use std::os::unix::fs::PermissionsExt;
372        let dir = tempfile::tempdir().unwrap();
373        let store = JsonlStore::open(dir.path()).unwrap();
374        // First probe creates the (empty) events file.
375        assert!(store.writable().await, "a fresh store is writable");
376
377        // Make the events file read-only so an append open fails.
378        std::fs::set_permissions(store.path(), std::fs::Permissions::from_mode(0o444)).unwrap();
379        let observed = store.writable().await;
380        // Restore perms so the tempdir can be cleaned up.
381        std::fs::set_permissions(store.path(), std::fs::Permissions::from_mode(0o644)).unwrap();
382        assert!(
383            !observed,
384            "a read-only events file must report not writable"
385        );
386    }
387
388    #[tokio::test]
389    async fn high_water_is_scoped_per_stream() {
390        let dir = tempfile::tempdir().unwrap();
391        let store = JsonlStore::open(dir.path()).unwrap();
392        store.append(&ev(1, "a", "one")).await.unwrap();
393        store.append(&ev(1, "b", "one")).await.unwrap();
394        store.append(&ev(2, "a", "two")).await.unwrap();
395        assert_eq!(store.high_water("a").await.unwrap(), 2);
396        assert_eq!(store.high_water("b").await.unwrap(), 1);
397        assert_eq!(store.high_water("missing").await.unwrap(), 0);
398    }
399
400    #[tokio::test]
401    async fn corrupt_trailing_line_is_tolerated() {
402        let dir = tempfile::tempdir().unwrap();
403        let store = JsonlStore::open(dir.path()).unwrap();
404        store.append(&ev(1, "a", "good")).await.unwrap();
405        // Simulate a torn write.
406        let mut f = std::fs::OpenOptions::new()
407            .append(true)
408            .open(store.path())
409            .unwrap();
410        f.write_all(b"{not valid json\n").unwrap();
411        drop(f);
412        let events = store.replay("a", 0).await.unwrap();
413        assert_eq!(events.len(), 1);
414        assert_eq!(store.high_water("a").await.unwrap(), 1);
415    }
416
417    #[tokio::test]
418    async fn jsonl_store_satisfies_conformance() {
419        let dir = tempfile::tempdir().unwrap();
420        let store = JsonlStore::open(dir.path()).unwrap();
421        crate::testkit::store_conformance(&store).await;
422    }
423
424    #[tokio::test]
425    async fn jsonl_store_prunes_by_age() {
426        let dir = tempfile::tempdir().unwrap();
427        let store = JsonlStore::open(dir.path()).unwrap();
428        crate::testkit::store_prune_conformance(&store).await;
429    }
430
431    #[tokio::test]
432    async fn jsonl_store_aggregates_usage() {
433        let dir = tempfile::tempdir().unwrap();
434        let store = JsonlStore::open(dir.path()).unwrap();
435        crate::testkit::store_usage_conformance(&store).await;
436    }
437}