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 pub timed_out: u64,
62}
63
64pub(crate) fn day_of(ts: &str) -> Option<&str> {
68 ts.get(..10)
69}
70
71pub(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 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#[derive(Debug, Clone, PartialEq)]
133pub struct CursoredEvent {
134 pub cursor: u64,
137 pub event: FleetEvent,
139}
140
141#[async_trait]
143pub trait Store: Send + Sync {
144 async fn append(&self, event: &FleetEvent) -> Result<()>;
146
147 async fn replay(&self, stream_key: &str, from_seq: u64) -> Result<Vec<FleetEvent>>;
149
150 async fn replay_fleet(&self, after_cursor: u64, limit: usize) -> Result<Vec<CursoredEvent>>;
160
161 async fn latest_fleet_cursor(&self) -> Result<u64>;
166
167 async fn high_water(&self, stream_key: &str) -> Result<u64>;
170
171 async fn writable(&self) -> bool;
174
175 async fn prune(&self, before_ts: &str) -> Result<u64>;
178
179 async fn usage(&self, since: &str, until: &str) -> Result<Vec<UsageRow>>;
187}
188
189pub 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
202pub struct JsonlStore {
206 path: PathBuf,
207 write_lock: Mutex<()>,
209}
210
211impl JsonlStore {
212 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 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 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 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 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 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 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 assert!(store.writable().await, "a fresh store is writable");
441
442 std::fs::set_permissions(store.path(), std::fs::Permissions::from_mode(0o444)).unwrap();
444 let observed = store.writable().await;
445 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 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}