1use 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
17pub(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#[derive(Debug, Clone, PartialEq)]
39pub struct UsageRow {
40 pub workspace: String,
42 pub day: String,
44 pub cost_usd: f64,
46 pub turns: u64,
48 pub done: u64,
50 pub failed: u64,
52 pub killed: u64,
54 pub crashed: u64,
56}
57
58pub(crate) fn day_of(ts: &str) -> Option<&str> {
62 ts.get(..10)
63}
64
65pub(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 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#[async_trait]
120pub trait Store: Send + Sync {
121 async fn append(&self, event: &FleetEvent) -> Result<()>;
123
124 async fn replay(&self, stream_key: &str, from_seq: u64) -> Result<Vec<FleetEvent>>;
126
127 async fn high_water(&self, stream_key: &str) -> Result<u64>;
130
131 async fn writable(&self) -> bool;
134
135 async fn prune(&self, before_ts: &str) -> Result<u64>;
138
139 async fn usage(&self, since: &str, until: &str) -> Result<Vec<UsageRow>>;
147}
148
149pub 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
162pub struct JsonlStore {
166 path: PathBuf,
167 write_lock: Mutex<()>,
169}
170
171impl JsonlStore {
172 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 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 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 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 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 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 assert!(store.writable().await, "a fresh store is writable");
376
377 std::fs::set_permissions(store.path(), std::fs::Permissions::from_mode(0o444)).unwrap();
379 let observed = store.writable().await;
380 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 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}