1use crate::blob::BlobStore;
10use crate::error::{Error, ResolveError, Result, StorageError};
11use crate::event::{
12 AdapterStatus, AgentKind, ChangeKind, Event, EventDetail, EventIndexRow, EventKind, EventRow,
13 FileChange, NewSession, RawEventRow, SessionRow, SessionStatus,
14};
15use crate::step::assign_steps as assign_steps_pass;
16use rusqlite::{params, Connection, OpenFlags, OptionalExtension};
17use serde::Serialize;
18use std::fs;
19use std::path::{Path, PathBuf};
20use std::sync::mpsc::{self, Receiver, Sender};
21use std::thread::JoinHandle;
22use std::time::Duration;
23
24pub struct Store {
26 conn: Connection,
27 blobs: BlobStore,
28 db_path: PathBuf,
29}
30
31pub struct EventWriter {
34 tx: Sender<WriterReq>,
35 handle: Option<JoinHandle<()>>,
36}
37
38enum WriterReq {
39 Append(Event, Sender<std::result::Result<i64, StorageError>>),
40 AppendFileChange(
41 Event,
42 FileChange,
43 Sender<std::result::Result<i64, StorageError>>,
44 ),
45 Flush(Sender<std::result::Result<(), StorageError>>),
46 Finish(Sender<std::result::Result<(), StorageError>>),
47}
48
49#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct CreatedSession {
52 pub id: String,
54 pub short_id: String,
56}
57
58#[derive(Debug, Clone, Default, PartialEq, Eq)]
62pub struct PruneStats {
63 pub orphan_files_removed: u64,
68 pub orphan_bytes_reclaimed: u64,
70 pub orphan_rows_removed: u64,
75}
76
77#[derive(Debug, Clone, PartialEq, Eq)]
82pub struct StoreStats {
83 pub sessions: u64,
85 pub events: u64,
87 pub blobs_count: u64,
89 pub blobs_uncompressed_bytes: u64,
92 pub db_bytes: u64,
94 pub wal_bytes: u64,
96 pub shm_bytes: u64,
98 pub blobs_dir_bytes: u64,
100 pub largest_sessions: Vec<LargestSession>,
103}
104
105#[derive(Debug, Clone, PartialEq, Eq)]
108pub struct LargestSession {
109 pub id: String,
111 pub short_id: String,
113 pub event_count: u64,
115}
116
117#[derive(Debug, Clone, PartialEq, Eq)]
125#[non_exhaustive]
126pub enum FindingLocation {
127 Command,
129 Summary,
131 Body,
133 Blob {
136 hash: String,
138 path: Option<String>,
140 },
141}
142
143impl std::fmt::Display for FindingLocation {
144 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
145 match self {
146 Self::Command => f.write_str("command"),
147 Self::Summary => f.write_str("summary"),
148 Self::Body => f.write_str("body"),
149 Self::Blob { hash, path } => match path {
150 Some(p) => write!(f, "file {p}"),
151 None => write!(f, "blob {}", &hash[..hash.len().min(8)]),
152 },
153 }
154 }
155}
156
157#[derive(Debug, Clone, PartialEq, Eq)]
160pub struct ScanFinding {
161 pub event_id: Option<i64>,
164 pub step: Option<i64>,
166 pub event_kind: Option<EventKind>,
168 pub location: FindingLocation,
170 pub secret: crate::redact::SecretKind,
172 pub hash8: String,
174 pub count: u64,
176}
177
178#[derive(Debug, Clone, PartialEq, Eq)]
180pub struct SecretSummary {
181 pub secret: crate::redact::SecretKind,
183 pub hash8: String,
185 pub count: u64,
187}
188
189#[derive(Debug, Clone, PartialEq, Eq)]
191pub struct RedactOutcome {
192 pub events_rewritten: u64,
194 pub blobs_rewritten: u64,
196 pub blobs_shredded: u64,
198 pub secrets: Vec<SecretSummary>,
200 pub audit_event_id: i64,
202}
203
204#[derive(Debug, Clone, Serialize)]
206pub struct SearchResult {
207 pub event_id: i64,
209 pub session_id: String,
211 pub session_short_id: String,
213 pub step: Option<i64>,
215 pub kind: EventKind,
217 pub summary: String,
219 pub snippet: String,
222 pub ts_ms: i64,
224}
225
226#[derive(Debug, Default)]
228pub struct SearchFilters {
229 pub agent_kind: Option<AgentKind>,
231 pub event_kind: Option<EventKind>,
233 pub since: Option<i64>,
235 pub path: Option<String>,
237 pub limit: u32,
239}
240
241fn extract_fts_text(body_json: Option<&serde_json::Value>) -> String {
244 let Some(body) = body_json else {
245 return String::new();
246 };
247 match body {
248 serde_json::Value::Object(obj) => {
249 if let Some(text) = obj.get("text").and_then(|v| v.as_str()) {
251 return text.to_string();
252 }
253 if let Some(name) = obj.get("name").and_then(|v| v.as_str()) {
255 let mut text = format!("tool_call: {name}");
256 if let Some(input) = obj.get("input") {
257 if let Some(input_str) = input.as_str() {
258 text.push_str(" input: ");
259 text.push_str(input_str);
260 } else if let Some(input_obj) = input.as_object() {
261 if let Some(cmd) = input_obj.get("command").and_then(|v| v.as_str()) {
262 text.push_str(" command: ");
263 text.push_str(cmd);
264 }
265 }
266 }
267 return text;
268 }
269 if let Some(content) = obj.get("content").and_then(|v| v.as_str()) {
271 return content.to_string();
272 }
273 if let Some(reason) = obj.get("reason").and_then(|v| v.as_str()) {
275 return reason.to_string();
276 }
277 if obj
279 .get("overflow")
280 .and_then(serde_json::Value::as_bool)
281 .unwrap_or(false)
282 {
283 return String::new();
284 }
285 serde_json::to_string(body).unwrap_or_default()
287 }
288 serde_json::Value::String(s) => s.clone(),
289 _ => String::new(),
290 }
291}
292
293struct ScanEventRow {
296 id: i64,
297 kind: EventKind,
298 step: Option<i64>,
299 summary: String,
300 body: Option<String>,
301 blob_hash: Option<String>,
302}
303
304struct EventRawRow {
307 session_id: String,
308 ts_ms: i64,
309 kind_str: String,
310 step: Option<i64>,
311 correlates: Option<i64>,
312 summary: String,
313 body_str: Option<String>,
314 blob_hash: Option<String>,
315}
316
317impl Store {
318 pub fn open(db_path: &Path, blobs_dir: &Path) -> Result<Self> {
322 secure_create_parent(db_path)?;
323 secure_create_dir(blobs_dir)?;
324 let conn = Connection::open_with_flags(
325 db_path,
326 OpenFlags::SQLITE_OPEN_READ_WRITE
327 | OpenFlags::SQLITE_OPEN_CREATE
328 | OpenFlags::SQLITE_OPEN_NO_MUTEX,
329 )
330 .map_err(|e| StorageError::Open {
331 path: db_path.to_path_buf(),
332 source: std::io::Error::other(e),
333 })?;
334 conn.busy_timeout(Duration::from_secs(5))?;
335 conn.execute("PRAGMA foreign_keys = ON", [])?;
339 conn.execute("PRAGMA synchronous = NORMAL", [])?;
352 run_migrations(&conn)?;
353 let store = Self {
354 conn,
355 blobs: BlobStore::new(blobs_dir.to_path_buf()),
356 db_path: db_path.to_path_buf(),
357 };
358 store.heal_steps()?;
363 Ok(store)
364 }
365
366 pub fn blobs(&self) -> &BlobStore {
369 &self.blobs
370 }
371
372 pub fn integrity_check(&self) -> Result<String> {
376 let row: String = self
377 .conn
378 .query_row("PRAGMA integrity_check", [], |r| r.get(0))
379 .map_err(StorageError::Sqlite)?;
380 Ok(row)
381 }
382
383 pub fn vacuum(&self) -> Result<()> {
390 self.conn.execute_batch("VACUUM")?;
391 Ok(())
392 }
393
394 pub fn prune_orphan_blobs(&self) -> Result<PruneStats> {
408 let mut stats = PruneStats::default();
409 for hash in self.blobs.iter_hashes()? {
411 let refcount: Option<i64> = self
412 .conn
413 .query_row(
414 "SELECT refcount FROM blobs WHERE hash = ?1",
415 params![&hash],
416 |r| r.get::<_, i64>(0),
417 )
418 .optional()?;
419 let is_orphan = refcount.map_or(true, |r| r <= 0);
420 if is_orphan {
421 let path = self.blobs.blob_path(&hash);
422 let bytes = fs::metadata(&path).map_or(0, |m| m.len());
423 if self.blobs.remove_if_unreferenced(&hash, 0)? {
424 stats.orphan_files_removed += 1;
425 stats.orphan_bytes_reclaimed += bytes;
426 }
427 if refcount.is_some() {
428 self.conn
430 .execute("DELETE FROM blobs WHERE hash = ?1", params![&hash])?;
431 stats.orphan_rows_removed += 1;
432 }
433 }
434 }
435 let dangling: Vec<String> = {
437 let mut stmt = self
438 .conn
439 .prepare("SELECT hash FROM blobs WHERE refcount > 0")?;
440 let rows = stmt.query_map([], |r| r.get::<_, String>(0))?;
441 let mut v = Vec::new();
442 for r in rows {
443 let hash = r?;
444 if !self.blobs.blob_path(&hash).exists() {
445 v.push(hash);
446 }
447 }
448 v
449 };
450 for hash in &dangling {
451 self.conn
452 .execute("DELETE FROM blobs WHERE hash = ?1", params![hash])?;
453 stats.orphan_rows_removed += 1;
454 }
455 Ok(stats)
456 }
457
458 pub fn store_stats(&self, largest: u32) -> Result<StoreStats> {
462 let sessions: i64 = self
463 .conn
464 .query_row("SELECT COUNT(*) FROM sessions", [], |r| r.get(0))?;
465 let events: i64 = self
466 .conn
467 .query_row("SELECT COUNT(*) FROM events", [], |r| r.get(0))?;
468 let blobs_count: i64 = self
469 .conn
470 .query_row("SELECT COUNT(*) FROM blobs", [], |r| r.get(0))?;
471 let blobs_uncompressed: i64 =
472 self.conn
473 .query_row("SELECT COALESCE(SUM(size), 0) FROM blobs", [], |r| r.get(0))?;
474 let mut largest_sessions = Vec::new();
475 {
476 let mut stmt = self.conn.prepare(
477 "SELECT s.id, s.short_id, COUNT(e.id) AS n
478 FROM sessions s
479 LEFT JOIN events e ON e.session_id = s.id
480 GROUP BY s.id, s.short_id
481 ORDER BY n DESC, s.started_at DESC
482 LIMIT ?1",
483 )?;
484 let rows = stmt.query_map(params![i64::from(largest)], |r| {
485 Ok(LargestSession {
486 id: r.get(0)?,
487 short_id: r.get(1)?,
488 event_count: r.get::<_, i64>(2).map(|n| u64::try_from(n).unwrap_or(0))?,
489 })
490 })?;
491 for r in rows {
492 largest_sessions.push(r?);
493 }
494 }
495 let db_bytes = file_size(&self.db_path);
497 let wal_bytes = file_size(&sidecar(&self.db_path, "-wal"));
498 let shm_bytes = file_size(&sidecar(&self.db_path, "-shm"));
499 let mut blobs_dir_bytes = 0u64;
500 for hash in self.blobs.iter_hashes()? {
501 blobs_dir_bytes += file_size(&self.blobs.blob_path(&hash));
502 }
503 Ok(StoreStats {
504 sessions: u64::try_from(sessions).unwrap_or(0),
505 events: u64::try_from(events).unwrap_or(0),
506 blobs_count: u64::try_from(blobs_count).unwrap_or(0),
507 blobs_uncompressed_bytes: u64::try_from(blobs_uncompressed).unwrap_or(0),
508 db_bytes,
509 wal_bytes,
510 shm_bytes,
511 blobs_dir_bytes,
512 largest_sessions,
513 })
514 }
515
516 pub fn create_session(&self, new: &NewSession) -> Result<CreatedSession> {
518 let id = new.id.to_string();
519 let short_id = new.short_id();
520 let command_json = serde_json::to_string(&new.command).map_err(|e| {
521 StorageError::Sqlite(rusqlite::Error::ToSqlConversionFailure(Box::new(e)))
522 })?;
523 self.conn.execute(
524 "INSERT INTO sessions
525 (id, short_id, started_at, status, agent_kind, adapter_status,
526 command, cwd, hostname, hh_version, model, git_branch, git_sha, git_dirty)
527 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)",
528 params![
529 id,
530 short_id,
531 new.started_at,
532 SessionStatus::Recording.to_string(),
533 new.agent_kind.to_string(),
534 new.adapter_status.to_string(),
535 command_json,
536 new.cwd.to_string_lossy(),
537 new.hostname,
538 new.hh_version,
539 new.model,
540 new.git_branch,
541 new.git_sha,
542 new.git_dirty.map(i64::from),
543 ],
544 )?;
545 Ok(CreatedSession { id, short_id })
546 }
547
548 pub fn finalize_session(
551 &self,
552 id: &str,
553 ended_at: i64,
554 exit_code: Option<i32>,
555 status: SessionStatus,
556 ) -> Result<()> {
557 self.conn.execute(
558 "UPDATE sessions SET ended_at = ?1, exit_code = ?2, status = ?3 WHERE id = ?4",
559 params![ended_at, exit_code, status.to_string(), id],
560 )?;
561 let _ = self.conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);");
566 Ok(())
567 }
568
569 pub fn set_session_adapter_meta(
575 &self,
576 id: &str,
577 model: Option<&str>,
578 usage_json: Option<&serde_json::Value>,
579 status: AdapterStatus,
580 ) -> Result<()> {
581 let usage: Option<String> = match usage_json {
582 Some(v) => Some(serde_json::to_string(v).map_err(|e| {
583 StorageError::Sqlite(rusqlite::Error::ToSqlConversionFailure(Box::new(e)))
584 })?),
585 None => None,
586 };
587 self.conn.execute(
588 "UPDATE sessions SET
589 model = COALESCE(?1, model),
590 usage_json = COALESCE(?2, usage_json),
591 adapter_status = ?3
592 WHERE id = ?4",
593 params![model, usage, status.to_string(), id],
594 )?;
595 Ok(())
596 }
597
598 pub fn list_sessions(&self, limit: u32) -> Result<Vec<SessionRow>> {
600 let mut stmt = self.conn.prepare(
601 "SELECT s.id, s.short_id, s.started_at, s.ended_at, s.exit_code, s.status,
602 s.agent_kind, s.adapter_status, s.command, s.cwd,
603 (SELECT COUNT(DISTINCT e.step) FROM events e
604 WHERE e.session_id = s.id AND e.step IS NOT NULL) AS step_count,
605 (SELECT COUNT(DISTINCT fc.path) FROM file_changes fc
606 JOIN events e ON e.id = fc.event_id
607 WHERE e.session_id = s.id) AS files_changed,
608 s.imported_from
609 FROM sessions s
610 ORDER BY s.started_at DESC
611 LIMIT ?1",
612 )?;
613 let rows = stmt.query_map(params![i64::from(limit)], map_session_row)?;
614 let mut out = Vec::new();
615 for row in rows {
616 out.push(row?);
617 }
618 Ok(out)
619 }
620
621 pub fn resolve_session(&self, id_or_last: &str) -> Result<String> {
625 if id_or_last == "last" {
626 return self
627 .conn
628 .query_row(
629 "SELECT id FROM sessions ORDER BY started_at DESC LIMIT 1",
630 [],
631 |r| r.get::<_, String>(0),
632 )
633 .optional()?
634 .ok_or_else(|| StorageError::from(ResolveError::Empty).into());
635 }
636 let exact: Option<String> = self
638 .conn
639 .query_row(
640 "SELECT id FROM sessions WHERE id = ?1",
641 params![id_or_last],
642 |r| r.get::<_, String>(0),
643 )
644 .optional()?;
645 if let Some(id) = exact {
646 return Ok(id);
647 }
648 let pattern = format!("{id_or_last}%");
650 let mut stmt = self.conn.prepare(
651 "SELECT id, short_id, started_at FROM sessions
652 WHERE short_id LIKE ?1
653 ORDER BY started_at DESC",
654 )?;
655 let candidates: Vec<(String, String, i64)> = stmt
656 .query_map(params![pattern], |r| {
657 Ok((
658 r.get::<_, String>(0)?,
659 r.get::<_, String>(1)?,
660 r.get::<_, i64>(2)?,
661 ))
662 })?
663 .collect::<std::result::Result<_, _>>()?;
664 match candidates.len() {
665 0 => Err(StorageError::NotFound(id_or_last.to_string()).into()),
666 1 => Ok(candidates[0].0.clone()),
667 n => {
668 use std::fmt::Write;
669 let mut lines = String::new();
670 for (_, sid, started) in &candidates {
671 let _ = writeln!(lines, " {sid} {}", format_ts_ms(*started));
673 }
674 Err(StorageError::from(ResolveError::Ambiguous {
675 prefix: id_or_last.to_string(),
676 count: n,
677 candidates: lines,
678 })
679 .into())
680 }
681 }
682 }
683
684 pub fn session_started_at(&self, id: &str) -> Result<i64> {
689 let started_at = self
690 .conn
691 .query_row(
692 "SELECT started_at FROM sessions WHERE id = ?1",
693 params![id],
694 |r| r.get::<_, i64>(0),
695 )
696 .optional()?
697 .ok_or_else(|| StorageError::NotFound(id.to_string()))?;
698 Ok(started_at)
699 }
700
701 pub fn session_short_id(&self, id: &str) -> Result<String> {
705 let short_id = self
706 .conn
707 .query_row(
708 "SELECT short_id FROM sessions WHERE id = ?1",
709 params![id],
710 |r| r.get::<_, String>(0),
711 )
712 .optional()?
713 .ok_or_else(|| StorageError::NotFound(id.to_string()))?;
714 Ok(short_id)
715 }
716
717 pub fn search(&self, query: &str, filters: &SearchFilters) -> Result<Vec<SearchResult>> {
724 if query.trim().is_empty() {
725 return Ok(Vec::new());
726 }
727 let limit = i64::from(filters.limit.clamp(1, 500));
728 let agent_kind = filters.agent_kind.as_ref().map(AgentKind::to_string);
729 let event_kind = filters.event_kind.as_ref().map(EventKind::to_string);
730
731 let sql = "SELECT e.id, e.session_id, s.short_id, e.step, e.kind, e.summary,
732 snippet(events_fts, 2, '<b>', '</b>', '...', 32) AS snippet,
733 e.ts_ms
734 FROM events_fts
735 JOIN events e ON e.id = events_fts.rowid
736 JOIN sessions s ON s.id = e.session_id
737 WHERE events_fts MATCH ?1
738 AND (?2 IS NULL OR s.agent_kind = ?2)
739 AND (?3 IS NULL OR e.kind = ?3)
740 AND (?4 IS NULL OR s.started_at >= ?4)
741 AND (?5 IS NULL OR s.cwd LIKE '%' || ?5 || '%')
742 ORDER BY s.started_at DESC, e.ts_ms ASC
743 LIMIT ?6";
744
745 let mut stmt = self.conn.prepare(sql).map_err(StorageError::Sqlite)?;
746 let rows = stmt
747 .query_map(
748 params![
749 query,
750 agent_kind,
751 event_kind,
752 filters.since,
753 filters.path,
754 limit,
755 ],
756 |r| {
757 let kind_str: String = r.get(4)?;
758 let kind = kind_str.parse().unwrap_or(EventKind::AgentMessage);
759 Ok(SearchResult {
760 event_id: r.get(0)?,
761 session_id: r.get(1)?,
762 session_short_id: r.get(2)?,
763 step: r.get(3)?,
764 kind,
765 summary: r.get(5)?,
766 snippet: r.get(6)?,
767 ts_ms: r.get(7)?,
768 })
769 },
770 )
771 .map_err(StorageError::Sqlite)?;
772
773 let mut out = Vec::new();
774 for r in rows {
775 out.push(r.map_err(StorageError::Sqlite)?);
776 }
777 Ok(out)
778 }
779
780 pub fn delete_session(&self, id: &str) -> Result<usize> {
793 let refs: Vec<(String, i64)> = {
796 let mut stmt = self.conn.prepare(
797 "SELECT e.blob_hash, COUNT(*)
798 FROM events e
799 WHERE e.session_id = ?1 AND e.blob_hash IS NOT NULL
800 GROUP BY e.blob_hash",
801 )?;
802 let rows = stmt.query_map(params![id], |r| {
803 Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?))
804 })?;
805 let mut v = Vec::new();
806 for r in rows {
807 v.push(r?);
808 }
809 v
810 };
811 let tx = self.conn.unchecked_transaction()?;
812 tx.execute(
815 "DELETE FROM events_fts WHERE rowid IN (SELECT id FROM events WHERE session_id = ?1)",
816 params![id],
817 )?;
818 let mut removed = 0usize;
822 for (hash, count) in &refs {
823 let refcount: i64 = tx.query_row(
824 "UPDATE blobs SET refcount = refcount - ?2
825 WHERE hash = ?1 RETURNING refcount",
826 params![hash, count],
827 |r| r.get::<_, i64>(0),
828 )?;
829 if refcount <= 0 {
830 tx.execute("DELETE FROM blobs WHERE hash = ?1", params![hash])?;
831 if self.blobs.remove_if_unreferenced(hash, refcount)? {
832 removed += 1;
833 }
834 }
835 }
836 tx.execute(
843 "UPDATE events SET correlates = NULL WHERE session_id = ?1",
844 params![id],
845 )?;
846 let deleted = tx.execute("DELETE FROM sessions WHERE id = ?1", params![id])?;
848 if deleted == 0 {
849 tx.rollback()?;
850 return Err(StorageError::NotFound(id.to_string()).into());
851 }
852 tx.commit()?;
853 Ok(removed)
854 }
855
856 pub fn scan_session(
865 &self,
866 id: &str,
867 detectors: &crate::redact::Detectors,
868 ) -> Result<Vec<ScanFinding>> {
869 let command_text: String = self
870 .conn
871 .query_row(
872 "SELECT command FROM sessions WHERE id = ?1",
873 params![id],
874 |r| r.get(0),
875 )
876 .optional()?
877 .ok_or_else(|| StorageError::NotFound(id.to_string()))?;
878 let mut out = Vec::new();
879 if let Ok(cmd) = serde_json::from_str::<serde_json::Value>(&command_text) {
881 push_grouped(
882 &mut out,
883 detectors.detect_json(&cmd),
884 None,
885 None,
886 None,
887 &FindingLocation::Command,
888 );
889 }
890 let rows: Vec<ScanEventRow> = {
892 let mut stmt = self.conn.prepare(
893 "SELECT id, kind, step, summary, body_json, blob_hash FROM events
894 WHERE session_id = ?1 ORDER BY ts_ms, id",
895 )?;
896 let mapped = stmt.query_map(params![id], |r| {
897 let kind_str: String = r.get(1)?;
898 Ok(ScanEventRow {
899 id: r.get(0)?,
900 kind: kind_str.parse().unwrap_or(EventKind::AgentMessage),
901 step: r.get(2)?,
902 summary: r.get(3)?,
903 body: r.get(4)?,
904 blob_hash: r.get(5)?,
905 })
906 })?;
907 let mut v = Vec::new();
908 for m in mapped {
909 v.push(m?);
910 }
911 v
912 };
913 let mut seen_blobs = std::collections::HashSet::new();
914 for row in &rows {
915 push_grouped(
916 &mut out,
917 detectors.detect(&row.summary),
918 Some(row.id),
919 row.step,
920 Some(row.kind),
921 &FindingLocation::Summary,
922 );
923 if let Some(body) = &row.body {
924 let findings = match serde_json::from_str::<serde_json::Value>(body) {
929 Ok(v) => detectors.detect_json(&v),
930 Err(_) => detectors.detect(body),
931 };
932 push_grouped(
933 &mut out,
934 findings,
935 Some(row.id),
936 row.step,
937 Some(row.kind),
938 &FindingLocation::Body,
939 );
940 }
941 if let Some(hash) = &row.blob_hash {
942 if !seen_blobs.insert(hash.clone()) {
943 continue;
944 }
945 let Ok(content) = self.blobs.get(hash) else {
948 continue;
949 };
950 let path: Option<String> = self
951 .conn
952 .query_row(
953 "SELECT path FROM file_changes WHERE event_id = ?1",
954 params![row.id],
955 |r| r.get(0),
956 )
957 .optional()?;
958 push_grouped(
959 &mut out,
960 detectors.detect_bytes(&content),
961 Some(row.id),
962 row.step,
963 Some(row.kind),
964 &FindingLocation::Blob {
965 hash: hash.clone(),
966 path,
967 },
968 );
969 }
970 }
971 Ok(out)
972 }
973
974 #[allow(clippy::too_many_lines)] pub fn redact_session(
985 &self,
986 id: &str,
987 detectors: &crate::redact::Detectors,
988 ) -> Result<RedactOutcome> {
989 let (status, started_at): (String, i64) = self
990 .conn
991 .query_row(
992 "SELECT status, started_at FROM sessions WHERE id = ?1",
993 params![id],
994 |r| Ok((r.get(0)?, r.get(1)?)),
995 )
996 .optional()?
997 .ok_or_else(|| StorageError::NotFound(id.to_string()))?;
998 if status == "recording" {
999 return Err(StorageError::StillRecording(id.to_string()).into());
1000 }
1001
1002 let mut tally: std::collections::BTreeMap<(crate::redact::SecretKind, String), u64> =
1003 std::collections::BTreeMap::new();
1004 let count_findings =
1005 |tally: &mut std::collections::BTreeMap<(crate::redact::SecretKind, String), u64>,
1006 findings: &[crate::redact::Finding]| {
1007 for f in findings {
1008 *tally.entry((f.kind.clone(), f.hash8.clone())).or_insert(0) += 1;
1009 }
1010 };
1011 let mut events_rewritten = 0u64;
1012 let mut blobs_rewritten = 0u64;
1013 let mut shred_list: Vec<String> = Vec::new();
1014
1015 let tx = self.conn.unchecked_transaction()?;
1016
1017 let command_text: String = tx.query_row(
1019 "SELECT command FROM sessions WHERE id = ?1",
1020 params![id],
1021 |r| r.get(0),
1022 )?;
1023 if let Ok(mut cmd) = serde_json::from_str::<serde_json::Value>(&command_text) {
1024 let findings = detectors.redact_json(&mut cmd);
1025 if !findings.is_empty() {
1026 count_findings(&mut tally, &findings);
1027 tx.execute(
1028 "UPDATE sessions SET command = ?1 WHERE id = ?2",
1029 params![cmd.to_string(), id],
1030 )?;
1031 }
1032 }
1033
1034 let event_rows: Vec<(i64, String, Option<String>)> = {
1036 let mut stmt =
1037 tx.prepare("SELECT id, summary, body_json FROM events WHERE session_id = ?1")?;
1038 let mapped = stmt.query_map(params![id], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))?;
1039 let mut v = Vec::new();
1040 for m in mapped {
1041 v.push(m?);
1042 }
1043 v
1044 };
1045 for (event_id, summary, body) in &event_rows {
1046 let mut touched = false;
1047 let new_summary = detectors.redact_text(summary).map(|r| {
1048 count_findings(&mut tally, &r.findings);
1049 crate::event::truncate_summary(&r.text)
1050 });
1051 let new_body: Option<String> = body.as_ref().and_then(|b| {
1052 match serde_json::from_str::<serde_json::Value>(b) {
1053 Ok(mut v) => {
1054 let findings = detectors.redact_json(&mut v);
1055 if findings.is_empty() {
1056 None
1057 } else {
1058 count_findings(&mut tally, &findings);
1059 Some(v.to_string())
1060 }
1061 }
1062 Err(_) => detectors.redact_text(b).map(|r| {
1065 count_findings(&mut tally, &r.findings);
1066 r.text
1067 }),
1068 }
1069 });
1070 if let Some(ref s) = new_summary {
1071 tx.execute(
1072 "UPDATE events SET summary = ?1 WHERE id = ?2",
1073 params![s, event_id],
1074 )?;
1075 touched = true;
1076 }
1077 if let Some(ref b) = new_body {
1078 tx.execute(
1079 "UPDATE events SET body_json = ?1 WHERE id = ?2",
1080 params![b, event_id],
1081 )?;
1082 touched = true;
1083 }
1084 if touched {
1085 events_rewritten += 1;
1086 let new_body_value: Option<serde_json::Value> = new_body
1089 .as_deref()
1090 .and_then(|s| serde_json::from_str(s).ok());
1091 let body_text = extract_fts_text(new_body_value.as_ref());
1092 let _ = tx.execute(
1093 "UPDATE events_fts SET summary = ?1, body_text = ?2 WHERE rowid = ?3",
1094 params![
1095 new_summary.as_deref().unwrap_or(summary.as_str()),
1096 body_text,
1097 event_id
1098 ],
1099 );
1100 }
1101 }
1102
1103 let blob_refs: Vec<(String, i64)> = {
1109 let mut stmt = tx.prepare(
1110 "SELECT blob_hash, COUNT(*) FROM events
1111 WHERE session_id = ?1 AND blob_hash IS NOT NULL GROUP BY blob_hash",
1112 )?;
1113 let mapped = stmt.query_map(params![id], |r| Ok((r.get(0)?, r.get(1)?)))?;
1114 let mut v = Vec::new();
1115 for m in mapped {
1116 v.push(m?);
1117 }
1118 v
1119 };
1120 for (old_hash, refs) in &blob_refs {
1121 let Ok(content) = self.blobs.get(old_hash) else {
1122 continue; };
1124 let Some(redacted) = detectors.redact_bytes(&content) else {
1125 continue; };
1127 count_findings(&mut tally, &redacted.findings);
1128 let put = self.blobs.put(&redacted.bytes)?;
1129 tx.execute(
1131 "UPDATE events SET blob_hash = ?1 WHERE session_id = ?2 AND blob_hash = ?3",
1132 params![put.hash, id, old_hash],
1133 )?;
1134 for column_update in [
1136 "UPDATE file_changes SET before_hash = ?1
1137 WHERE before_hash = ?2
1138 AND event_id IN (SELECT id FROM events WHERE session_id = ?3)",
1139 "UPDATE file_changes SET after_hash = ?1
1140 WHERE after_hash = ?2
1141 AND event_id IN (SELECT id FROM events WHERE session_id = ?3)",
1142 ] {
1143 tx.execute(column_update, params![put.hash, old_hash, id])?;
1144 }
1145 let envelope_rows: Vec<(i64, String)> = {
1147 let mut stmt = tx.prepare(
1148 "SELECT id, body_json FROM events
1149 WHERE session_id = ?1 AND blob_hash = ?2 AND body_json IS NOT NULL",
1150 )?;
1151 let mapped =
1152 stmt.query_map(params![id, put.hash], |r| Ok((r.get(0)?, r.get(1)?)))?;
1153 let mut v = Vec::new();
1154 for m in mapped {
1155 v.push(m?);
1156 }
1157 v
1158 };
1159 for (event_id, body) in envelope_rows {
1160 let Ok(mut v) = serde_json::from_str::<serde_json::Value>(&body) else {
1161 continue;
1162 };
1163 let is_envelope = v
1164 .get("overflow")
1165 .and_then(serde_json::Value::as_bool)
1166 .unwrap_or(false);
1167 if !is_envelope {
1168 continue;
1169 }
1170 if let Some(obj) = v.as_object_mut() {
1171 obj.insert(
1172 "blob_hash".into(),
1173 serde_json::Value::String(put.hash.clone()),
1174 );
1175 obj.insert("size".into(), serde_json::json!(put.size));
1176 }
1177 tx.execute(
1178 "UPDATE events SET body_json = ?1 WHERE id = ?2",
1179 params![v.to_string(), event_id],
1180 )?;
1181 }
1182 tx.execute(
1184 "INSERT INTO blobs (hash, size, refcount) VALUES (?1, ?2, ?3)
1185 ON CONFLICT(hash) DO UPDATE SET refcount = refcount + ?3",
1186 params![put.hash, i64::try_from(put.size).unwrap_or(i64::MAX), refs],
1187 )?;
1188 let remaining: Option<i64> = tx
1189 .query_row(
1190 "UPDATE blobs SET refcount = refcount - ?2
1191 WHERE hash = ?1 RETURNING refcount",
1192 params![old_hash, refs],
1193 |r| r.get(0),
1194 )
1195 .optional()?;
1196 if remaining.is_some_and(|r| r <= 0) {
1197 tx.execute("DELETE FROM blobs WHERE hash = ?1", params![old_hash])?;
1198 shred_list.push(old_hash.clone());
1199 }
1200 blobs_rewritten += 1;
1201 }
1202
1203 let secrets: Vec<SecretSummary> = tally
1208 .iter()
1209 .map(|((secret, hash8), count)| SecretSummary {
1210 secret: secret.clone(),
1211 hash8: hash8.clone(),
1212 count: *count,
1213 })
1214 .collect();
1215 let total: u64 = secrets.iter().map(|s| s.count).sum();
1216 let audit_body = serde_json::json!({
1217 "redaction_audit": {
1218 "secrets": secrets
1219 .iter()
1220 .map(|s| {
1221 serde_json::json!({
1222 "type": s.secret.to_string(),
1223 "hash8": s.hash8,
1224 "count": s.count,
1225 })
1226 })
1227 .collect::<Vec<_>>(),
1228 "events_rewritten": events_rewritten,
1229 "blobs_rewritten": blobs_rewritten,
1230 }
1231 });
1232 let ts_ms = (unix_ms() - started_at).max(0);
1233 tx.execute(
1234 "INSERT INTO events (session_id, ts_ms, kind, step, summary, body_json, blob_hash, correlates)
1235 VALUES (?1, ?2, 'lifecycle', NULL, ?3, ?4, NULL, NULL)",
1236 params![
1237 id,
1238 ts_ms,
1239 crate::event::truncate_summary(&format!(
1240 "redaction: {total} secret occurrence(s) removed"
1241 )),
1242 audit_body.to_string(),
1243 ],
1244 )?;
1245 let audit_event_id = tx.last_insert_rowid();
1246 tx.commit()?;
1247
1248 self.assign_steps(id)?;
1253 let mut blobs_shredded = 0u64;
1254 for hash in &shred_list {
1255 if self.blobs.shred(hash)? {
1256 blobs_shredded += 1;
1257 }
1258 }
1259 self.conn
1260 .execute_batch("PRAGMA wal_checkpoint(TRUNCATE);")?;
1261 self.vacuum()?;
1262 self.conn
1263 .execute_batch("PRAGMA wal_checkpoint(TRUNCATE);")?;
1264
1265 Ok(RedactOutcome {
1266 events_rewritten,
1267 blobs_rewritten,
1268 blobs_shredded,
1269 secrets,
1270 audit_event_id,
1271 })
1272 }
1273
1274 pub fn import(&self, bundle: &crate::bundle::Bundle) -> Result<CreatedSession> {
1297 let new = bundle_new_session(bundle);
1298 let created = self.create_session(&new)?;
1299
1300 for content in bundle.blobs.values() {
1301 self.blobs.put(content)?;
1302 }
1303
1304 let tx = self.conn.unchecked_transaction()?;
1305 let mut seq_to_id: std::collections::HashMap<u64, i64> = std::collections::HashMap::new();
1306 let mut pending_correlates: Vec<(i64, u64)> = Vec::new();
1307 for ev in &bundle.events {
1308 let seq = ev
1309 .get("seq")
1310 .and_then(serde_json::Value::as_u64)
1311 .unwrap_or(0);
1312 let event = bundle_event_to_event(&created.id, ev);
1313 let new_id = insert_event(&tx, &event)?;
1314 seq_to_id.insert(seq, new_id);
1315 if let Some(correlates_seq) =
1316 ev.get("correlates_seq").and_then(serde_json::Value::as_u64)
1317 {
1318 pending_correlates.push((new_id, correlates_seq));
1319 }
1320 if let Some(fc) = bundle_event_file_change(ev) {
1321 insert_file_change_row(&tx, new_id, &fc)?;
1322 }
1323 }
1324 for (new_id, correlates_seq) in pending_correlates {
1325 if let Some(&target_id) = seq_to_id.get(&correlates_seq) {
1326 tx.execute(
1327 "UPDATE events SET correlates = ?1 WHERE id = ?2",
1328 params![target_id, new_id],
1329 )?;
1330 }
1331 }
1332 tx.commit()?;
1333
1334 let status: SessionStatus = bundle
1335 .session
1336 .get("status")
1337 .and_then(serde_json::Value::as_str)
1338 .and_then(|s| s.parse().ok())
1339 .unwrap_or(SessionStatus::Ok);
1340 let exit_code = bundle
1341 .session
1342 .get("exit_code")
1343 .and_then(serde_json::Value::as_i64)
1344 .and_then(|n| i32::try_from(n).ok());
1345 if let Some(ended_at) = bundle
1346 .session
1347 .get("ended_at")
1348 .and_then(serde_json::Value::as_i64)
1349 {
1350 self.finalize_session(&created.id, ended_at, exit_code, status)?;
1351 }
1352 let original_id = bundle
1353 .session
1354 .get("id")
1355 .and_then(serde_json::Value::as_str)
1356 .unwrap_or_default();
1357 self.conn.execute(
1358 "UPDATE sessions SET imported_from = ?1 WHERE id = ?2",
1359 params![original_id, created.id],
1360 )?;
1361
1362 Ok(created)
1363 }
1364
1365 pub fn session_stats(&self, id: &str) -> Result<(i64, i64)> {
1370 let event_count: i64 = self.conn.query_row(
1371 "SELECT COUNT(*) FROM events WHERE session_id = ?1",
1372 params![id],
1373 |r| r.get::<_, i64>(0),
1374 )?;
1375 let files_changed: i64 = self.conn.query_row(
1376 "SELECT COUNT(DISTINCT path) FROM file_changes
1377 WHERE event_id IN (SELECT id FROM events WHERE session_id = ?1)",
1378 params![id],
1379 |r| r.get::<_, i64>(0),
1380 )?;
1381 Ok((event_count, files_changed))
1382 }
1383
1384 pub fn session_step_count(&self, id: &str) -> Result<i64> {
1390 let count: i64 = self.conn.query_row(
1391 "SELECT COUNT(DISTINCT step) FROM events WHERE session_id = ?1 AND step IS NOT NULL",
1392 params![id],
1393 |r| r.get::<_, i64>(0),
1394 )?;
1395 Ok(count)
1396 }
1397
1398 pub fn list_events(&self, session_id: &str) -> Result<Vec<EventRow>> {
1402 let mut stmt = self.conn.prepare(
1403 "SELECT id, session_id, ts_ms, kind, step, correlates FROM events
1404 WHERE session_id = ?1 ORDER BY ts_ms, id",
1405 )?;
1406 let rows = stmt.query_map(params![session_id], |r| {
1407 let kind_str: String = r.get(3)?;
1408 let kind = kind_str.parse().unwrap_or(EventKind::AgentMessage);
1412 Ok(EventRow {
1413 id: r.get(0)?,
1414 session_id: r.get(1)?,
1415 ts_ms: r.get(2)?,
1416 kind,
1417 step: r.get(4)?,
1418 correlates: r.get(5)?,
1419 })
1420 })?;
1421 let mut out = Vec::new();
1422 for r in rows {
1423 out.push(r?);
1424 }
1425 Ok(out)
1426 }
1427
1428 pub fn get_session(&self, id: &str) -> Result<SessionRow> {
1432 self.conn
1433 .query_row(
1434 "SELECT s.id, s.short_id, s.started_at, s.ended_at, s.exit_code, s.status,
1435 s.agent_kind, s.adapter_status, s.command, s.cwd,
1436 (SELECT COUNT(DISTINCT e.step) FROM events e
1437 WHERE e.session_id = s.id AND e.step IS NOT NULL) AS step_count,
1438 (SELECT COUNT(DISTINCT fc.path) FROM file_changes fc
1439 JOIN events e ON e.id = fc.event_id
1440 WHERE e.session_id = s.id) AS files_changed,
1441 s.imported_from
1442 FROM sessions s WHERE s.id = ?1",
1443 params![id],
1444 map_session_row,
1445 )
1446 .optional()?
1447 .ok_or_else(|| StorageError::NotFound(id.to_string()).into())
1448 }
1449
1450 pub fn list_event_index(&self, session_id: &str) -> Result<Vec<EventIndexRow>> {
1456 let mut stmt = self.conn.prepare(
1457 "SELECT id, ts_ms, kind, step, correlates, summary FROM events
1458 WHERE session_id = ?1 ORDER BY ts_ms, id",
1459 )?;
1460 let rows = stmt.query_map(params![session_id], |r| {
1461 let kind_str: String = r.get(2)?;
1462 let kind = kind_str.parse().unwrap_or(EventKind::AgentMessage);
1463 Ok(EventIndexRow {
1464 id: r.get(0)?,
1465 ts_ms: r.get(1)?,
1466 kind,
1467 step: r.get(3)?,
1468 correlates: r.get(4)?,
1469 summary: r.get(5)?,
1470 })
1471 })?;
1472 let mut out = Vec::new();
1473 for r in rows {
1474 out.push(r?);
1475 }
1476 Ok(out)
1477 }
1478
1479 pub fn get_event_detail(&self, id: i64) -> Result<EventDetail> {
1485 let row: EventRawRow = self
1486 .conn
1487 .query_row(
1488 "SELECT session_id, ts_ms, kind, step, correlates, summary, body_json, blob_hash
1489 FROM events WHERE id = ?1",
1490 params![id],
1491 |r| {
1492 Ok(EventRawRow {
1493 session_id: r.get(0)?,
1494 ts_ms: r.get(1)?,
1495 kind_str: r.get(2)?,
1496 step: r.get(3)?,
1497 correlates: r.get(4)?,
1498 summary: r.get(5)?,
1499 body_str: r.get(6)?,
1500 blob_hash: r.get(7)?,
1501 })
1502 },
1503 )
1504 .optional()?
1505 .ok_or_else(|| StorageError::NotFound(id.to_string()))?;
1506 let kind = row.kind_str.parse().unwrap_or(EventKind::AgentMessage);
1507 let inline_body: Option<serde_json::Value> = row
1508 .body_str
1509 .as_deref()
1510 .and_then(|s| serde_json::from_str(s).ok());
1511 let body_json = self.resolve_body(inline_body, row.blob_hash.as_deref());
1512 let file_change = if kind == EventKind::FileChange {
1513 self.get_file_change(id)?
1514 } else {
1515 None
1516 };
1517 Ok(EventDetail {
1518 id,
1519 session_id: row.session_id,
1520 ts_ms: row.ts_ms,
1521 kind,
1522 step: row.step,
1523 correlates: row.correlates,
1524 summary: row.summary,
1525 body_json,
1526 file_change,
1527 })
1528 }
1529
1530 pub fn get_correlated_event(
1536 &self,
1537 event_id: i64,
1538 correlates: Option<i64>,
1539 ) -> Result<Option<EventDetail>> {
1540 if let Some(cid) = correlates {
1541 return self.get_event_detail(cid).map(Some);
1542 }
1543 let other_id: Option<i64> = self
1544 .conn
1545 .query_row(
1546 "SELECT id FROM events WHERE correlates = ?1 LIMIT 1",
1547 params![event_id],
1548 |r| r.get(0),
1549 )
1550 .optional()?;
1551 match other_id {
1552 Some(oid) => self.get_event_detail(oid).map(Some),
1553 None => Ok(None),
1554 }
1555 }
1556
1557 pub fn for_each_event_detail(
1573 &self,
1574 session_id: &str,
1575 mut emit: impl FnMut(EventDetail) -> Result<()>,
1576 ) -> Result<()> {
1577 let mut stmt = self.conn.prepare(
1578 "SELECT e.id, e.session_id, e.ts_ms, e.kind, e.step, e.correlates,
1579 e.summary, e.body_json, e.blob_hash,
1580 fc.event_id, fc.path, fc.change_kind, fc.before_hash,
1581 fc.after_hash, fc.is_binary
1582 FROM events e
1583 LEFT JOIN file_changes fc ON fc.event_id = e.id
1584 WHERE e.session_id = ?1
1585 ORDER BY e.ts_ms, e.id",
1586 )?;
1587 let mut rows = stmt.query(params![session_id])?;
1588 while let Some(row) = rows.next()? {
1589 let id: i64 = row.get(0)?;
1590 let session_id_row: String = row.get(1)?;
1591 let ts_ms: i64 = row.get(2)?;
1592 let kind_str: String = row.get(3)?;
1593 let step: Option<i64> = row.get(4)?;
1594 let correlates: Option<i64> = row.get(5)?;
1595 let summary: String = row.get(6)?;
1596 let body_str: Option<String> = row.get(7)?;
1597 let blob_hash: Option<String> = row.get(8)?;
1598 let kind = kind_str.parse().unwrap_or(EventKind::AgentMessage);
1599 let inline_body: Option<serde_json::Value> = body_str
1600 .as_deref()
1601 .and_then(|s| serde_json::from_str(s).ok());
1602 let body_json = self.resolve_body(inline_body, blob_hash.as_deref());
1603 let fc_event_id: Option<i64> = row.get(9)?;
1606 let file_change = if fc_event_id.is_some() {
1607 let change_kind_str: String = row.get(11)?;
1608 let change_kind = change_kind_str.parse().unwrap_or(ChangeKind::Modified);
1609 let is_binary: i64 = row.get(14)?;
1610 Some(FileChange {
1611 event_id: id,
1612 path: row.get(10)?,
1613 change_kind,
1614 before_hash: row.get(12)?,
1615 after_hash: row.get(13)?,
1616 is_binary: is_binary != 0,
1617 })
1618 } else {
1619 None
1620 };
1621 emit(EventDetail {
1622 id,
1623 session_id: session_id_row,
1624 ts_ms,
1625 kind,
1626 step,
1627 correlates,
1628 summary,
1629 body_json,
1630 file_change,
1631 })?;
1632 }
1633 Ok(())
1634 }
1635
1636 pub fn for_each_event_raw(
1644 &self,
1645 session_id: &str,
1646 mut emit: impl FnMut(RawEventRow) -> Result<()>,
1647 ) -> Result<()> {
1648 let mut stmt = self.conn.prepare(
1649 "SELECT e.id, e.ts_ms, e.kind, e.step, e.correlates,
1650 e.summary, e.body_json, e.blob_hash, b.size,
1651 fc.event_id, fc.path, fc.change_kind, fc.before_hash,
1652 fc.after_hash, fc.is_binary
1653 FROM events e
1654 LEFT JOIN file_changes fc ON fc.event_id = e.id
1655 LEFT JOIN blobs b ON b.hash = e.blob_hash
1656 WHERE e.session_id = ?1
1657 ORDER BY e.ts_ms, e.id",
1658 )?;
1659 let mut rows = stmt.query(params![session_id])?;
1660 while let Some(row) = rows.next()? {
1661 let id: i64 = row.get(0)?;
1662 let ts_ms: i64 = row.get(1)?;
1663 let kind_str: String = row.get(2)?;
1664 let step: Option<i64> = row.get(3)?;
1665 let correlates: Option<i64> = row.get(4)?;
1666 let summary: String = row.get(5)?;
1667 let body_str: Option<String> = row.get(6)?;
1668 let blob_hash: Option<String> = row.get(7)?;
1669 let blob_size: Option<i64> = row.get(8)?;
1670 let kind = kind_str.parse().unwrap_or(EventKind::AgentMessage);
1671 let body_json: Option<serde_json::Value> = body_str
1672 .as_deref()
1673 .and_then(|s| serde_json::from_str(s).ok());
1674 let fc_event_id: Option<i64> = row.get(9)?;
1675 let file_change = if fc_event_id.is_some() {
1676 let change_kind_str: String = row.get(11)?;
1677 let change_kind = change_kind_str.parse().unwrap_or(ChangeKind::Modified);
1678 let is_binary: i64 = row.get(14)?;
1679 Some(FileChange {
1680 event_id: id,
1681 path: row.get(10)?,
1682 change_kind,
1683 before_hash: row.get(12)?,
1684 after_hash: row.get(13)?,
1685 is_binary: is_binary != 0,
1686 })
1687 } else {
1688 None
1689 };
1690 emit(RawEventRow {
1691 id,
1692 ts_ms,
1693 kind,
1694 step,
1695 correlates,
1696 summary,
1697 body_json,
1698 blob_hash,
1699 blob_size: blob_size.map(|n| u64::try_from(n).unwrap_or(0)),
1700 file_change,
1701 })?;
1702 }
1703 Ok(())
1704 }
1705
1706 fn resolve_body(
1712 &self,
1713 inline: Option<serde_json::Value>,
1714 blob_hash: Option<&str>,
1715 ) -> Option<serde_json::Value> {
1716 let Some(hash) = blob_hash else {
1717 return inline;
1718 };
1719 let is_overflow_envelope = inline
1720 .as_ref()
1721 .and_then(|v| v.get("overflow"))
1722 .and_then(serde_json::Value::as_bool)
1723 .unwrap_or(false);
1724 if !is_overflow_envelope {
1725 return inline;
1726 }
1727 match self.blobs.get(hash) {
1728 Ok(bytes) => match serde_json::from_slice(&bytes) {
1729 Ok(v) => Some(v),
1730 Err(_) => inline,
1731 },
1732 Err(_) => inline,
1733 }
1734 }
1735
1736 fn get_file_change(&self, event_id: i64) -> Result<Option<FileChange>> {
1738 self.conn
1739 .query_row(
1740 "SELECT event_id, path, change_kind, before_hash, after_hash, is_binary
1741 FROM file_changes WHERE event_id = ?1",
1742 params![event_id],
1743 |r| {
1744 let change_kind_str: String = r.get(2)?;
1745 let change_kind = change_kind_str.parse().unwrap_or(ChangeKind::Modified);
1746 let is_binary: i64 = r.get(5)?;
1747 Ok(FileChange {
1748 event_id: r.get(0)?,
1749 path: r.get(1)?,
1750 change_kind,
1751 before_hash: r.get(3)?,
1752 after_hash: r.get(4)?,
1753 is_binary: is_binary != 0,
1754 })
1755 },
1756 )
1757 .optional()
1758 .map_err(Into::into)
1759 }
1760
1761 pub fn assign_steps(&self, session_id: &str) -> Result<()> {
1766 let mut rows = self.list_events(session_id)?;
1767 assign_steps_pass(&mut rows);
1768 let tx = self.conn.unchecked_transaction()?;
1769 for r in &rows {
1770 tx.execute(
1771 "UPDATE events SET step = ?1 WHERE id = ?2",
1772 params![r.step, r.id],
1773 )?;
1774 }
1775 tx.commit()?;
1776 Ok(())
1777 }
1778
1779 pub fn mark_stale_interrupted(&self) -> Result<usize> {
1793 let updated = self.conn.execute(
1794 "UPDATE sessions SET status = 'interrupted'
1795 WHERE status = 'recording' AND ended_at IS NULL",
1796 [],
1797 )?;
1798 Ok(updated)
1799 }
1800
1801 fn heal_steps(&self) -> Result<()> {
1807 let ids: Vec<String> = {
1808 let mut stmt = self.conn.prepare(
1809 "SELECT DISTINCT session_id FROM events
1810 WHERE step IS NULL AND kind != 'terminal_output'",
1811 )?;
1812 let rows = stmt.query_map([], |r| r.get::<_, String>(0))?;
1813 let mut v = Vec::new();
1814 for r in rows {
1815 v.push(r?);
1816 }
1817 v
1818 };
1819 for id in &ids {
1820 self.assign_steps(id)?;
1821 }
1822 Ok(())
1823 }
1824
1825 pub fn event_writer(&self) -> Result<EventWriter> {
1828 self.event_writer_with_redactor(None)
1829 }
1830
1831 pub fn event_writer_with_redactor(
1838 &self,
1839 redactor: Option<std::sync::Arc<crate::redact::Detectors>>,
1840 ) -> Result<EventWriter> {
1841 let (tx, rx) = mpsc::channel::<WriterReq>();
1842 let db_path_for_thread = self.db_path.clone();
1843 let handle = std::thread::Builder::new()
1844 .name("hh-writer".into())
1845 .spawn(move || writer_run(&db_path_for_thread, rx, redactor.as_deref()))
1846 .map_err(|e| StorageError::Open {
1847 path: self.db_path.clone(),
1848 source: e,
1849 })?;
1850 Ok(EventWriter {
1851 tx,
1852 handle: Some(handle),
1853 })
1854 }
1855}
1856
1857impl EventWriter {
1858 pub fn append_event(&self, event: Event) -> Result<i64> {
1860 let (rtx, rrx) = mpsc::channel();
1861 self.tx
1862 .send(WriterReq::Append(event, rtx))
1863 .map_err(|_| StorageError::WriterClosed)?;
1864 rrx.recv()
1865 .map_err(|_| StorageError::WriterClosed)?
1866 .map_err(Error::from)
1867 }
1868
1869 pub fn append_file_change(&self, event: Event, change: FileChange) -> Result<i64> {
1877 let (rtx, rrx) = mpsc::channel();
1878 self.tx
1879 .send(WriterReq::AppendFileChange(event, change, rtx))
1880 .map_err(|_| StorageError::WriterClosed)?;
1881 rrx.recv()
1882 .map_err(|_| StorageError::WriterClosed)?
1883 .map_err(Error::from)
1884 }
1885
1886 pub fn flush(&self) -> Result<()> {
1888 let (rtx, rrx) = mpsc::channel();
1889 self.tx
1890 .send(WriterReq::Flush(rtx))
1891 .map_err(|_| StorageError::WriterClosed)?;
1892 rrx.recv()
1893 .map_err(|_| StorageError::WriterClosed)?
1894 .map_err(Error::from)
1895 }
1896
1897 pub fn close(&mut self) -> Result<()> {
1905 let (rtx, rrx) = mpsc::channel();
1906 let _ = self.tx.send(WriterReq::Finish(rtx));
1907 let _ = rrx.recv();
1908 if let Some(handle) = self.handle.take() {
1909 handle.join().map_err(|_| StorageError::WriterPanic)?;
1910 }
1911 Ok(())
1912 }
1913
1914 pub fn finish(mut self) -> Result<()> {
1916 let (rtx, rrx) = mpsc::channel();
1917 if self.tx.send(WriterReq::Finish(rtx)).is_err() {
1918 }
1920 let _ = rrx.recv();
1921 if let Some(handle) = self.handle.take() {
1922 handle.join().map_err(|_| StorageError::WriterPanic)?;
1923 }
1924 Ok(())
1925 }
1926}
1927
1928impl Drop for EventWriter {
1929 fn drop(&mut self) {
1930 let (rtx, rrx) = mpsc::channel();
1933 let _ = self.tx.send(WriterReq::Finish(rtx));
1934 let _ = rrx.recv();
1935 if let Some(handle) = self.handle.take() {
1936 let _ = handle.join();
1937 }
1938 }
1939}
1940
1941fn writer_run(
1942 db_path: &Path,
1943 rx: Receiver<WriterReq>,
1944 redactor: Option<&crate::redact::Detectors>,
1945) {
1946 let Ok(mut conn) = Connection::open_with_flags(
1949 db_path,
1950 OpenFlags::SQLITE_OPEN_READ_WRITE
1951 | OpenFlags::SQLITE_OPEN_CREATE
1952 | OpenFlags::SQLITE_OPEN_NO_MUTEX,
1953 ) else {
1954 return;
1955 };
1956 if let Err(e) = conn.busy_timeout(Duration::from_secs(5)) {
1957 eprintln!("hh: warning: writer thread could not set busy_timeout: {e}");
1958 }
1959 if let Err(e) = conn.execute("PRAGMA foreign_keys = ON", []) {
1965 eprintln!("hh: warning: writer thread could not enable foreign_keys: {e}");
1966 }
1967 if let Err(e) = conn.execute("PRAGMA synchronous = NORMAL", []) {
1974 eprintln!("hh: warning: writer thread could not set synchronous=NORMAL: {e}");
1975 }
1976 for req in rx {
1977 match req {
1978 WriterReq::Append(mut event, reply) => {
1979 if let Some(d) = redactor {
1980 redact_event_in_place(&mut event, d);
1981 }
1982 let res = insert_event(&conn, &event);
1983 let _ = reply.send(res);
1984 }
1985 WriterReq::AppendFileChange(mut event, change, reply) => {
1986 if let Some(d) = redactor {
1987 redact_event_in_place(&mut event, d);
1988 }
1989 let res = insert_event_with_file_change(&mut conn, &event, &change);
1990 let _ = reply.send(res);
1991 }
1992 WriterReq::Flush(reply) => {
1993 let res = conn
1994 .execute_batch("PRAGMA wal_checkpoint(TRUNCATE);")
1995 .map_err(StorageError::from);
1996 let _ = reply.send(res);
1997 }
1998 WriterReq::Finish(reply) => {
1999 let _ = reply.send(Ok(()));
2000 break;
2001 }
2002 }
2003 }
2004}
2005
2006fn redact_event_in_place(event: &mut Event, d: &crate::redact::Detectors) {
2013 if let Some(r) = d.redact_text(&event.summary) {
2014 event.summary = crate::event::truncate_summary(&r.text);
2015 }
2016 if let Some(body) = event.body_json.as_mut() {
2017 let _ = d.redact_json(body);
2018 }
2019}
2020
2021fn insert_event(conn: &Connection, event: &Event) -> std::result::Result<i64, StorageError> {
2022 let body = event
2023 .body_json
2024 .as_ref()
2025 .map(|v| {
2026 serde_json::to_string(v).map_err(|e| {
2027 StorageError::Sqlite(rusqlite::Error::ToSqlConversionFailure(Box::new(e)))
2028 })
2029 })
2030 .transpose()?;
2031 if let Some(hash) = &event.blob_hash {
2034 let size = i64::try_from(event.blob_size.unwrap_or(0)).unwrap_or(i64::MAX);
2035 conn.execute(
2036 "INSERT INTO blobs (hash, size, refcount) VALUES (?1, ?2, 1)
2037 ON CONFLICT(hash) DO UPDATE SET refcount = refcount + 1",
2038 params![hash, size],
2039 )?;
2040 }
2041 conn.execute(
2042 "INSERT INTO events
2043 (session_id, ts_ms, kind, step, summary, body_json, blob_hash, correlates)
2044 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
2045 params![
2046 event.session_id,
2047 event.ts_ms,
2048 event.kind.to_string(),
2049 event.step,
2050 event.summary,
2051 body,
2052 event.blob_hash,
2053 event.correlates,
2054 ],
2055 )?;
2056 let new_id = conn.last_insert_rowid();
2057 let body_text = extract_fts_text(event.body_json.as_ref());
2059 let _ = conn.execute(
2060 "INSERT INTO events_fts (rowid, session_id, summary, body_text, kind, step)
2061 VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
2062 params![
2063 new_id,
2064 event.session_id,
2065 event.summary,
2066 body_text,
2067 event.kind.to_string(),
2068 event.step,
2069 ],
2070 );
2071 Ok(new_id)
2072}
2073
2074fn insert_event_with_file_change(
2077 conn: &mut Connection,
2078 event: &Event,
2079 change: &FileChange,
2080) -> std::result::Result<i64, StorageError> {
2081 let tx = conn.transaction().map_err(StorageError::from)?;
2082 let body = event
2083 .body_json
2084 .as_ref()
2085 .map(|v| {
2086 serde_json::to_string(v).map_err(|e| {
2087 StorageError::Sqlite(rusqlite::Error::ToSqlConversionFailure(Box::new(e)))
2088 })
2089 })
2090 .transpose()?;
2091 if let Some(hash) = &event.blob_hash {
2092 let size = i64::try_from(event.blob_size.unwrap_or(0)).unwrap_or(i64::MAX);
2093 tx.execute(
2094 "INSERT INTO blobs (hash, size, refcount) VALUES (?1, ?2, 1)
2095 ON CONFLICT(hash) DO UPDATE SET refcount = refcount + 1",
2096 params![hash, size],
2097 )?;
2098 }
2099 tx.execute(
2100 "INSERT INTO events
2101 (session_id, ts_ms, kind, step, summary, body_json, blob_hash, correlates)
2102 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
2103 params![
2104 event.session_id,
2105 event.ts_ms,
2106 event.kind.to_string(),
2107 event.step,
2108 event.summary,
2109 body,
2110 event.blob_hash,
2111 event.correlates,
2112 ],
2113 )?;
2114 let event_id = tx.last_insert_rowid();
2115 let body_text = extract_fts_text(event.body_json.as_ref());
2117 let _ = tx.execute(
2118 "INSERT INTO events_fts (rowid, session_id, summary, body_text, kind, step)
2119 VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
2120 params![
2121 event_id,
2122 event.session_id,
2123 event.summary,
2124 body_text,
2125 event.kind.to_string(),
2126 event.step,
2127 ],
2128 );
2129 tx.execute(
2130 "INSERT INTO file_changes (event_id, path, change_kind, before_hash, after_hash, is_binary)
2131 VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
2132 params![
2133 event_id,
2134 change.path,
2135 change.change_kind.to_string(),
2136 change.before_hash,
2137 change.after_hash,
2138 i64::from(change.is_binary),
2139 ],
2140 )?;
2141 tx.commit().map_err(StorageError::from)?;
2142 Ok(event_id)
2143}
2144
2145fn insert_file_change_row(
2151 conn: &Connection,
2152 event_id: i64,
2153 change: &FileChange,
2154) -> std::result::Result<(), StorageError> {
2155 conn.execute(
2156 "INSERT INTO file_changes (event_id, path, change_kind, before_hash, after_hash, is_binary)
2157 VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
2158 params![
2159 event_id,
2160 change.path,
2161 change.change_kind.to_string(),
2162 change.before_hash,
2163 change.after_hash,
2164 i64::from(change.is_binary),
2165 ],
2166 )?;
2167 Ok(())
2168}
2169
2170fn bundle_new_session(bundle: &crate::bundle::Bundle) -> NewSession {
2176 let s = &bundle.session;
2177 let agent_kind = s
2178 .get("agent_kind")
2179 .and_then(serde_json::Value::as_str)
2180 .and_then(|x| x.parse().ok())
2181 .unwrap_or(AgentKind::Generic);
2182 let adapter_status = match s.get("adapter_status").and_then(serde_json::Value::as_str) {
2183 Some("active") => AdapterStatus::Active,
2184 Some("degraded") => AdapterStatus::Degraded,
2185 _ => AdapterStatus::None,
2186 };
2187 let started_at = s
2188 .get("started_at")
2189 .and_then(serde_json::Value::as_i64)
2190 .unwrap_or(0);
2191 let command = s
2192 .get("command")
2193 .and_then(serde_json::Value::as_array)
2194 .map(|arr| {
2195 arr.iter()
2196 .filter_map(|x| x.as_str().map(str::to_string))
2197 .collect()
2198 })
2199 .unwrap_or_default();
2200 let cwd = s
2201 .get("cwd")
2202 .and_then(serde_json::Value::as_str)
2203 .map(PathBuf::from)
2204 .unwrap_or_default();
2205 NewSession {
2206 id: crate::event::now_v7(),
2207 started_at,
2208 agent_kind,
2209 adapter_status,
2210 command,
2211 cwd,
2212 hostname: None,
2213 hh_version: bundle.hh_version.clone(),
2214 model: None,
2215 git_branch: None,
2216 git_sha: None,
2217 git_dirty: None,
2218 }
2219}
2220
2221fn bundle_event_to_event(session_id: &str, ev: &serde_json::Value) -> Event {
2225 let kind = ev
2226 .get("kind")
2227 .and_then(serde_json::Value::as_str)
2228 .unwrap_or_default()
2229 .parse()
2230 .unwrap_or(EventKind::AgentMessage);
2231 Event {
2232 session_id: session_id.to_string(),
2233 ts_ms: ev
2234 .get("ts_ms")
2235 .and_then(serde_json::Value::as_i64)
2236 .unwrap_or(0),
2237 kind,
2238 step: ev.get("step").and_then(serde_json::Value::as_i64),
2239 summary: ev
2240 .get("summary")
2241 .and_then(serde_json::Value::as_str)
2242 .unwrap_or_default()
2243 .to_string(),
2244 body_json: ev.get("body").filter(|v| !v.is_null()).cloned(),
2245 blob_hash: ev
2246 .get("blob_hash")
2247 .and_then(serde_json::Value::as_str)
2248 .map(str::to_string),
2249 blob_size: ev.get("blob_size").and_then(serde_json::Value::as_u64),
2250 correlates: None,
2251 }
2252}
2253
2254fn bundle_event_file_change(ev: &serde_json::Value) -> Option<FileChange> {
2256 let fc = ev.get("file_change")?;
2257 if fc.is_null() {
2258 return None;
2259 }
2260 let change_kind = fc
2261 .get("change_kind")
2262 .and_then(serde_json::Value::as_str)?
2263 .parse()
2264 .ok()?;
2265 Some(FileChange {
2266 event_id: 0, path: fc
2268 .get("path")
2269 .and_then(serde_json::Value::as_str)
2270 .unwrap_or_default()
2271 .to_string(),
2272 change_kind,
2273 before_hash: fc
2274 .get("before_hash")
2275 .and_then(serde_json::Value::as_str)
2276 .map(str::to_string),
2277 after_hash: fc
2278 .get("after_hash")
2279 .and_then(serde_json::Value::as_str)
2280 .map(str::to_string),
2281 is_binary: fc
2282 .get("is_binary")
2283 .and_then(serde_json::Value::as_bool)
2284 .unwrap_or(false),
2285 })
2286}
2287
2288fn push_grouped(
2293 out: &mut Vec<ScanFinding>,
2294 findings: Vec<crate::redact::Finding>,
2295 event_id: Option<i64>,
2296 step: Option<i64>,
2297 event_kind: Option<EventKind>,
2298 location: &FindingLocation,
2299) {
2300 let mut grouped: std::collections::BTreeMap<(crate::redact::SecretKind, String), u64> =
2301 std::collections::BTreeMap::new();
2302 for f in findings {
2303 *grouped.entry((f.kind, f.hash8)).or_insert(0) += 1;
2304 }
2305 for ((secret, hash8), count) in grouped {
2306 out.push(ScanFinding {
2307 event_id,
2308 step,
2309 event_kind,
2310 location: location.clone(),
2311 secret,
2312 hash8,
2313 count,
2314 });
2315 }
2316}
2317
2318fn map_session_row(r: &rusqlite::Row<'_>) -> rusqlite::Result<SessionRow> {
2319 let status_str: String = r.get(5)?;
2320 let agent_str: String = r.get(6)?;
2321 let adapter_str: String = r.get(7)?;
2322 let command_json: String = r.get(8)?;
2323 let command: Vec<String> = serde_json::from_str(&command_json).unwrap_or_default();
2324 let status = status_str.parse().unwrap_or(SessionStatus::Recording);
2325 let agent = agent_str.parse().unwrap_or(AgentKind::Generic);
2326 let adapter = match adapter_str.as_str() {
2327 "active" => AdapterStatus::Active,
2328 "degraded" => AdapterStatus::Degraded,
2329 _ => AdapterStatus::None,
2330 };
2331 Ok(SessionRow {
2332 id: r.get(0)?,
2333 short_id: r.get(1)?,
2334 started_at: r.get(2)?,
2335 ended_at: r.get(3)?,
2336 exit_code: r.get(4)?,
2337 status,
2338 agent_kind: agent,
2339 adapter_status: adapter,
2340 command,
2341 cwd: PathBuf::from(r.get::<_, String>(9)?),
2342 step_count: r.get(10)?,
2343 files_changed: r.get(11)?,
2344 imported_from: r.get(12)?,
2345 })
2346}
2347
2348fn run_migrations(conn: &Connection) -> std::result::Result<(), StorageError> {
2356 let table_exists: i64 = conn.query_row(
2357 "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name='schema_migrations')",
2358 [],
2359 |r| r.get::<_, i64>(0),
2360 )?;
2361 let applied: i64 = if table_exists == 1 {
2362 conn.query_row(
2363 "SELECT COALESCE(MAX(version), 0) FROM schema_migrations",
2364 [],
2365 |r| r.get::<_, i64>(0),
2366 )?
2367 } else {
2368 0
2369 };
2370 let now = unix_ms();
2371 for &(version, sql) in crate::migrations::MIGRATIONS {
2372 if version <= applied {
2373 continue;
2374 }
2375 conn.execute_batch(sql)
2380 .map_err(|e| StorageError::Migration { version, source: e })?;
2381 conn.execute(
2382 "INSERT OR IGNORE INTO schema_migrations (version, applied_at) VALUES (?1, ?2)",
2383 params![version, now],
2384 )?;
2385 }
2386 Ok(())
2387}
2388
2389fn file_size(path: &Path) -> u64 {
2392 fs::metadata(path).map_or(0, |m| m.len())
2393}
2394
2395fn sidecar(db_path: &Path, suffix: &str) -> PathBuf {
2400 let mut s = db_path.as_os_str().to_owned();
2401 s.push(suffix);
2402 PathBuf::from(s)
2403}
2404
2405fn unix_ms() -> i64 {
2408 std::time::SystemTime::now()
2409 .duration_since(std::time::UNIX_EPOCH)
2410 .map_or(0, |d| i64::try_from(d.as_millis()).unwrap_or(i64::MAX))
2411}
2412
2413fn format_ts_ms(ms: i64) -> String {
2415 use time::OffsetDateTime;
2416 match OffsetDateTime::from_unix_timestamp_nanos(i128::from(ms) * 1_000_000) {
2417 Ok(t) => t
2418 .format(&time::macros::format_description!(
2419 "[year]-[month]-[day] [hour]:[minute]:[second]"
2420 ))
2421 .unwrap_or_else(|_| ms.to_string()),
2422 Err(_) => ms.to_string(),
2423 }
2424}
2425
2426fn secure_create_parent(path: &Path) -> Result<()> {
2428 if let Some(parent) = path.parent() {
2429 secure_create_dir(parent)?;
2430 }
2431 Ok(())
2432}
2433
2434fn secure_create_dir(path: &Path) -> Result<()> {
2435 if path.as_os_str().is_empty() {
2436 return Ok(());
2437 }
2438 #[cfg(unix)]
2439 {
2440 use std::os::unix::fs::DirBuilderExt;
2441 std::fs::DirBuilder::new()
2442 .recursive(true)
2443 .mode(0o700)
2444 .create(path)
2445 .map_err(|e| StorageError::Open {
2446 path: path.to_path_buf(),
2447 source: e,
2448 })?;
2449 }
2450 #[cfg(not(unix))]
2451 {
2452 let _ = std::fs::create_dir_all(path);
2453 }
2454 Ok(())
2455}
2456
2457#[cfg(test)]
2458mod tests {
2459 use super::*;
2460 use crate::event::{EventKind, NewSession};
2461 use tempfile::TempDir;
2462 use uuid::Uuid;
2463
2464 fn open_store() -> (TempDir, Store) {
2465 let tmp = TempDir::new().unwrap();
2466 let db = tmp.path().join("hh.db");
2467 let blobs = tmp.path().join("blobs");
2468 let store = Store::open(&db, &blobs).unwrap();
2469 (tmp, store)
2470 }
2471
2472 fn new_session() -> NewSession {
2473 NewSession {
2474 id: Uuid::now_v7(),
2475 started_at: 1_700_000_000_000,
2476 agent_kind: AgentKind::Generic,
2477 adapter_status: AdapterStatus::None,
2478 command: vec!["claude".into()],
2479 cwd: PathBuf::from("/tmp"),
2480 hostname: Some("host".into()),
2481 hh_version: "0.1.0-beta.1".into(),
2482 model: None,
2483 git_branch: Some("main".into()),
2484 git_sha: Some("deadbeef".into()),
2485 git_dirty: Some(false),
2486 }
2487 }
2488
2489 fn event(session_id: &str, ts: i64, step: Option<i64>) -> Event {
2490 Event {
2491 session_id: session_id.to_string(),
2492 ts_ms: ts,
2493 kind: EventKind::AgentMessage,
2494 step,
2495 summary: "hello".into(),
2496 body_json: Some(serde_json::json!({"text": "hi"})),
2497 blob_hash: None,
2498 blob_size: None,
2499 correlates: None,
2500 }
2501 }
2502
2503 #[test]
2504 fn migration_is_idempotent() {
2505 let tmp = TempDir::new().unwrap();
2506 let db = tmp.path().join("hh.db");
2507 let blobs = tmp.path().join("blobs");
2508 {
2510 let store = Store::open(&db, &blobs).unwrap();
2511 let v: i64 = store
2513 .conn
2514 .query_row("SELECT MAX(version) FROM schema_migrations", [], |r| {
2515 r.get(0)
2516 })
2517 .unwrap();
2518 assert_eq!(v, crate::migrations::LATEST_VERSION);
2519 let count: i64 = store
2521 .conn
2522 .query_row("SELECT COUNT(*) FROM sessions", [], |r| r.get(0))
2523 .unwrap();
2524 assert_eq!(count, 0);
2525 }
2526 {
2528 let store = Store::open(&db, &blobs).unwrap();
2529 let v: i64 = store
2530 .conn
2531 .query_row("SELECT MAX(version) FROM schema_migrations", [], |r| {
2532 r.get(0)
2533 })
2534 .unwrap();
2535 assert_eq!(v, crate::migrations::LATEST_VERSION);
2536 }
2537 {
2539 let _store = Store::open(&db, &blobs).unwrap();
2540 }
2541 }
2542
2543 #[test]
2544 fn wal_mode_is_active() {
2545 let (_tmp, store) = open_store();
2546 let mode: String = store
2547 .conn
2548 .query_row("PRAGMA journal_mode", [], |r| r.get(0))
2549 .unwrap();
2550 assert_eq!(mode, "wal");
2551 }
2552
2553 #[test]
2557 fn heal_partial_index_exists_after_migration_0002() {
2558 let tmp = TempDir::new().unwrap();
2559 let db = tmp.path().join("hh.db");
2560 let blobs = tmp.path().join("blobs");
2561 {
2562 let store = Store::open(&db, &blobs).unwrap();
2563 let count: i64 = store
2564 .conn
2565 .query_row(
2566 "SELECT COUNT(*) FROM sqlite_master
2567 WHERE type='index' AND name='idx_events_needs_heal'",
2568 [],
2569 |r| r.get(0),
2570 )
2571 .unwrap();
2572 assert_eq!(count, 1, "partial index idx_events_needs_heal must exist");
2573 }
2574 let store = Store::open(&db, &blobs).unwrap();
2576 let count: i64 = store
2577 .conn
2578 .query_row(
2579 "SELECT COUNT(*) FROM sqlite_master
2580 WHERE type='index' AND name='idx_events_needs_heal'",
2581 [],
2582 |r| r.get(0),
2583 )
2584 .unwrap();
2585 assert_eq!(count, 1, "reopening must not duplicate the index");
2586 }
2587
2588 #[test]
2589 fn create_list_finalize_session() {
2590 let (_tmp, store) = open_store();
2591 let created = store.create_session(&new_session()).unwrap();
2592 let rows = store.list_sessions(10).unwrap();
2593 assert_eq!(rows.len(), 1);
2594 assert_eq!(rows[0].short_id, created.short_id);
2595 assert_eq!(rows[0].status, SessionStatus::Recording);
2596 assert_eq!(rows[0].agent_kind, AgentKind::Generic);
2597
2598 store
2599 .finalize_session(&created.id, 1_700_000_060_000, Some(0), SessionStatus::Ok)
2600 .unwrap();
2601 let rows = store.list_sessions(10).unwrap();
2602 assert_eq!(rows[0].status, SessionStatus::Ok);
2603 assert_eq!(rows[0].ended_at, Some(1_700_000_060_000));
2604 assert_eq!(rows[0].exit_code, Some(0));
2605 }
2606
2607 #[test]
2608 fn writer_appends_events_and_counts_steps() {
2609 let (_tmp, store) = open_store();
2610 let created = store.create_session(&new_session()).unwrap();
2611 {
2612 let writer = store.event_writer().unwrap();
2613 writer.append_event(event(&created.id, 0, Some(1))).unwrap();
2614 writer.append_event(event(&created.id, 5, Some(2))).unwrap();
2615 writer
2617 .append_event(Event {
2618 kind: EventKind::TerminalOutput,
2619 step: None,
2620 ..event(&created.id, 7, None)
2621 })
2622 .unwrap();
2623 writer.flush().unwrap();
2624 writer.finish().unwrap();
2625 }
2626 let rows = store.list_sessions(10).unwrap();
2627 assert_eq!(rows[0].step_count, 2);
2628 }
2629
2630 #[test]
2631 fn resolve_last_returns_most_recent() {
2632 let (_tmp, store) = open_store();
2633 let a = store.create_session(&new_session()).unwrap();
2634 let mut b = new_session();
2636 b.started_at = 1_700_000_001_000;
2637 let b = store.create_session(&b).unwrap();
2638 let resolved = store.resolve_session("last").unwrap();
2639 assert_eq!(resolved, b.id);
2640 assert_eq!(store.resolve_session(&a.id).unwrap(), a.id);
2642 }
2643
2644 #[test]
2645 fn resolve_short_id_prefix_unique() {
2646 let (_tmp, store) = open_store();
2647 let created = store.create_session(&new_session()).unwrap();
2648 let resolved = store.resolve_session(&created.short_id).unwrap();
2650 assert_eq!(resolved, created.id);
2651 let prefix: String = created.short_id.chars().take(3).collect();
2653 let resolved = store.resolve_session(&prefix).unwrap();
2654 assert_eq!(resolved, created.id);
2655 }
2656
2657 #[test]
2658 fn resolve_ambiguous_prefix_lists_candidates() {
2659 let tmp = TempDir::new().unwrap();
2661 let db = tmp.path().join("hh.db");
2662 let blobs = tmp.path().join("blobs");
2663 let store = Store::open(&db, &blobs).unwrap();
2664 let id_a = Uuid::now_v7(); store.create_session(&new_session()).unwrap();
2667 let shared_prefix = "ab";
2670 store.conn.execute(
2671 "INSERT INTO sessions (id, short_id, started_at, status, agent_kind, adapter_status, command, cwd, hh_version)
2672 VALUES (?1, ?2, ?3, 'recording', 'generic', 'none', '[]', '/tmp', '0.1.0-beta.1')",
2673 params![id_a.to_string(), format!("{shared_prefix}cd12"), 1_700_000_000_000_i64],
2674 ).unwrap();
2675 store.conn.execute(
2676 "INSERT INTO sessions (id, short_id, started_at, status, agent_kind, adapter_status, command, cwd, hh_version)
2677 VALUES (?1, ?2, ?3, 'recording', 'generic', 'none', '[]', '/tmp', '0.1.0-beta.1')",
2678 params![Uuid::now_v7().to_string(), format!("{shared_prefix}ef34"), 1_700_000_001_000_i64],
2679 ).unwrap();
2680 let err = store.resolve_session(shared_prefix).unwrap_err();
2681 let msg = format!("{err}");
2682 assert!(msg.contains("ambiguous"), "msg was: {msg}");
2683 assert!(msg.contains("abcd12"), "msg was: {msg}");
2684 assert!(msg.contains("abef34"), "msg was: {msg}");
2685 }
2686
2687 #[test]
2688 fn resolve_empty_when_no_sessions() {
2689 let (_tmp, store) = open_store();
2690 let err = store.resolve_session("last").unwrap_err();
2691 assert!(matches!(
2692 err,
2693 crate::Error::Storage(StorageError::Resolve(ResolveError::Empty))
2694 ));
2695 }
2696
2697 #[test]
2698 fn resolve_not_found_for_unknown_prefix() {
2699 let (_tmp, store) = open_store();
2700 let err = store.resolve_session("zzzzzz").unwrap_err();
2701 assert!(matches!(
2702 err,
2703 crate::Error::Storage(StorageError::NotFound(_))
2704 ));
2705 }
2706
2707 #[test]
2708 fn delete_session_gcs_unreferenced_blobs() {
2709 let (_tmp, store) = open_store();
2710 let created = store.create_session(&new_session()).unwrap();
2711 let out = store.blobs().put(b"payload to gc").unwrap();
2713 let writer = store.event_writer().unwrap();
2714 writer
2715 .append_event(Event {
2716 kind: EventKind::FileChange,
2717 blob_hash: Some(out.hash.clone()),
2718 blob_size: Some(out.size),
2719 summary: "file changed".into(),
2720 ..event(&created.id, 1, Some(1))
2721 })
2722 .unwrap();
2723 writer.finish().unwrap();
2724 let blob_path = store.blobs().blob_path(&out.hash);
2726 assert!(blob_path.exists());
2727 let rc: i64 = store
2728 .conn
2729 .query_row(
2730 "SELECT refcount FROM blobs WHERE hash = ?1",
2731 params![out.hash],
2732 |r| r.get(0),
2733 )
2734 .unwrap();
2735 assert_eq!(rc, 1);
2736 let removed = store.delete_session(&created.id).unwrap();
2738 assert_eq!(removed, 1);
2739 assert!(!blob_path.exists());
2740 let rc: Option<i64> = store
2742 .conn
2743 .query_row(
2744 "SELECT refcount FROM blobs WHERE hash = ?1",
2745 params![out.hash],
2746 |r| r.get(0),
2747 )
2748 .optional()
2749 .unwrap();
2750 assert!(rc.is_none());
2751 }
2752
2753 #[test]
2754 fn shared_blob_refcount_survives_one_session_delete() {
2755 let (_tmp, store) = open_store();
2756 let a = store.create_session(&new_session()).unwrap();
2757 let mut b = new_session();
2758 b.started_at += 1000;
2759 let b = store.create_session(&b).unwrap();
2760 let out = store.blobs().put(b"shared payload").unwrap();
2761 let writer = store.event_writer().unwrap();
2762 for sid in [&a.id, &b.id] {
2763 writer
2764 .append_event(Event {
2765 kind: EventKind::FileChange,
2766 blob_hash: Some(out.hash.clone()),
2767 blob_size: Some(out.size),
2768 summary: "file changed".into(),
2769 ..event(sid, 1, Some(1))
2770 })
2771 .unwrap();
2772 }
2773 writer.finish().unwrap();
2774 let blob_path = store.blobs().blob_path(&out.hash);
2775 store.delete_session(&a.id).unwrap();
2777 assert!(blob_path.exists());
2778 store.delete_session(&b.id).unwrap();
2780 assert!(!blob_path.exists());
2781 }
2782
2783 #[test]
2788 fn finalize_checkpoint_makes_db_copy_safe() {
2789 let tmp = TempDir::new().unwrap();
2790 let db = tmp.path().join("hh.db");
2791 let blobs = tmp.path().join("blobs");
2792 let created = {
2793 let store = Store::open(&db, &blobs).unwrap();
2794 let c = store.create_session(&new_session()).unwrap();
2795 store
2796 .finalize_session(&c.id, 1_700_000_060_000, Some(0), SessionStatus::Ok)
2797 .unwrap();
2798 let wal = sidecar(&db, "-wal");
2801 if wal.exists() {
2802 assert_eq!(
2803 fs::metadata(&wal).unwrap().len(),
2804 0,
2805 "WAL must be truncated to 0 bytes after finalize checkpoint"
2806 );
2807 }
2808 c
2809 };
2810 let copy_dir = TempDir::new().unwrap();
2812 let copy_db = copy_dir.path().join("hh.db");
2813 let copy_blobs = copy_dir.path().join("blobs");
2814 fs::create_dir_all(©_blobs).unwrap();
2815 fs::copy(&db, ©_db).unwrap();
2816 let store = Store::open(©_db, ©_blobs).unwrap();
2817 let row = store.get_session(&created.id).unwrap();
2818 assert_eq!(row.status, SessionStatus::Ok);
2819 assert_eq!(row.ended_at, Some(1_700_000_060_000));
2820 assert_eq!(row.exit_code, Some(0));
2821 }
2822
2823 #[test]
2827 fn prune_removes_orphan_files_and_dangling_rows() {
2828 let (_tmp, store) = open_store();
2829 let created = store.create_session(&new_session()).unwrap();
2830
2831 let orphan = store.blobs().put(b"orphan file content").unwrap();
2833 let orphan_path = store.blobs().blob_path(&orphan.hash);
2834 assert!(orphan_path.exists());
2835
2836 let live = store.blobs().put(b"live referenced content").unwrap();
2838 let live_path = store.blobs().blob_path(&live.hash);
2839 {
2840 let writer = store.event_writer().unwrap();
2841 writer
2842 .append_event(Event {
2843 kind: EventKind::FileChange,
2844 blob_hash: Some(live.hash.clone()),
2845 blob_size: Some(live.size),
2846 summary: "live".into(),
2847 ..event(&created.id, 1, Some(1))
2848 })
2849 .unwrap();
2850 writer.finish().unwrap();
2851 }
2852 assert!(live_path.exists());
2853
2854 let dangling = store.blobs().put(b"dangling row content").unwrap();
2856 let dangling_path = store.blobs().blob_path(&dangling.hash);
2857 {
2858 let writer = store.event_writer().unwrap();
2859 writer
2860 .append_event(Event {
2861 kind: EventKind::FileChange,
2862 blob_hash: Some(dangling.hash.clone()),
2863 blob_size: Some(dangling.size),
2864 summary: "dangling".into(),
2865 ..event(&created.id, 2, Some(2))
2866 })
2867 .unwrap();
2868 writer.finish().unwrap();
2869 }
2870 fs::remove_file(&dangling_path).unwrap();
2871
2872 let stats = store.prune_orphan_blobs().unwrap();
2873 assert_eq!(stats.orphan_files_removed, 1, "the orphan file was removed");
2874 assert!(stats.orphan_bytes_reclaimed > 0);
2875 assert!(!orphan_path.exists(), "orphan file gone");
2876 assert_eq!(
2877 stats.orphan_rows_removed, 1,
2878 "the dangling (file-missing) row was removed"
2879 );
2880 assert!(live_path.exists(), "the live referenced blob survives");
2881 let live_rc: Option<i64> = store
2882 .conn
2883 .query_row(
2884 "SELECT refcount FROM blobs WHERE hash = ?1",
2885 params![live.hash],
2886 |r| r.get(0),
2887 )
2888 .optional()
2889 .unwrap();
2890 assert_eq!(live_rc, Some(1));
2891 let dangling_rc: Option<i64> = store
2892 .conn
2893 .query_row(
2894 "SELECT refcount FROM blobs WHERE hash = ?1",
2895 params![dangling.hash],
2896 |r| r.get(0),
2897 )
2898 .optional()
2899 .unwrap();
2900 assert!(dangling_rc.is_none(), "dangling row removed");
2901 }
2902
2903 #[test]
2906 fn store_stats_reports_counts_and_largest() {
2907 let (_tmp, store) = open_store();
2908 let a = store.create_session(&new_session()).unwrap();
2909 let mut b = new_session();
2910 b.started_at += 1000;
2911 let b = store.create_session(&b).unwrap();
2912 {
2913 let writer = store.event_writer().unwrap();
2914 for ts in 0..3 {
2915 writer.append_event(event(&a.id, ts, Some(ts + 1))).unwrap();
2916 }
2917 writer.append_event(event(&b.id, 0, Some(1))).unwrap();
2918 let live = store.blobs().put(b"stats live blob").unwrap();
2920 writer
2921 .append_event(Event {
2922 kind: EventKind::FileChange,
2923 blob_hash: Some(live.hash.clone()),
2924 blob_size: Some(live.size),
2925 summary: "blob".into(),
2926 ..event(&a.id, 5, Some(4))
2927 })
2928 .unwrap();
2929 writer.finish().unwrap();
2930 }
2931 let _orphan = store.blobs().put(b"stats orphan blob").unwrap();
2933
2934 let stats = store.store_stats(5).unwrap();
2935 assert_eq!(stats.sessions, 2);
2936 assert_eq!(stats.events, 5, "a has 4 (3 + blob event), b has 1");
2937 assert_eq!(stats.blobs_count, 1, "only the referenced blob has a row");
2938 assert!(stats.blobs_uncompressed_bytes > 0);
2939 assert!(stats.db_bytes > 0);
2940 assert!(stats.blobs_dir_bytes > 0, "two blob files on disk");
2941 assert_eq!(stats.largest_sessions.len(), 2);
2942 assert_eq!(stats.largest_sessions[0].id, a.id);
2943 assert_eq!(stats.largest_sessions[0].event_count, 4);
2944 assert_eq!(stats.largest_sessions[1].event_count, 1);
2945 }
2946
2947 #[test]
2952 fn for_each_event_detail_streams_in_order_and_resolves_blob() {
2953 let (_tmp, store) = open_store();
2954 let created = store.create_session(&new_session()).unwrap();
2955 let real_body = serde_json::json!({"text": "resolved payload"});
2956 let out = store
2957 .blobs()
2958 .put(serde_json::to_vec(&real_body).unwrap().as_slice())
2959 .unwrap();
2960 let envelope = serde_json::json!({
2961 "overflow": true,
2962 "size": out.size,
2963 "blob_hash": out.hash,
2964 "encoding": "blob",
2965 });
2966 let writer = store.event_writer().unwrap();
2967 let id_plain = writer.append_event(event(&created.id, 0, Some(1))).unwrap();
2968 let id_blob = writer
2969 .append_event(Event {
2970 body_json: Some(envelope),
2971 blob_hash: Some(out.hash.clone()),
2972 blob_size: Some(out.size),
2973 ..event(&created.id, 1, Some(2))
2974 })
2975 .unwrap();
2976 let id_fc = writer
2977 .append_file_change(
2978 Event {
2979 kind: EventKind::FileChange,
2980 summary: "file changed".into(),
2981 ..event(&created.id, 2, Some(3))
2982 },
2983 FileChange {
2984 event_id: 0,
2985 path: "src/lib.rs".into(),
2986 change_kind: ChangeKind::Modified,
2987 before_hash: None,
2988 after_hash: None,
2989 is_binary: false,
2990 },
2991 )
2992 .unwrap();
2993 writer.finish().unwrap();
2994
2995 let mut got: Vec<EventDetail> = Vec::new();
2996 store
2997 .for_each_event_detail(&created.id, |d| {
2998 got.push(d);
2999 Ok(())
3000 })
3001 .unwrap();
3002 assert_eq!(got.len(), 3);
3003 assert_eq!(got[0].id, id_plain);
3004 assert_eq!(got[0].body_json.as_ref().unwrap()["text"], "hi");
3005 assert!(got[0].file_change.is_none());
3006 assert_eq!(got[1].id, id_blob);
3007 assert_eq!(
3008 got[1].body_json.as_ref().unwrap(),
3009 &real_body,
3010 "blob overflow resolved inline"
3011 );
3012 assert_eq!(got[2].id, id_fc);
3013 let fc = got[2].file_change.as_ref().expect("file_change attached");
3014 assert_eq!(fc.path, "src/lib.rs");
3015 assert_eq!(fc.event_id, id_fc);
3016 }
3017
3018 #[test]
3028 #[ignore = "100k-row scale check; run with --ignored (docs/performance.md)"]
3029 fn for_each_event_detail_streams_100k_session() {
3030 let (tmp, store) = open_store();
3031 let created = store.create_session(&new_session()).unwrap();
3032 let sid = created.id.clone();
3033 let db_path = tmp.path().join("hh.db");
3034 let blobs_path = tmp.path().join("blobs");
3035 drop(store);
3038
3039 {
3043 let conn = Connection::open(&db_path).unwrap();
3044 conn.execute("BEGIN", []).unwrap();
3045 let mut stmt = conn
3046 .prepare(
3047 "INSERT INTO events
3048 (session_id, ts_ms, kind, step, summary, body_json,
3049 blob_hash, correlates)
3050 VALUES (?1, ?2, ?3, ?4, ?5, ?6, NULL, NULL)",
3051 )
3052 .unwrap();
3053 for i in 0i64..100_000 {
3054 stmt.execute(params![
3055 &sid,
3056 i,
3057 "agent_message",
3058 Some((i / 4) + 1),
3059 format!("event {i}"),
3060 r#"{"text":"x"}"#,
3061 ])
3062 .unwrap();
3063 }
3064 drop(stmt);
3065 conn.execute("COMMIT", []).unwrap();
3066 }
3067
3068 let store = Store::open(&db_path, &blobs_path).unwrap();
3073
3074 let index = store.list_event_index(&sid).unwrap();
3076 assert_eq!(index.len(), 100_000, "index loads all 100k rows");
3077 let timeline = crate::build_timeline(&index, false);
3078 assert!(!timeline.is_empty(), "timeline builds over 100k rows");
3079
3080 let mut count = 0u64;
3086 let mut last_ts = i64::MIN;
3087 store
3088 .for_each_event_detail(&sid, |d| {
3089 count += 1;
3090 assert!(d.ts_ms >= last_ts, "stream is ordered by ts_ms, id");
3091 last_ts = d.ts_ms;
3092 Ok(())
3093 })
3094 .unwrap();
3095 assert_eq!(count, 100_000, "stream emits all 100k events in order");
3096 }
3097
3098 #[test]
3099 fn create_session_honors_adapter_status() {
3100 let (_tmp, store) = open_store();
3101 let mut ns = new_session();
3102 ns.adapter_status = AdapterStatus::Active;
3103 let created = store.create_session(&ns).unwrap();
3104 let status: String = store
3105 .conn
3106 .query_row(
3107 "SELECT adapter_status FROM sessions WHERE id = ?1",
3108 params![&created.id],
3109 |r| r.get(0),
3110 )
3111 .unwrap();
3112 assert_eq!(status, "active");
3113 }
3114
3115 #[test]
3116 fn set_session_adapter_meta_updates_and_preserves() {
3117 let (_tmp, store) = open_store();
3118 let created = store.create_session(&new_session()).unwrap();
3119 store
3120 .set_session_adapter_meta(
3121 &created.id,
3122 Some("claude-sonnet-5"),
3123 Some(&serde_json::json!({"input_tokens": 42, "output_tokens": 7})),
3124 AdapterStatus::Active,
3125 )
3126 .unwrap();
3127 let (model, usage, status): (Option<String>, Option<String>, String) = store
3128 .conn
3129 .query_row(
3130 "SELECT model, usage_json, adapter_status FROM sessions WHERE id = ?1",
3131 params![&created.id],
3132 |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
3133 )
3134 .unwrap();
3135 assert_eq!(model.as_deref(), Some("claude-sonnet-5"));
3136 let usage_val: serde_json::Value = serde_json::from_str(&usage.unwrap()).unwrap();
3137 assert_eq!(usage_val["input_tokens"], 42);
3138 assert_eq!(usage_val["output_tokens"], 7);
3139 assert_eq!(status, "active");
3140
3141 store
3144 .set_session_adapter_meta(&created.id, None, None, AdapterStatus::Degraded)
3145 .unwrap();
3146 let (model, status): (Option<String>, String) = store
3147 .conn
3148 .query_row(
3149 "SELECT model, adapter_status FROM sessions WHERE id = ?1",
3150 params![&created.id],
3151 |r| Ok((r.get(0)?, r.get(1)?)),
3152 )
3153 .unwrap();
3154 assert_eq!(
3155 model.as_deref(),
3156 Some("claude-sonnet-5"),
3157 "model survives a None update"
3158 );
3159 assert_eq!(status, "degraded", "status is always overwritten");
3160 }
3161
3162 #[test]
3163 fn assign_steps_shares_call_and_result_step() {
3164 let (_tmp, store) = open_store();
3165 let created = store.create_session(&new_session()).unwrap();
3166 {
3167 let writer = store.event_writer().unwrap();
3168 let call_id = writer
3169 .append_event(Event {
3170 kind: EventKind::ToolCall,
3171 body_json: Some(serde_json::json!({"correlate_key": "tu_1"})),
3172 summary: "call".into(),
3173 ..event(&created.id, 0, None)
3174 })
3175 .unwrap();
3176 writer
3177 .append_event(Event {
3178 kind: EventKind::ToolResult,
3179 correlates: Some(call_id),
3180 body_json: Some(serde_json::json!({"correlate_key": "tu_1"})),
3181 summary: "result".into(),
3182 ..event(&created.id, 5, None)
3183 })
3184 .unwrap();
3185 writer.finish().unwrap();
3186 }
3187 store.assign_steps(&created.id).unwrap();
3188 let steps: Vec<Option<i64>> = store
3189 .conn
3190 .prepare("SELECT step FROM events WHERE session_id = ?1 ORDER BY ts_ms, id")
3191 .unwrap()
3192 .query_map(params![&created.id], |r| r.get::<_, Option<i64>>(0))
3193 .unwrap()
3194 .map(|r| r.unwrap())
3195 .collect();
3196 assert_eq!(steps, vec![Some(1), Some(1)], "call+result share step 1");
3197 }
3198
3199 #[test]
3200 fn assign_steps_self_heals_on_open() {
3201 let tmp = TempDir::new().unwrap();
3202 let db = tmp.path().join("hh.db");
3203 let blobs = tmp.path().join("blobs");
3204 let created = {
3205 let store = Store::open(&db, &blobs).unwrap();
3206 let created = store.create_session(&new_session()).unwrap();
3207 {
3208 let writer = store.event_writer().unwrap();
3209 let call_id = writer
3210 .append_event(Event {
3211 kind: EventKind::ToolCall,
3212 summary: "call".into(),
3213 ..event(&created.id, 0, None)
3214 })
3215 .unwrap();
3216 writer
3217 .append_event(Event {
3218 kind: EventKind::ToolResult,
3219 correlates: Some(call_id),
3220 summary: "result".into(),
3221 ..event(&created.id, 5, None)
3222 })
3223 .unwrap();
3224 writer.finish().unwrap();
3225 }
3226 created.id
3228 };
3229 let store = Store::open(&db, &blobs).unwrap();
3231 let steps: Vec<Option<i64>> = store
3232 .conn
3233 .prepare("SELECT step FROM events WHERE session_id = ?1 ORDER BY ts_ms, id")
3234 .unwrap()
3235 .query_map(params![created], |r| r.get::<_, Option<i64>>(0))
3236 .unwrap()
3237 .map(|r| r.unwrap())
3238 .collect();
3239 assert_eq!(
3240 steps,
3241 vec![Some(1), Some(1)],
3242 "self-heal assigned shared step 1"
3243 );
3244 }
3245
3246 #[test]
3247 fn list_sessions_counts_distinct_steps() {
3248 let (_tmp, store) = open_store();
3249 let created = store.create_session(&new_session()).unwrap();
3250 {
3251 let writer = store.event_writer().unwrap();
3252 writer
3256 .append_event(Event {
3257 kind: EventKind::ToolCall,
3258 summary: "c".into(),
3259 ..event(&created.id, 0, Some(1))
3260 })
3261 .unwrap();
3262 writer
3263 .append_event(Event {
3264 kind: EventKind::ToolResult,
3265 summary: "r".into(),
3266 ..event(&created.id, 5, Some(1))
3267 })
3268 .unwrap();
3269 writer
3270 .append_event(Event {
3271 kind: EventKind::AgentMessage,
3272 summary: "m".into(),
3273 ..event(&created.id, 10, Some(2))
3274 })
3275 .unwrap();
3276 writer.finish().unwrap();
3277 }
3278 let rows = store.list_sessions(10).unwrap();
3279 assert_eq!(
3280 rows[0].step_count, 2,
3281 "distinct steps should be 2 (shared 1 + 2), not 3"
3282 );
3283 }
3284
3285 #[test]
3286 fn delete_session_with_correlated_events() {
3287 let (_tmp, store) = open_store();
3290 let created = store.create_session(&new_session()).unwrap();
3291 {
3292 let writer = store.event_writer().unwrap();
3293 let call_id = writer
3294 .append_event(Event {
3295 kind: EventKind::ToolCall,
3296 summary: "call".into(),
3297 ..event(&created.id, 0, Some(1))
3298 })
3299 .unwrap();
3300 writer
3301 .append_event(Event {
3302 kind: EventKind::ToolResult,
3303 correlates: Some(call_id),
3304 summary: "result".into(),
3305 ..event(&created.id, 5, Some(1))
3306 })
3307 .unwrap();
3308 writer.finish().unwrap();
3309 }
3310 store.delete_session(&created.id).unwrap();
3311 let count: i64 = store
3312 .conn
3313 .query_row(
3314 "SELECT COUNT(*) FROM events WHERE session_id = ?1",
3315 params![&created.id],
3316 |r| r.get(0),
3317 )
3318 .unwrap();
3319 assert_eq!(count, 0, "all events removed on session delete");
3320 }
3321
3322 #[test]
3323 fn get_session_returns_full_row() {
3324 let (_tmp, store) = open_store();
3325 let created = store.create_session(&new_session()).unwrap();
3326 let row = store.get_session(&created.id).unwrap();
3327 assert_eq!(row.id, created.id);
3328 assert_eq!(row.short_id, created.short_id);
3329 assert_eq!(row.status, SessionStatus::Recording);
3330 }
3331
3332 #[test]
3333 fn get_session_not_found() {
3334 let (_tmp, store) = open_store();
3335 let err = store.get_session("does-not-exist").unwrap_err();
3336 assert!(matches!(
3337 err,
3338 crate::Error::Storage(StorageError::NotFound(_))
3339 ));
3340 }
3341
3342 #[test]
3343 fn list_event_index_includes_terminal_output_with_summary() {
3344 let (_tmp, store) = open_store();
3345 let created = store.create_session(&new_session()).unwrap();
3346 {
3347 let writer = store.event_writer().unwrap();
3348 writer
3349 .append_event(Event {
3350 summary: "hi".into(),
3351 ..event(&created.id, 0, Some(1))
3352 })
3353 .unwrap();
3354 writer
3355 .append_event(Event {
3356 kind: EventKind::TerminalOutput,
3357 step: None,
3358 summary: "chunk".into(),
3359 ..event(&created.id, 1, None)
3360 })
3361 .unwrap();
3362 writer.finish().unwrap();
3363 }
3364 let idx = store.list_event_index(&created.id).unwrap();
3365 assert_eq!(idx.len(), 2);
3366 assert_eq!(idx[0].summary, "hi");
3367 assert_eq!(idx[1].kind, EventKind::TerminalOutput);
3368 assert_eq!(idx[1].summary, "chunk");
3369 assert!(idx[1].step.is_none());
3370 }
3371
3372 #[test]
3373 fn get_event_detail_returns_inline_body() {
3374 let (_tmp, store) = open_store();
3375 let created = store.create_session(&new_session()).unwrap();
3376 let writer = store.event_writer().unwrap();
3377 let id = writer.append_event(event(&created.id, 0, Some(1))).unwrap();
3378 writer.finish().unwrap();
3379 let detail = store.get_event_detail(id).unwrap();
3380 assert_eq!(detail.id, id);
3381 assert_eq!(detail.summary, "hello");
3382 assert_eq!(detail.body_json.unwrap()["text"], "hi");
3383 assert!(detail.file_change.is_none());
3384 }
3385
3386 #[test]
3387 fn get_event_detail_not_found() {
3388 let (_tmp, store) = open_store();
3389 let err = store.get_event_detail(999_999).unwrap_err();
3390 assert!(matches!(
3391 err,
3392 crate::Error::Storage(StorageError::NotFound(_))
3393 ));
3394 }
3395
3396 #[test]
3397 fn get_event_detail_resolves_blob_overflow() {
3398 let (_tmp, store) = open_store();
3399 let created = store.create_session(&new_session()).unwrap();
3400 let real_body = serde_json::json!({"text": "the real payload"});
3401 let out = store
3402 .blobs()
3403 .put(serde_json::to_vec(&real_body).unwrap().as_slice())
3404 .unwrap();
3405 let envelope = serde_json::json!({
3406 "overflow": true,
3407 "size": out.size,
3408 "blob_hash": out.hash,
3409 "encoding": "blob",
3410 });
3411 let writer = store.event_writer().unwrap();
3412 let id = writer
3413 .append_event(Event {
3414 body_json: Some(envelope),
3415 blob_hash: Some(out.hash.clone()),
3416 blob_size: Some(out.size),
3417 ..event(&created.id, 0, Some(1))
3418 })
3419 .unwrap();
3420 writer.finish().unwrap();
3421 let detail = store.get_event_detail(id).unwrap();
3422 assert_eq!(
3423 detail.body_json.unwrap(),
3424 real_body,
3425 "overflow envelope must be transparently resolved to the real payload"
3426 );
3427 }
3428
3429 #[test]
3430 fn get_event_detail_includes_file_change() {
3431 let (_tmp, store) = open_store();
3432 let created = store.create_session(&new_session()).unwrap();
3433 let writer = store.event_writer().unwrap();
3434 let id = writer
3435 .append_file_change(
3436 Event {
3437 kind: EventKind::FileChange,
3438 summary: "file changed".into(),
3439 ..event(&created.id, 0, Some(1))
3440 },
3441 FileChange {
3442 event_id: 0,
3443 path: "src/main.rs".into(),
3444 change_kind: ChangeKind::Modified,
3445 before_hash: None,
3446 after_hash: None,
3447 is_binary: false,
3448 },
3449 )
3450 .unwrap();
3451 writer.finish().unwrap();
3452 let detail = store.get_event_detail(id).unwrap();
3453 let fc = detail.file_change.expect("file_change must be attached");
3454 assert_eq!(fc.path, "src/main.rs");
3455 assert_eq!(fc.change_kind, ChangeKind::Modified);
3456 }
3457
3458 #[test]
3459 fn get_correlated_event_resolves_both_directions() {
3460 let (_tmp, store) = open_store();
3461 let created = store.create_session(&new_session()).unwrap();
3462 let writer = store.event_writer().unwrap();
3463 let call_id = writer
3464 .append_event(Event {
3465 kind: EventKind::ToolCall,
3466 summary: "call".into(),
3467 ..event(&created.id, 0, Some(1))
3468 })
3469 .unwrap();
3470 let result_id = writer
3471 .append_event(Event {
3472 kind: EventKind::ToolResult,
3473 correlates: Some(call_id),
3474 summary: "result".into(),
3475 ..event(&created.id, 5, Some(1))
3476 })
3477 .unwrap();
3478 writer.finish().unwrap();
3479
3480 let from_result = store
3482 .get_correlated_event(result_id, Some(call_id))
3483 .unwrap()
3484 .expect("result must resolve to its call");
3485 assert_eq!(from_result.id, call_id);
3486
3487 let from_call = store
3489 .get_correlated_event(call_id, None)
3490 .unwrap()
3491 .expect("call must resolve to its result");
3492 assert_eq!(from_call.id, result_id);
3493 }
3494
3495 #[test]
3496 fn get_correlated_event_none_when_uncorrelated() {
3497 let (_tmp, store) = open_store();
3498 let created = store.create_session(&new_session()).unwrap();
3499 let writer = store.event_writer().unwrap();
3500 let id = writer.append_event(event(&created.id, 0, Some(1))).unwrap();
3501 writer.finish().unwrap();
3502 assert!(store.get_correlated_event(id, None).unwrap().is_none());
3503 }
3504
3505 #[test]
3513 fn list_event_index_loads_10k_events_within_generous_bound() {
3514 let (_tmp, store) = open_store();
3515 let created = store.create_session(&new_session()).unwrap();
3516
3517 {
3524 let tx = store.conn.unchecked_transaction().unwrap();
3525 for i in 0..5_000i64 {
3526 let call_ts = i * 2;
3527 let result_ts = call_ts + 1;
3528 tx.execute(
3529 "INSERT INTO events (session_id, ts_ms, kind, summary) VALUES (?1, ?2, 'tool_call', ?3)",
3530 params![created.id, call_ts, format!("tool_call #{i}")],
3531 )
3532 .unwrap();
3533 let call_id = tx.last_insert_rowid();
3534 tx.execute(
3535 "INSERT INTO events (session_id, ts_ms, kind, summary, correlates) VALUES (?1, ?2, 'tool_result', ?3, ?4)",
3536 params![created.id, result_ts, format!("tool_result #{i}"), call_id],
3537 )
3538 .unwrap();
3539 }
3540 tx.commit().unwrap();
3541 }
3542 store.assign_steps(&created.id).unwrap();
3543
3544 let start = std::time::Instant::now();
3545 let index = store.list_event_index(&created.id).unwrap();
3546 let rows = crate::timeline::build_timeline(&index, false);
3547 let elapsed = start.elapsed();
3548
3549 assert_eq!(index.len(), 10_000, "5k call+result pairs = 10k events");
3550 assert_eq!(rows.len(), 5_000, "each pair collapses to one step row");
3551 assert!(
3552 elapsed < std::time::Duration::from_secs(2),
3553 "loading + grouping 10k events took {elapsed:?}, expected well under 2s"
3554 );
3555 }
3556
3557 mod blob_refcount_prop {
3565 use super::*;
3566 use proptest::prelude::*;
3567 use std::collections::HashMap;
3568
3569 const BLOB_CONTENTS: [&[u8]; 3] = [b"blob-a-content", b"blob-b-content", b"blob-c-content"];
3572
3573 #[derive(Debug, Clone)]
3576 enum Op {
3577 Create,
3578 Append { session: u8, blob: u8 },
3579 Delete { session: u8 },
3580 }
3581
3582 fn op_strategy() -> impl Strategy<Value = Op> {
3583 prop_oneof![
3584 Just(Op::Create),
3585 (0u8..4, 0u8..3).prop_map(|(session, blob)| Op::Append { session, blob }),
3586 (0u8..4).prop_map(|session| Op::Delete { session }),
3587 ]
3588 }
3589
3590 proptest! {
3591 #![proptest_config(ProptestConfig { cases: 32, ..ProptestConfig::default() })]
3592
3593 #[test]
3594 fn refcounts_match_live_refs_and_gc_is_exact(
3595 ops in prop::collection::vec(op_strategy(), 1..40)
3596 ) {
3597 let (_tmp, store) = open_store();
3598 let writer = store.event_writer().unwrap();
3599 let mut sessions: HashMap<u8, String> = HashMap::new();
3601 let mut expected_refs: HashMap<String, i64> = HashMap::new();
3603 let mut next_started_at = 1_700_000_000_000i64;
3604 let mut ts = 0i64;
3605
3606 for op in ops {
3607 match op {
3608 Op::Create => {
3609 if let Some(slot) = (0u8..4).find(|s| !sessions.contains_key(s)) {
3610 let mut ns = new_session();
3611 ns.started_at = next_started_at;
3612 next_started_at += 1;
3613 let created = store.create_session(&ns).unwrap();
3614 sessions.insert(slot, created.id);
3615 }
3616 }
3617 Op::Append { session, blob } => {
3618 let Some(sid) = sessions.get(&session) else { continue };
3619 let content = BLOB_CONTENTS[usize::from(blob % 3)];
3620 let out = store.blobs().put(content).unwrap();
3621 ts += 1;
3622 writer
3623 .append_event(Event {
3624 kind: EventKind::FileChange,
3625 blob_hash: Some(out.hash.clone()),
3626 blob_size: Some(out.size),
3627 summary: "file changed".into(),
3628 ..event(sid, ts, Some(1))
3629 })
3630 .unwrap();
3631 *expected_refs.entry(out.hash).or_insert(0) += 1;
3632 }
3633 Op::Delete { session } => {
3634 let Some(sid) = sessions.remove(&session) else { continue };
3635 let refs: Vec<(String, i64)> = {
3638 let mut stmt = store
3639 .conn
3640 .prepare(
3641 "SELECT blob_hash, COUNT(*) FROM events \
3642 WHERE session_id = ?1 AND blob_hash IS NOT NULL \
3643 GROUP BY blob_hash",
3644 )
3645 .unwrap();
3646 stmt.query_map(params![sid], |r| {
3647 Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?))
3648 })
3649 .unwrap()
3650 .collect::<std::result::Result<_, _>>()
3651 .unwrap()
3652 };
3653 store.delete_session(&sid).unwrap();
3654 for (hash, count) in refs {
3655 if let Some(e) = expected_refs.get_mut(&hash) {
3656 *e -= count;
3657 }
3658 }
3659 }
3660 }
3661
3662 for (hash, expected) in &expected_refs {
3663 prop_assert!(
3664 *expected >= 0,
3665 "test bookkeeping went negative — bug in the test itself"
3666 );
3667 let row: Option<i64> = store
3668 .conn
3669 .query_row(
3670 "SELECT refcount FROM blobs WHERE hash = ?1",
3671 params![hash],
3672 |r| r.get(0),
3673 )
3674 .optional()
3675 .unwrap();
3676 let path = store.blobs().blob_path(hash);
3677 if *expected > 0 {
3678 prop_assert_eq!(
3679 row, Some(*expected),
3680 "DB refcount must equal the live reference count"
3681 );
3682 prop_assert!(path.exists(), "a referenced blob must stay on disk");
3683 } else {
3684 prop_assert_eq!(row, None, "an unreferenced blob's row must be GC'd");
3685 prop_assert!(!path.exists(), "an unreferenced blob's file must be GC'd");
3686 }
3687 }
3688 }
3689 writer.finish().unwrap();
3690 }
3691 }
3692 }
3693
3694 mod migration_idempotency_prop {
3699 use proptest::prelude::*;
3700 use tempfile::TempDir;
3701
3702 fn schema_dump(conn: &rusqlite::Connection) -> Vec<String> {
3703 let mut stmt = conn
3704 .prepare(
3705 "SELECT sql FROM sqlite_master \
3706 WHERE sql IS NOT NULL ORDER BY type, name",
3707 )
3708 .unwrap();
3709 stmt.query_map([], |r| r.get::<_, String>(0))
3710 .unwrap()
3711 .collect::<std::result::Result<_, _>>()
3712 .unwrap()
3713 }
3714
3715 proptest! {
3716 #![proptest_config(ProptestConfig { cases: 16, ..ProptestConfig::default() })]
3717
3718 #[test]
3719 fn reopening_any_number_of_times_is_a_schema_no_op(extra_opens in 1usize..6) {
3720 let tmp = TempDir::new().unwrap();
3721 let db = tmp.path().join("hh.db");
3722 let blobs = tmp.path().join("blobs");
3723
3724 let first = crate::store::Store::open(&db, &blobs).unwrap();
3725 let baseline = schema_dump(&first.conn);
3726 drop(first);
3727
3728 for _ in 0..extra_opens {
3729 let store = crate::store::Store::open(&db, &blobs).unwrap();
3730 let dump = schema_dump(&store.conn);
3731 prop_assert_eq!(&dump, &baseline, "schema drifted after re-opening");
3732 drop(store);
3733 }
3734 }
3735 }
3736 }
3737}