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    /// Runs prospero killed for passing their wall-clock deadline (#221).
57    ///
58    /// Counted from the `AgentTimedOut` event, not from a status: the kill it
59    /// causes is a real `killed` transition and is still counted there. This
60    /// says how many of those kills were policy rather than a person.
61    pub timed_out: u64,
62}
63
64/// The UTC day (`YYYY-MM-DD`) an RFC-3339 timestamp falls on, or `None` if the
65/// string is too short to carry one. Timestamps are stored as written, so this
66/// slices rather than parses — the same assumption [`Store::prune`] makes.
67pub(crate) fn day_of(ts: &str) -> Option<&str> {
68    ts.get(..10)
69}
70
71/// Fold events into per-(workspace, day) aggregates. Shared by the backends
72/// that cannot push this into SQL; the sqlite and Postgres stores compute the
73/// identical shape in the database instead.
74pub(crate) fn aggregate_usage<'a>(events: impl Iterator<Item = &'a FleetEvent>) -> Vec<UsageRow> {
75    use crate::event::EventKind;
76    use crate::model::AgentStatus;
77    use std::collections::BTreeMap;
78
79    let mut rows: BTreeMap<(String, String), UsageRow> = BTreeMap::new();
80    for e in events {
81        let Some(day) = day_of(&e.ts) else { continue };
82        // Only the two event kinds below open a row. Otherwise a workspace that
83        // merely emitted output would appear as a zero-cost, zero-outcome row.
84        let interesting = match &e.kind {
85            EventKind::AgentFinished { .. } => true,
86            EventKind::StatusChanged { to, .. } => to.is_terminal(),
87            EventKind::AgentTimedOut { .. } => true,
88            _ => false,
89        };
90        if !interesting {
91            continue;
92        }
93        let row = rows
94            .entry((e.repo.clone(), day.to_string()))
95            .or_insert_with(|| UsageRow {
96                workspace: e.repo.clone(),
97                day: day.to_string(),
98                cost_usd: 0.0,
99                turns: 0,
100                done: 0,
101                failed: 0,
102                killed: 0,
103                crashed: 0,
104                timed_out: 0,
105            });
106        match &e.kind {
107            EventKind::AgentFinished {
108                cost_usd, turns, ..
109            } => {
110                row.cost_usd += cost_usd;
111                row.turns += u64::from(*turns);
112            }
113            EventKind::StatusChanged { to, .. } => match to {
114                AgentStatus::Done => row.done += 1,
115                AgentStatus::Failed => row.failed += 1,
116                AgentStatus::Killed => row.killed += 1,
117                AgentStatus::Crashed => row.crashed += 1,
118                _ => {}
119            },
120            EventKind::AgentTimedOut { .. } => row.timed_out += 1,
121            _ => {}
122        }
123    }
124    rows.into_values().collect()
125}
126
127/// One stored event with its fleet-wide cursor (#219).
128///
129/// The cursor is the backend's durable insertion order — the only total order
130/// that exists across streams — and is what a client resuming
131/// `GET /api/fleet/stream` hands back.
132#[derive(Debug, Clone, PartialEq)]
133pub struct CursoredEvent {
134    /// Fleet-wide position of this event. Strictly increasing, stable once
135    /// written, and meaningful only to the backend that issued it.
136    pub cursor: u64,
137    /// The event itself.
138    pub event: FleetEvent,
139}
140
141/// A durable, append-only event log keyed by stream.
142#[async_trait]
143pub trait Store: Send + Sync {
144    /// Append one event to durable storage.
145    async fn append(&self, event: &FleetEvent) -> Result<()>;
146
147    /// Replay events for one stream with `seq >= from_seq`, in `seq` order.
148    async fn replay(&self, stream_key: &str, from_seq: u64) -> Result<Vec<FleetEvent>>;
149
150    /// Replay **every** stream's events after `after_cursor`, in durable
151    /// insertion order, at most `limit` of them (#219).
152    ///
153    /// Per-stream `seq` cannot order a fleet — two agents both have a `seq` 1 —
154    /// so each event carries a fleet-wide [`CursoredEvent::cursor`], the
155    /// backend's own insertion order. That cursor is what a resuming client
156    /// hands back, so it is exclusive on `after_cursor`, strictly increasing,
157    /// and stable once written. `limit` bounds the batch so a long history
158    /// resumes in chunks rather than one unbounded read.
159    async fn replay_fleet(&self, after_cursor: u64, limit: usize) -> Result<Vec<CursoredEvent>>;
160
161    /// The newest fleet cursor, or 0 when nothing is stored (#219).
162    ///
163    /// This is where a `from=now` subscriber starts: it wants what happens
164    /// next, not the history, and `replay_fleet` is exclusive on its argument.
165    async fn latest_fleet_cursor(&self) -> Result<u64>;
166
167    /// The highest `seq` ever persisted for `stream_key` (0 if none). Used to
168    /// resume that stream's sequence counter across daemon restarts.
169    async fn high_water(&self, stream_key: &str) -> Result<u64>;
170
171    /// Whether the backend can currently accept writes. A cheap, non-destructive
172    /// probe used by the readiness endpoint.
173    async fn writable(&self) -> bool;
174
175    /// Delete events with `ts < before_ts` (RFC-3339, lexically ordered).
176    /// Returns the number removed. Backs age-based retention (#4).
177    async fn prune(&self, before_ts: &str) -> Result<u64>;
178
179    /// Aggregate cost, turns, and terminal outcomes per (workspace, UTC day)
180    /// over `[since, until)` (RFC-3339, lexically ordered like [`Store::prune`]).
181    ///
182    /// Deliberately **not** a default method: a backend that silently returned
183    /// nothing here would make the usage endpoint report zero spend rather than
184    /// fail, which is worse than a compile error. Every backend implements it,
185    /// and `testkit::store_usage_conformance` holds them to identical semantics.
186    async fn usage(&self, since: &str, until: &str) -> Result<Vec<UsageRow>>;
187}
188
189/// Delete events older than `max_age` from `store`, returning the count
190/// removed. The daemon's age-based retention policy (#4), factored out of
191/// [`crate::fleet::FleetManager::prune_older_than`] so the k8s arm — which
192/// builds no `FleetManager` (#83) — can prune off the shared store too.
193pub async fn prune_store_older_than(
194    store: &dyn Store,
195    max_age: std::time::Duration,
196) -> Result<u64> {
197    let max = chrono::Duration::from_std(max_age).unwrap_or_else(|_| chrono::Duration::zero());
198    let before = (chrono::Utc::now() - max).to_rfc3339();
199    store.prune(&before).await
200}
201
202/// Append-only JSON-lines store. All events go to a single `events.jsonl`;
203/// replay filters by stream key. Simple and debuggable for the first stab; rotation
204/// and per-agent sharding are deferred.
205pub struct JsonlStore {
206    path: PathBuf,
207    // Serialize writes so concurrent appends don't interleave partial lines.
208    write_lock: Mutex<()>,
209}
210
211impl JsonlStore {
212    /// Open (creating parent dirs) an append-only store at `dir/events.jsonl`.
213    pub fn open(dir: &Path) -> Result<Self> {
214        std::fs::create_dir_all(dir)?;
215        Ok(Self {
216            path: dir.join("events.jsonl"),
217            write_lock: Mutex::new(()),
218        })
219    }
220
221    /// The backing file path.
222    pub fn path(&self) -> &Path {
223        &self.path
224    }
225
226    fn read_all(&self) -> Result<Vec<FleetEvent>> {
227        let file = match std::fs::File::open(&self.path) {
228            Ok(f) => f,
229            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
230            Err(e) => return Err(e.into()),
231        };
232        let reader = BufReader::new(file);
233        let mut out = Vec::new();
234        for line in reader.lines() {
235            let line = line?;
236            if line.trim().is_empty() {
237                continue;
238            }
239            // Tolerate a corrupt/torn trailing line rather than failing replay.
240            match serde_json::from_str::<FleetEvent>(&line) {
241                Ok(ev) => out.push(ev),
242                Err(_) => {
243                    tracing::warn!(target: "prospero_store", "skipping unparseable event line");
244                    continue;
245                }
246            }
247        }
248        Ok(out)
249    }
250}
251
252#[async_trait]
253impl Store for JsonlStore {
254    async fn append(&self, event: &FleetEvent) -> Result<()> {
255        let mut line = serde_json::to_string(event)?;
256        line.push('\n');
257        let _guard = self
258            .write_lock
259            .lock()
260            .map_err(|_| CoreError::Store("event store write lock poisoned".into()))?;
261        let mut file = std::fs::OpenOptions::new()
262            .create(true)
263            .append(true)
264            .open(&self.path)?;
265        file.write_all(line.as_bytes())?;
266        Ok(())
267    }
268
269    async fn replay(&self, stream_key: &str, from_seq: u64) -> Result<Vec<FleetEvent>> {
270        let mut events: Vec<FleetEvent> = self
271            .read_all()?
272            .into_iter()
273            .filter(|e| e.stream_key() == stream_key && e.seq >= from_seq)
274            .collect();
275        events.sort_by_key(|e| e.seq);
276        Ok(events)
277    }
278
279    /// The cursor is the event's 1-based line number in the log. The file is
280    /// append-only, so a written line's position never moves — which is exactly
281    /// the stability the cursor contract requires. (Pruning rewrites the file
282    /// and does renumber, but it only ever removes a prefix by age; a client
283    /// resuming from a pruned cursor asks for history that is gone either way.)
284    async fn replay_fleet(&self, after_cursor: u64, limit: usize) -> Result<Vec<CursoredEvent>> {
285        Ok(self
286            .read_all()?
287            .into_iter()
288            .enumerate()
289            .map(|(i, event)| CursoredEvent {
290                cursor: i as u64 + 1,
291                event,
292            })
293            .filter(|c| c.cursor > after_cursor)
294            .take(limit)
295            .collect())
296    }
297
298    async fn latest_fleet_cursor(&self) -> Result<u64> {
299        Ok(self.read_all()?.len() as u64)
300    }
301
302    async fn high_water(&self, stream_key: &str) -> Result<u64> {
303        Ok(self
304            .read_all()?
305            .iter()
306            .filter(|e| e.stream_key() == stream_key)
307            .map(|e| e.seq)
308            .max()
309            .unwrap_or(0))
310    }
311
312    async fn writable(&self) -> bool {
313        // Non-destructive: opening for create+append touches no existing data,
314        // and exercises the same path `append` takes.
315        std::fs::OpenOptions::new()
316            .create(true)
317            .append(true)
318            .open(&self.path)
319            .is_ok()
320    }
321
322    async fn prune(&self, before_ts: &str) -> Result<u64> {
323        let _guard = self
324            .write_lock
325            .lock()
326            .map_err(|_| CoreError::Store("event store write lock poisoned".into()))?;
327        let all = self.read_all()?;
328        let before = all.len();
329        let kept: Vec<FleetEvent> = all
330            .into_iter()
331            .filter(|e| e.ts.as_str() >= before_ts)
332            .collect();
333        let removed = (before - kept.len()) as u64;
334        if removed == 0 {
335            return Ok(0);
336        }
337        let mut body = String::new();
338        for e in &kept {
339            body.push_str(&serde_json::to_string(e)?);
340            body.push('\n');
341        }
342        std::fs::write(&self.path, body)?;
343        Ok(removed)
344    }
345
346    async fn usage(&self, since: &str, until: &str) -> Result<Vec<UsageRow>> {
347        let all = self.read_all()?;
348        Ok(aggregate_usage(all.iter().filter(|e| {
349            e.ts.as_str() >= since && e.ts.as_str() < until
350        })))
351    }
352}
353
354#[cfg(test)]
355mod tests {
356    use super::*;
357    use crate::event::{EventKind, OutputStream};
358
359    fn ev(seq: u64, agent: &str, chunk: &str) -> FleetEvent {
360        FleetEvent {
361            seq,
362            ts: "t".into(),
363            repo: "r".into(),
364            agent_id: agent.into(),
365            actor: None,
366            on_behalf_of: None,
367            kind: EventKind::Output {
368                stream: OutputStream::Stdout,
369                chunk: chunk.into(),
370            },
371        }
372    }
373
374    #[tokio::test]
375    async fn prune_store_older_than_removes_only_aged_events() {
376        let dir = tempfile::tempdir().unwrap();
377        let store = JsonlStore::open(dir.path()).unwrap();
378        // One event ~2h old, one ~now (real RFC3339-UTC so lexical == chrono).
379        let mut e_old = ev(1, "a", "old");
380        e_old.ts = (chrono::Utc::now() - chrono::Duration::hours(2)).to_rfc3339();
381        let mut e_new = ev(2, "a", "new");
382        e_new.ts = chrono::Utc::now().to_rfc3339();
383        store.append(&e_old).await.unwrap();
384        store.append(&e_new).await.unwrap();
385
386        // Prune everything older than 1h ⇒ only the 2h-old event goes.
387        let removed = prune_store_older_than(&store, std::time::Duration::from_secs(3600))
388            .await
389            .unwrap();
390        assert_eq!(removed, 1);
391        let left = store.replay("a", 0).await.unwrap();
392        assert_eq!(left.len(), 1);
393        assert_eq!(left[0].seq, 2);
394    }
395
396    #[tokio::test]
397    async fn append_and_replay_filters_by_agent_and_seq() {
398        let dir = tempfile::tempdir().unwrap();
399        let store = JsonlStore::open(dir.path()).unwrap();
400        store.append(&ev(1, "a", "one")).await.unwrap();
401        store.append(&ev(2, "b", "two")).await.unwrap();
402        store.append(&ev(3, "a", "three")).await.unwrap();
403
404        let a_events = store.replay("a", 0).await.unwrap();
405        assert_eq!(a_events.len(), 2);
406        assert_eq!(a_events[0].seq, 1);
407        assert_eq!(a_events[1].seq, 3);
408
409        let from2 = store.replay("a", 3).await.unwrap();
410        assert_eq!(from2.len(), 1);
411        assert_eq!(from2[0].seq, 3);
412    }
413
414    #[tokio::test]
415    async fn high_water_recovers_max_seq_across_reopen() {
416        let dir = tempfile::tempdir().unwrap();
417        {
418            let store = JsonlStore::open(dir.path()).unwrap();
419            store.append(&ev(5, "a", "x")).await.unwrap();
420            store.append(&ev(9, "a", "y")).await.unwrap();
421        }
422        let reopened = JsonlStore::open(dir.path()).unwrap();
423        assert_eq!(reopened.high_water("a").await.unwrap(), 9);
424    }
425
426    #[tokio::test]
427    async fn high_water_is_zero_when_empty() {
428        let dir = tempfile::tempdir().unwrap();
429        let store = JsonlStore::open(dir.path()).unwrap();
430        assert_eq!(store.high_water("a").await.unwrap(), 0);
431    }
432
433    #[cfg(unix)]
434    #[tokio::test]
435    async fn writable_reflects_store_permissions() {
436        use std::os::unix::fs::PermissionsExt;
437        let dir = tempfile::tempdir().unwrap();
438        let store = JsonlStore::open(dir.path()).unwrap();
439        // First probe creates the (empty) events file.
440        assert!(store.writable().await, "a fresh store is writable");
441
442        // Make the events file read-only so an append open fails.
443        std::fs::set_permissions(store.path(), std::fs::Permissions::from_mode(0o444)).unwrap();
444        let observed = store.writable().await;
445        // Restore perms so the tempdir can be cleaned up.
446        std::fs::set_permissions(store.path(), std::fs::Permissions::from_mode(0o644)).unwrap();
447        assert!(
448            !observed,
449            "a read-only events file must report not writable"
450        );
451    }
452
453    #[tokio::test]
454    async fn high_water_is_scoped_per_stream() {
455        let dir = tempfile::tempdir().unwrap();
456        let store = JsonlStore::open(dir.path()).unwrap();
457        store.append(&ev(1, "a", "one")).await.unwrap();
458        store.append(&ev(1, "b", "one")).await.unwrap();
459        store.append(&ev(2, "a", "two")).await.unwrap();
460        assert_eq!(store.high_water("a").await.unwrap(), 2);
461        assert_eq!(store.high_water("b").await.unwrap(), 1);
462        assert_eq!(store.high_water("missing").await.unwrap(), 0);
463    }
464
465    #[tokio::test]
466    async fn corrupt_trailing_line_is_tolerated() {
467        let dir = tempfile::tempdir().unwrap();
468        let store = JsonlStore::open(dir.path()).unwrap();
469        store.append(&ev(1, "a", "good")).await.unwrap();
470        // Simulate a torn write.
471        let mut f = std::fs::OpenOptions::new()
472            .append(true)
473            .open(store.path())
474            .unwrap();
475        f.write_all(b"{not valid json\n").unwrap();
476        drop(f);
477        let events = store.replay("a", 0).await.unwrap();
478        assert_eq!(events.len(), 1);
479        assert_eq!(store.high_water("a").await.unwrap(), 1);
480    }
481
482    #[tokio::test]
483    async fn jsonl_store_satisfies_conformance() {
484        let dir = tempfile::tempdir().unwrap();
485        let store = JsonlStore::open(dir.path()).unwrap();
486        crate::testkit::store_conformance(&store).await;
487    }
488
489    #[tokio::test]
490    async fn jsonl_store_satisfies_fleet_replay_conformance() {
491        let dir = tempfile::tempdir().unwrap();
492        let store = JsonlStore::open(dir.path()).unwrap();
493        crate::testkit::store_fleet_replay_conformance(&store).await;
494    }
495
496    #[tokio::test]
497    async fn jsonl_store_prunes_by_age() {
498        let dir = tempfile::tempdir().unwrap();
499        let store = JsonlStore::open(dir.path()).unwrap();
500        crate::testkit::store_prune_conformance(&store).await;
501    }
502
503    #[tokio::test]
504    async fn jsonl_store_aggregates_usage() {
505        let dir = tempfile::tempdir().unwrap();
506        let store = JsonlStore::open(dir.path()).unwrap();
507        crate::testkit::store_usage_conformance(&store).await;
508    }
509}