Skip to main content

hh_core/
store.rs

1//! SQLite-backed session/event store (SRS §4.1, §3, FR-3.1).
2//!
3//! The [`Store`] owns one [`rusqlite::Connection`] on the thread that opened
4//! it, used for session lifecycle and reads. During recording an
5//! [`EventWriter`] runs the single-writer task on its own thread with its own
6//! connection, fed by an `mpsc` channel — satisfying CLAUDE.md's
7//! "single-writer, never share a Connection across threads" rule.
8
9use 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
24/// The on-disk store: SQLite DB + blob store.
25pub struct Store {
26    conn: Connection,
27    blobs: BlobStore,
28    db_path: PathBuf,
29}
30
31/// A handle to the single-writer task. Drop is intentional and explicit: call
32/// [`EventWriter::finish`] to flush and join the writer thread.
33pub 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/// The result of creating a session.
50#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct CreatedSession {
52    /// Full UUID string.
53    pub id: String,
54    /// 6-hex-char short id.
55    pub short_id: String,
56}
57
58/// Reclaim report from [`Store::prune_orphan_blobs`]: how many orphan blob
59/// files and stale `blobs` rows were removed, and how much on-disk space was
60/// freed. Used by `hh gc` (Area 3).
61#[derive(Debug, Clone, Default, PartialEq, Eq)]
62pub struct PruneStats {
63    /// Number of on-disk blob files removed: files with no `blobs` row, or a
64    /// zero-refcount row, backing them (a crash between [`BlobStore::put`] and
65    /// the referencing event's commit, or a [`Store::delete_session`] that
66    /// decremented the refcount but crashed before removing the file).
67    pub orphan_files_removed: u64,
68    /// Total compressed on-disk bytes reclaimed by removing those files.
69    pub orphan_bytes_reclaimed: u64,
70    /// Number of `blobs` rows removed: zero-refcount rows paired with an orphan
71    /// file, plus refcount-positive rows whose backing file was missing
72    /// (deleted out of band). Removing the row lets a future reference
73    /// re-create the blob instead of pointing at a missing file.
74    pub orphan_rows_removed: u64,
75}
76
77/// Whole-store inventory reported by [`Store::store_stats`]: counts and disk
78/// footprint broken down by the DB file (plus its WAL/SHM sidecars) and the
79/// blob directory, plus the largest sessions by event count. Used by
80/// `hh stats` (Area 3).
81#[derive(Debug, Clone, PartialEq, Eq)]
82pub struct StoreStats {
83    /// Number of recorded sessions.
84    pub sessions: u64,
85    /// Total event rows across all sessions.
86    pub events: u64,
87    /// Number of distinct blobs in the `blobs` table.
88    pub blobs_count: u64,
89    /// Sum of `blobs.size` (uncompressed content size) — "how much did I
90    /// record", distinct from the on-disk footprint in [`Self::blobs_dir_bytes`].
91    pub blobs_uncompressed_bytes: u64,
92    /// Size of the `hh.db` file in bytes.
93    pub db_bytes: u64,
94    /// Size of the `hh.db-wal` write-ahead log in bytes (0 if absent).
95    pub wal_bytes: u64,
96    /// Size of the `hh.db-shm` shared-memory file in bytes (0 if absent).
97    pub shm_bytes: u64,
98    /// Total on-disk size of the compressed blob files (sum of file sizes).
99    pub blobs_dir_bytes: u64,
100    /// The largest sessions by event count, descending (up to the count
101    /// requested from [`Store::store_stats`]).
102    pub largest_sessions: Vec<LargestSession>,
103}
104
105/// One row of [`StoreStats::largest_sessions`]: a session id and its event
106/// count, for the "largest sessions" summary in `hh stats`.
107#[derive(Debug, Clone, PartialEq, Eq)]
108pub struct LargestSession {
109    /// Full session id.
110    pub id: String,
111    /// 6-hex-char short id.
112    pub short_id: String,
113    /// Number of events in this session.
114    pub event_count: u64,
115}
116
117/// Where a scan finding was located within a session
118/// (docs/redaction-design.md, `hh scan`).
119///
120/// `#[non_exhaustive]`: new location kinds are plausible as scan coverage
121/// grows; variant construction from other crates is unaffected, only
122/// exhaustive `match` needs a wildcard, which nothing outside this crate
123/// currently does.
124#[derive(Debug, Clone, PartialEq, Eq)]
125#[non_exhaustive]
126pub enum FindingLocation {
127    /// The session's recorded command line (`sessions.command`).
128    Command,
129    /// An event's one-line summary.
130    Summary,
131    /// An event's inline `body_json` payload.
132    Body,
133    /// A blob referenced by an event (an overflowed payload, a file
134    /// snapshot, or a non-UTF-8 terminal chunk).
135    Blob {
136        /// The blob's BLAKE3 hex hash.
137        hash: String,
138        /// The file path, when the referencing event is a `file_change`.
139        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/// One `hh scan` finding: what kind of secret, where, and its correlation
158/// tag — never the secret itself (docs/redaction-design.md).
159#[derive(Debug, Clone, PartialEq, Eq)]
160pub struct ScanFinding {
161    /// The containing event's row id; `None` for session-level locations
162    /// (the command line).
163    pub event_id: Option<i64>,
164    /// The containing event's step ordinal, if it has one.
165    pub step: Option<i64>,
166    /// The containing event's kind, if event-scoped.
167    pub event_kind: Option<EventKind>,
168    /// Where within the session the secret sits.
169    pub location: FindingLocation,
170    /// The detected secret kind.
171    pub secret: crate::redact::SecretKind,
172    /// `BLAKE3(secret)[..8]` — correlates the same secret across findings.
173    pub hash8: String,
174    /// Number of occurrences of this `(secret, hash8)` at this location.
175    pub count: u64,
176}
177
178/// Aggregated per-secret tally for a [`RedactOutcome`] and the audit event.
179#[derive(Debug, Clone, PartialEq, Eq)]
180pub struct SecretSummary {
181    /// The detected secret kind.
182    pub secret: crate::redact::SecretKind,
183    /// The secret's correlation tag.
184    pub hash8: String,
185    /// Total occurrences replaced.
186    pub count: u64,
187}
188
189/// The result of [`Store::redact_session`].
190#[derive(Debug, Clone, PartialEq, Eq)]
191pub struct RedactOutcome {
192    /// Event rows whose summary/body were rewritten.
193    pub events_rewritten: u64,
194    /// Blobs whose content was rewritten (stored under a new hash).
195    pub blobs_rewritten: u64,
196    /// Original blob files securely deleted (refcount reached zero).
197    pub blobs_shredded: u64,
198    /// Per-secret tallies of everything replaced.
199    pub secrets: Vec<SecretSummary>,
200    /// Row id of the appended redaction audit event.
201    pub audit_event_id: i64,
202}
203
204/// A search result from FTS5 full-text search.
205#[derive(Debug, Clone, Serialize)]
206pub struct SearchResult {
207    /// The matching event's row id.
208    pub event_id: i64,
209    /// The owning session's full UUID.
210    pub session_id: String,
211    /// The owning session's 6-hex-char short id.
212    pub session_short_id: String,
213    /// The event's step ordinal, if any.
214    pub step: Option<i64>,
215    /// The event kind.
216    pub kind: EventKind,
217    /// The event's one-line summary.
218    pub summary: String,
219    /// A highlighted snippet from the FTS5 `snippet()` function, with `<b>`
220    /// and `</b>` markers around matching terms.
221    pub snippet: String,
222    /// Milliseconds since session start.
223    pub ts_ms: i64,
224}
225
226/// Filters for [`Store::search`].
227#[derive(Debug, Default)]
228pub struct SearchFilters {
229    /// Restrict to sessions with this agent kind.
230    pub agent_kind: Option<AgentKind>,
231    /// Restrict to events of this kind.
232    pub event_kind: Option<EventKind>,
233    /// Only sessions started after this unix-ms timestamp.
234    pub since: Option<i64>,
235    /// Only sessions whose cwd contains this path fragment.
236    pub path: Option<String>,
237    /// Maximum results (default 50).
238    pub limit: u32,
239}
240
241/// Extract searchable text from an event's `body_json` for FTS5 indexing.
242/// Returns a flat text string suitable for full-text search.
243fn 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            // Text events: extract `text` field
250            if let Some(text) = obj.get("text").and_then(|v| v.as_str()) {
251                return text.to_string();
252            }
253            // Tool calls: extract `name` + `input`
254            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            // Tool results: extract `content`
270            if let Some(content) = obj.get("content").and_then(|v| v.as_str()) {
271                return content.to_string();
272            }
273            // Error events: extract `reason`
274            if let Some(reason) = obj.get("reason").and_then(|v| v.as_str()) {
275                return reason.to_string();
276            }
277            // Overflow envelopes: no searchable text
278            if obj
279                .get("overflow")
280                .and_then(serde_json::Value::as_bool)
281                .unwrap_or(false)
282            {
283                return String::new();
284            }
285            // Fallback: pretty-print the JSON (capped)
286            serde_json::to_string(body).unwrap_or_default()
287        }
288        serde_json::Value::String(s) => s.clone(),
289        _ => String::new(),
290    }
291}
292
293/// One event row as read by [`Store::scan_session`] (summary + inline body +
294/// blob reference; no ts).
295struct 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
304/// The raw column tuple backing [`Store::get_event_detail`], grouped into a
305/// struct rather than a bare tuple (clippy::type_complexity).
306struct 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    /// Open or create the store at `db_path`, applying migrations idempotently
319    /// (DR-1). The data and blob directories are created with `0700`
320    /// permissions (NFR-4).
321    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        // foreign_keys + synchronous are per-connection (the migration sets the
336        // persistent journal_mode=WAL on the DB file; these two must be set on
337        // every connection that wants enforcement / the tuned setting).
338        conn.execute("PRAGMA foreign_keys = ON", [])?;
339        // NFR-1 / NFR-3: `synchronous = NORMAL` (not the SQLite default FULL).
340        // In WAL mode NORMAL keeps ACID — no corruption on crash — but does not
341        // fsync the WAL on every commit; it fsyncs only at checkpoint. That is
342        // exactly the durability design NFR-3 names ("fsync on session
343        // finalize"): `finalize_session` runs `wal_checkpoint(TRUNCATE)`, which
344        // fsyncs, so a session is durable at finalize. The default FULL fsyncs
345        // per event (~3 ms fsync → ~300 events/s), which meets NFR-3's durability
346        // *and then some* but caps sustained ingest far below NFR-1's ≥5,000/s.
347        // NORMAL removes the per-commit fsync so ingest is not fsync-bound;
348        // SQLite's default `wal_autocheckpoint` (1000 pages ≈ 4 MiB) bounds the
349        // mid-session power-loss window to the last autocheckpoint. Set on the
350        // writer connection too (see `writer_run`).
351        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        // Self-heal step ordinals (ADR-0002): re-run the step pass for any
359        // session with a semantic event whose step is still NULL — a crashed
360        // finalize, or an attached MCP proxy's late events landing after the
361        // parent's finalize. Usually the empty set, so cheap.
362        store.heal_steps()?;
363        Ok(store)
364    }
365
366    /// Borrow the blob store (e.g. to write file snapshots before referencing
367    /// them from an event).
368    pub fn blobs(&self) -> &BlobStore {
369        &self.blobs
370    }
371
372    /// Run `PRAGMA integrity_check` and return its result. A healthy database
373    /// yields `"ok"`; a corrupt one yields the first reported fault line. Used by
374    /// `hh doctor` as a non-mutating health probe (integrity_check is read-only).
375    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    /// Reclaim free pages and shrink `hh.db` on disk (Area 3 / `hh gc`).
384    /// `VACUUM` rebuilds the database file, so it must run with no other
385    /// connection open and outside a transaction — `hh gc` is the only caller
386    /// and owns the store's sole connection, with the single-writer task not
387    /// running. A failure here (e.g. another process holds the file) is
388    /// surfaced; it never corrupts the store.
389    pub fn vacuum(&self) -> Result<()> {
390        self.conn.execute_batch("VACUUM")?;
391        Ok(())
392    }
393
394    /// Remove orphaned blob files and stale `blobs` rows (Area 3 / `hh gc`).
395    /// Two passes:
396    /// 1. For every blob file on disk, if no live `blobs` row references it
397    ///    (no row, or refcount ≤ 0), remove the file (and the stale row, if
398    ///    any). These are files leaked by a crash between [`BlobStore::put`]
399    ///    and the referencing event's commit, or a [`Store::delete_session`]
400    ///    that decremented the refcount but crashed before removing the file.
401    /// 2. For every `blobs` row with a positive refcount, if its file is
402    ///    missing on disk (deleted out of band), remove the row so a future
403    ///    reference re-creates the blob instead of pointing at a missing file.
404    ///
405    /// See [`PruneStats`] for what is reported. Never panics on a stray file
406    /// in the blobs directory; unparseable names are skipped.
407    pub fn prune_orphan_blobs(&self) -> Result<PruneStats> {
408        let mut stats = PruneStats::default();
409        // Pass 1: orphan files on disk.
410        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                    // A (zero-refcount) row backed this orphan file; drop it too.
429                    self.conn
430                        .execute("DELETE FROM blobs WHERE hash = ?1", params![&hash])?;
431                    stats.orphan_rows_removed += 1;
432                }
433            }
434        }
435        // Pass 2: dangling rows (refcount > 0, file missing on disk).
436        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    /// Whole-store inventory: counts, per-file disk footprint (the `hh.db`
459    /// file plus its `-wal`/`-shm` sidecars and the compressed blob directory),
460    /// and the `largest` sessions by event count (Area 3 / `hh stats`).
461    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        // On-disk footprint: the db file + WAL/SHM sidecars + compressed blobs.
496        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    /// Create a new session row (FR-1.2).
517    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    /// Finalize a session with end metadata (FR-1.6) and checkpoint WAL
549    /// (NFR-3 fsync-on-finalize).
550    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        // Best-effort WAL checkpoint to make the finalize durable on disk.
562        // `wal_checkpoint` returns a row, so use `execute_batch` (which discards
563        // result rows) rather than `execute` (which errors with
564        // `ExecuteReturnedResults`).
565        let _ = self.conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);");
566        Ok(())
567    }
568
569    /// Update a session's adapter-reported metadata at finalize (FR-1.5): model
570    /// name, token-usage JSON, and final adapter status. `model` and
571    /// `usage_json` use `COALESCE` so passing `None` (the adapter saw no
572    /// assistant records) does not clobber a value an earlier update set;
573    /// `adapter_status` is always overwritten with the outcome's status.
574    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    /// List sessions newest-first (FR-5.1).
599    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    /// Resolve a session id per FR-3.1: `last` → most recently started; a
622    /// short-id prefix → the unique session whose short id starts with it
623    /// (ambiguity is an error listing candidates); a full id → itself.
624    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        // Exact full-id match short-circuits.
637        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        // Prefix match on short_id.
649        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                    // `lines` is a String; writeln! never fails on it.
672                    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    /// Look up a session's `started_at` (unix-ms). Used by the MCP proxy in
685    /// attached mode to express event timestamps relative to the parent
686    /// session's clock, so MCP events interleave correctly on the parent's
687    /// timeline (FR-2). Errors if the session id does not exist.
688    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    /// Look up a session's short id (6 hex). Used by the MCP proxy to print the
702    /// parent session's short id in its epilogue when attaching (FR-2). Errors
703    /// if the session id does not exist.
704    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    /// Search events across all sessions using FTS5 full-text search.
718    /// Returns matching events with highlighted snippets, ordered by session
719    /// start time descending then event timestamp ascending.
720    ///
721    /// The `query` parameter uses FTS5 query syntax: words, phrases in quotes,
722    /// prefix with `*`, AND/OR/NOT operators. An empty query returns no results.
723    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    /// Delete a session and garbage-collect blobs no longer referenced by any
781    /// event (FR-6.1). Returns the number of blob files removed.
782    ///
783    /// Refcount semantics: the writer bumps a blob's refcount once *per
784    /// referencing event* (see `insert_event`'s `ON CONFLICT DO UPDATE SET
785    /// refcount = refcount + 1`), so deleting a session must decrement by the
786    /// number of this session's events that reference each blob — not by 1.
787    /// Otherwise a session that references the same content blob from two
788    /// events (e.g. two files with identical content) would leave the blob
789    /// leaked at refcount 1 after deletion. Grouping by `blob_hash` with
790    /// `COUNT(*)` and decrementing by that count keeps the books balanced and
791    /// lets a blob shared *across* sessions survive the deletion of one.
792    pub fn delete_session(&self, id: &str) -> Result<usize> {
793        // Collect (blob_hash, reference_count) for every blob this session's
794        // events reference, before the cascade deletes the events.
795        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        // Clean up the FTS5 index for this session's events before the cascade
813        // deletes the events (FTS rows are not cascade-deleted).
814        tx.execute(
815            "DELETE FROM events_fts WHERE rowid IN (SELECT id FROM events WHERE session_id = ?1)",
816            params![id],
817        )?;
818        // Decrement refcounts by the per-blob reference count, GC'ing any blob
819        // that reaches zero (content-addressed, so a blob still referenced by
820        // another session stays on disk).
821        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        // Null out intra-session event correlations before the cascade. The
837        // `events.correlates` self-FK has no ON DELETE clause (default NO
838        // ACTION), so without this the sessions→events cascade could trip
839        // RESTRICT deleting a `tool_call` while a `tool_result` still
840        // references it. Nulling first breaks the self-references; the whole
841        // session is being deleted anyway.
842        tx.execute(
843            "UPDATE events SET correlates = NULL WHERE session_id = ?1",
844            params![id],
845        )?;
846        // Cascade deletes events + file_changes (FK ON DELETE CASCADE).
847        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    /// Scan a session for secrets without mutating anything (`hh scan`,
857    /// docs/redaction-design.md). Runs `detectors` over the session's
858    /// recorded command line, every event's summary and inline body, and
859    /// every referenced blob's content (JSON-aware for structured payloads).
860    /// Findings carry the secret kind, location, and hash8 — never the
861    /// secret itself. Each distinct blob is scanned once, attributed to its
862    /// first referencing event (and to the file path when that event is a
863    /// `file_change`).
864    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        // Session command line.
880        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        // Events: summary + inline body; collect blob references as we go.
891        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                // Structured bodies are scanned as a parsed tree (see
925                // redact::Detectors::detect_json); a (defensively handled)
926                // unparseable body is scanned as text so scan and redact
927                // agree on what they cover.
928                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                // A missing/corrupt blob is `hh doctor`'s problem, not a scan
946                // failure — skip it rather than aborting the report.
947                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    /// Redact a session in place (`hh redact`, docs/redaction-design.md):
975    /// rewrite every event summary/body, rewrite affected blobs under new
976    /// hashes (repointing this session's references and moving refcounts),
977    /// securely delete originals that reach refcount zero, append a
978    /// redaction audit event, and purge plaintext remnants from the WAL and
979    /// freelist (checkpoint + VACUUM). Irreversible by design.
980    ///
981    /// Refuses a session still in `recording` status — a live writer could
982    /// re-insert plaintext mid-rewrite.
983    #[allow(clippy::too_many_lines)] // one transaction, inherently sequential phases
984    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        // Phase 1: the session's recorded command line.
1018        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        // Phase 2: event summaries and inline bodies.
1035        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                    // Defensive: an unparseable body is redacted as text so
1063                    // redact covers everything scan reports.
1064                    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                // Update the FTS5 index with the redacted summary/body.
1087                // Parse the new body back to a Value for text extraction.
1088                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        // Phase 3: referenced blobs. Each affected blob is re-stored redacted
1104        // under its new content hash; this session's references (events,
1105        // file_changes, overflow envelopes) are repointed and the refcounts
1106        // move with them. Originals that reach refcount zero are deleted from
1107        // the table now and shredded from disk after commit.
1108        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; // missing blob: gc/doctor territory, not redact's
1123            };
1124            let Some(redacted) = detectors.redact_bytes(&content) else {
1125                continue; // clean or binary
1126            };
1127            count_findings(&mut tally, &redacted.findings);
1128            let put = self.blobs.put(&redacted.bytes)?;
1129            // Repoint this session's event references.
1130            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            // Repoint file-change before/after hashes within this session.
1135            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            // Rewrite overflow envelopes that embed the old hash/size.
1146            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            // Move the refcounts: +refs on the new blob, -refs on the old.
1183            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        // Phase 4: the audit event — the session self-documents what was
1204        // removed (types + hash8 tallies, never secret material). Reuses
1205        // kind = 'lifecycle' so the documented events.kind value set is
1206        // unchanged (v1.0.0 addendum: additive only).
1207        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        // Post-commit: give the audit event its step ordinal, then destroy
1249        // the plaintext remnants — shred zero-ref originals on disk, and
1250        // checkpoint + VACUUM so no copy survives in the WAL or in freelist
1251        // pages of hh.db (invariant I1/I2 in docs/redaction-design.md).
1252        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    /// Import a validated [`crate::bundle::Bundle`] (`hh import file.hh`) as
1275    /// a brand-new local session: a fresh UUIDv7 id, `imported_from` set to
1276    /// the bundle's original session id, every referenced blob written
1277    /// content-addressed into this store, and every event/file_change
1278    /// re-inserted with fresh row ids.
1279    ///
1280    /// `correlates` is resolved from the bundle's `seq`-based scheme in a
1281    /// second pass after every event has a new id — a single forward pass
1282    /// cannot always resolve it inline, since a `tool_result` can
1283    /// legitimately correlate to a `tool_call` that sorts *after* it by
1284    /// `ts_ms` (a concurrent source; see `timeline.rs`'s "out of order
1285    /// result" test). `step`/`ts_ms` are reused verbatim from the bundle —
1286    /// they were already valid ordinals for this exact event sequence, so
1287    /// there is no need to re-run [`Self::assign_steps`].
1288    ///
1289    /// Blob writes happen before the transaction (content-addressed and
1290    /// idempotent — safe even if the import is retried), then every event
1291    /// insert runs in one transaction on the store's own connection, the
1292    /// same pattern [`Self::redact_session`]/[`Self::delete_session`] use:
1293    /// this is a one-shot batch operation, not concurrent live recording, so
1294    /// the writer-thread/mpsc machinery ([`Self::event_writer`]) is
1295    /// unnecessary.
1296    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    /// Return `(event_count, files_changed)` for a session (FR-1.6 epilogue).
1366    /// `event_count` is the total event rows; `files_changed` is the count of
1367    /// distinct paths in `file_changes`. Reads from the store's own
1368    /// connection (concurrent with the writer under WAL).
1369    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    /// Count the distinct stored step ordinals for a session (FR-3.4). A
1385    /// correlated `tool_result` shares its `tool_call`'s step, so this is
1386    /// `COUNT(DISTINCT step)` over non-null steps — not a count of semantic
1387    /// events. Step ordinals are stored at finalize (ADR-0002; see
1388    /// [`Self::assign_steps`]) and self-healed on [`Store::open`].
1389    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    /// Read all events for a session as [`EventRow`]s, ordered by `(ts_ms, id)`
1399    /// (the order the FR-3.4 step pass assigns in). Used by [`Self::assign_steps`]
1400    /// and by replay/inspect (FR-3/FR-4, future).
1401    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            // Unknown kinds should not occur for data we wrote; if one does,
1409            // treat it as a semantic step event (AgentMessage) so it still gets
1410            // a step rather than being silently dropped from the pass.
1411            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    /// Fetch one session's full row by its (already-resolved) full id
1429    /// (FR-3.2 header; FR-4). Call [`Self::resolve_session`] first to turn a
1430    /// short id / `last` into the full id this expects.
1431    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    /// Read the full per-event index for a session (FR-3.5 eager index load):
1451    /// every event (including `terminal_output`) with its one-line summary,
1452    /// but not the (potentially large) `body_json`/blob payload — that is
1453    /// fetched lazily per selected row via [`Self::get_event_detail`].
1454    /// Ordered by `(ts_ms, id)`.
1455    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    /// Fetch one event's full detail by row id (FR-3.5 lazy body load):
1480    /// resolves a blob-overflowed `body_json` transparently (fetches and
1481    /// decompresses the blob, returning its parsed content in place of the
1482    /// `{"overflow": true, ...}` envelope) and attaches the `file_changes` row
1483    /// for `FileChange` events. Errors if the id does not exist.
1484    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    /// Fetch the event correlated with `event_id` (FR-3.2 MCP/tool
1531    /// call+result pairing): if `correlates` is `Some`, that is the
1532    /// request/call this event points at; otherwise look for an event that
1533    /// points *at* `event_id` (its response/result). Returns `None` if there
1534    /// is no correlated event either way.
1535    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    /// Stream every event detail of `session_id`, in `(ts_ms, id)` order,
1558    /// calling `emit` once per event. This is the constant-memory path for
1559    /// `hh inspect --json` (FR-4): a single SQL cursor with a `LEFT JOIN` to
1560    /// `file_changes` walks the session row by row, and any blob-overflow
1561    /// body is resolved inline (the same overflow-envelope path as
1562    /// [`Self::get_event_detail`]), so the whole
1563    /// session is never collected into RAM — unlike [`Self::list_event_index`]
1564    /// followed by per-row [`Self::get_event_detail`], which is the right path
1565    /// for the interactive timeline but not for streaming a 100k-event session
1566    /// to NDJSON (Area 2).
1567    ///
1568    /// Each [`EventDetail`] is built, emitted, and dropped before the next
1569    /// row is fetched, so peak memory is O(1) regardless of session size. If
1570    /// `emit` returns `Err`, iteration stops immediately and the error
1571    /// propagates; a row-decode error likewise short-circuits.
1572    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            // LEFT JOIN: fc.event_id is NULL when the event has no
1604            // file_changes row. Use it as the "is there a change?" sentinel.
1605            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    /// Stream every event of `session_id` exactly as stored — no blob-
1637    /// overflow resolution, plus the referenced blob's size (`for_each_event_detail`'s
1638    /// resolution silently loses the association between an event and a
1639    /// binary/non-JSON blob it references — see
1640    /// [`crate::event::RawEventRow`]). Used by `hh-core::bundle` (`hh export
1641    /// --bundle`), which must carry every referenced blob byte-for-byte, not
1642    /// just the ones that happen to resolve as JSON.
1643    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    /// Resolve a possibly blob-overflowed body: if `inline` is the
1707    /// `{"overflow": true, ...}` envelope and `blob_hash` is set, fetch and
1708    /// decompress the blob and parse it as the real payload. Falls back to
1709    /// the inline value (the envelope itself) if the blob is missing or
1710    /// corrupt — a display concern, not worth failing the whole fetch over.
1711    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    /// Fetch the `file_changes` row attached to `event_id`, if any.
1737    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    /// Run the FR-3.4 step-assignment pass for a session and write the
1762    /// ordinals back to `events.step` in one transaction on the main
1763    /// connection (ADR-0002). Call after the writer is flushed + joined so
1764    /// there is no within-process writer contention. Idempotent.
1765    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    /// Mark any sessions still in `recording` status as `interrupted`
1780    /// (FR-1.7 crash-safety). Called on `Store::open` so a crashed `hh run`
1781    /// is readable on the next invocation.
1782    ///
1783    /// # SRS deviation (flagged)
1784    ///
1785    /// FR-1.7 says "detected via missing end timestamp + no live PID". The
1786    /// SRS schema (§4.1 `sessions`) has no PID column, so we cannot check
1787    /// liveness; we mark *all* `recording` sessions as `interrupted` on open.
1788    /// This is correct for the common case (one `hh` at a time) but would
1789    /// mis-mark a genuinely-live recording if two `hh run`s share a data dir
1790    /// — which the SRS does not support in v0.1 anyway. See the decisions
1791    /// summary.
1792    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    /// Re-run [`Self::assign_steps`] for every session that has a semantic
1802    /// event with a NULL step (ADR-0002 self-heal). Called from [`Self::open`].
1803    /// `terminal_output` events legitimately have NULL steps and are excluded
1804    /// from the "needs heal" probe so a session with only terminal chunks is
1805    /// not pointlessly rescanned.
1806    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    /// Spawn the single-writer task and return a handle for appending events.
1826    /// The writer opens its own [`Connection`] (never shared with the store's).
1827    pub fn event_writer(&self) -> Result<EventWriter> {
1828        self.event_writer_with_redactor(None)
1829    }
1830
1831    /// Like [`Self::event_writer`], but with an optional record-time
1832    /// redactor: every event's `summary` and `body_json` are redacted on the
1833    /// writer thread *before* the `INSERT` (docs/redaction-design.md
1834    /// enforcement point 1 — nothing hits disk unredacted). Blob content is
1835    /// covered by the other chokepoint, [`crate::blob::BlobStore::put`] on a
1836    /// [`crate::blob::BlobStore::with_redactor`] store.
1837    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    /// Append an event via the single-writer task. Returns the new event row id.
1859    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    /// Append a `file_change` event and attach its `file_changes` row in one
1870    /// writer transaction (SRS §4.1 `file_changes`; FR-1.4). The event is
1871    /// inserted first, then the `file_changes` row is written with
1872    /// `event_id = last_insert_rowid()`. Returns the event row id.
1873    ///
1874    /// The `FileChange.event_id` field is ignored and overwritten with the
1875    /// new event's id; callers do not need to pre-allocate it.
1876    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    /// Flush the writer: ensure all queued events are durable on disk.
1887    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    /// Close the writer without consuming it: send `Finish`, join the thread,
1898    /// and release the handle. Subsequent `append_event`/`flush` calls fail
1899    /// with [`StorageError::WriterClosed`]. For use when the writer is shared
1900    /// (e.g. behind `Arc<Mutex<EventWriter>>`) and cannot be moved out.
1901    ///
1902    /// Idempotent with [`EventWriter::finish`] / `Drop`: taking the handle
1903    /// here leaves `None` for `Drop`, so a later drop does not double-join.
1904    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    /// Finish the writer: flush, close the channel, and join the thread.
1915    pub fn finish(mut self) -> Result<()> {
1916        let (rtx, rrx) = mpsc::channel();
1917        if self.tx.send(WriterReq::Finish(rtx)).is_err() {
1918            // Thread already gone; fall through to join to surface the cause.
1919        }
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        // Best-effort drain if the caller forgot to finish(): signal the
1931        // thread to exit and join it so it never lingers past the store.
1932        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    // Opening failed: just return. `rx` drops, which closes the channel; any
1947    // in-flight caller's `send`/`recv` then fails with [`StorageError::WriterClosed`].
1948    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    // Unlike `Store::open` (which propagates this failure), the writer thread
1960    // cannot fail its own construction — it has already been spawned and the
1961    // caller is blocked waiting for the channel, not a `Result`. A failure
1962    // here would silently disable the `correlates`/`file_changes` FK checks
1963    // for the rest of the session, so at least surface it on stderr.
1964    if let Err(e) = conn.execute("PRAGMA foreign_keys = ON", []) {
1965        eprintln!("hh: warning: writer thread could not enable foreign_keys: {e}");
1966    }
1967    // NFR-1 / NFR-3: see `Store::open`. The writer connection owns every commit,
1968    // so this is the connection whose `synchronous` setting actually gates
1969    // ingest throughput. NORMAL keeps per-event commits off the fsync path
1970    // (fsync happens at the finalize checkpoint); FULL here would fsync every
1971    // event and cap ingest near ~300/s. A failure to set it is non-fatal but
1972    // silently leaves ingest fsync-bound, so surface it like foreign_keys.
1973    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
2006/// Record-time redaction of one event (writer thread, enforcement point 1).
2007/// The summary is redacted as text and re-truncated (the replacement token
2008/// can lengthen it past the 120-char limit); the body is redacted as a JSON
2009/// tree. `blob_hash` is left alone — blob content was already redacted by
2010/// [`BlobStore::put`] on the recorder's redacting store before this event
2011/// referenced it.
2012fn 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    // Bump blob refcount upsert, if a blob is referenced. Saturate to i64::MAX
2032    // if a (hypothetical) oversized blob exceeds the column range.
2033    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    // Maintain the FTS5 index: insert a row for every new event.
2058    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
2074/// Insert an event and its attached `file_changes` row in one transaction
2075/// (SRS §4.1; FR-1.4). Used by [`EventWriter::append_file_change`].
2076fn 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    // Maintain the FTS5 index: insert a row for every new event.
2116    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
2145/// Insert one `file_changes` row for an already-inserted event (used by
2146/// [`Store::import`], which — unlike [`insert_event_with_file_change`] —
2147/// runs every event of a session through one outer transaction rather than
2148/// one nested transaction per event, so it needs the plain-INSERT half of
2149/// that function without the transaction-starting half).
2150fn 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
2170/// Build the [`NewSession`] for [`Store::import`] from a bundle's `session`
2171/// block. Fields the bundle format does not carry (`hostname`/`model`/
2172/// `git_*`) are `None` — [`SessionRow`], which `bundle::export` reads from,
2173/// does not expose them either (a pre-existing gap in the read model, not
2174/// something this feature narrows further).
2175fn 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
2221/// Build the [`Event`] to insert for one bundle event (`correlates` is left
2222/// `None` — [`Store::import`] resolves it in a second pass once every event
2223/// has a new row id).
2224fn 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
2254/// Extract the `file_change` sub-object of a bundle event, if present.
2255fn 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, // overwritten by the caller once the owning event's id is known.
2267        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
2288/// Group raw detector findings by `(secret kind, hash8)` and append one
2289/// [`ScanFinding`] per group at the given location (`hh scan` reports
2290/// occurrence counts, not per-offset rows — offsets would say nothing useful
2291/// without printing the secret's surroundings).
2292fn 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
2348/// Apply embedded migrations idempotently (DR-1).
2349///
2350/// The first migration's DDL creates `schema_migrations` (it is part of the
2351/// public schema per DR-2), so we cannot assume the table exists before the
2352/// first migration runs. We probe `sqlite_master` instead: if the table is
2353/// absent the database is fresh (`applied = 0`) and every migration runs in
2354/// order; otherwise we skip up to and including the recorded `MAX(version)`.
2355fn 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        // Each migration's DDL may include PRAGMAs (0001 does) which cannot run
2376        // inside a transaction, so execute the batch outside a tx, then record
2377        // its version. `0002` is a single additive `CREATE INDEX IF NOT EXISTS`
2378        // (see migrations/0002_events_heal_index.sql).
2379        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
2389/// The size of `path` in bytes, or `0` if it does not exist or its length is
2390/// unreadable (best-effort footprint accounting for `hh stats`).
2391fn file_size(path: &Path) -> u64 {
2392    fs::metadata(path).map_or(0, |m| m.len())
2393}
2394
2395/// Append `suffix` (e.g. `"-wal"`, `"-shm"`) to `db_path` to name a SQLite
2396/// sidecar file. SQLite names the WAL/SHM as `<db>-wal`/`<db>-shm` regardless
2397/// of the db's extension, so this appends rather than `with_extension` (which
2398/// would mishandle an extension-less db name).
2399fn 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
2405/// Current unix time in milliseconds. Uses `std::SystemTime` (no `Date::now`,
2406/// which is unavailable in some test harnesses; this is fine in normal Rust).
2407fn 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
2413/// Format a unix-ms timestamp as an ISO-8601 UTC string for error messages.
2414fn 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
2426/// Create the parent directory of `path` with `0700` perms (NFR-4).
2427fn 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        // First open creates + migrates.
2509        {
2510            let store = Store::open(&db, &blobs).unwrap();
2511            // Sanity: schema_migrations recorded the latest migration version.
2512            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            // Tables exist.
2520            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        // Second open must not error and must not re-run the DDL.
2527        {
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        // Third open via the same path: still fine.
2538        {
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    /// Migration 0002 installs the partial index that makes `heal_steps` an
2554    /// O(needs-heal) probe rather than an O(all-events) scan (Area 4). A fresh
2555    /// store must carry it, and reopening must not duplicate it.
2556    #[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        // Reopen is a no-op migration (applied == LATEST) — index still exactly one.
2575        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            // Non-step event (terminal output) — not counted as a step.
2616            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        // Slightly newer start time.
2635        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        // Full id resolves.
2641        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        // Full short id resolves.
2649        let resolved = store.resolve_session(&created.short_id).unwrap();
2650        assert_eq!(resolved, created.id);
2651        // A prefix of the short id resolves while unique.
2652        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        // Build two sessions whose short ids share a common prefix.
2660        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        // Force deterministic short ids by crafting UUIDs with known hex prefixes.
2665        let id_a = Uuid::now_v7(); // we'll just insert rows directly to control short_id
2666        store.create_session(&new_session()).unwrap();
2667        // Insert a second session with a short_id sharing the first 2 chars by
2668        // writing directly (bypassing NewSession short_id derivation).
2669        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        // Put a blob on disk and reference it from an event.
2712        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        // Blob is on disk and refcounted to 1.
2725        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        // Delete the session -> blob GC'd.
2737        let removed = store.delete_session(&created.id).unwrap();
2738        assert_eq!(removed, 1);
2739        assert!(!blob_path.exists());
2740        // blobs row gone.
2741        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        // Deleting one session decrements to 1 — file stays.
2776        store.delete_session(&a.id).unwrap();
2777        assert!(blob_path.exists());
2778        // Deleting the second drops to 0 — file removed.
2779        store.delete_session(&b.id).unwrap();
2780        assert!(!blob_path.exists());
2781    }
2782
2783    /// NFR-3 / Area 3: `finalize_session` runs `PRAGMA wal_checkpoint(TRUNCATE)`,
2784    /// so the main `hh.db` file holds every committed page at rest. Copying
2785    /// *only* `hh.db` (no `-wal`/`-shm`) into a fresh data dir and reopening
2786    /// must yield the finalized session intact — i.e. `hh.db` is copy-safe.
2787    #[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            // checkpoint(TRUNCATE) ran in finalize: the WAL is 0 bytes (it is
2799            // removed on close, so only assert if still present).
2800            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        // Copy only hh.db (no sidecars) into a fresh data dir and reopen.
2811        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(&copy_blobs).unwrap();
2815        fs::copy(&db, &copy_db).unwrap();
2816        let store = Store::open(&copy_db, &copy_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    /// `prune_orphan_blobs` (Area 3 / `hh gc`) removes an orphan file (on
2824    /// disk, no `blobs` row), removes a dangling row (refcount > 0, file
2825    /// missing), and leaves a live referenced blob untouched.
2826    #[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        // 1) Orphan file: written to disk, never referenced (no blobs row).
2832        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        // 2) Live referenced blob: must survive prune.
2837        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        // 3) Dangling row: refcount > 0, but the file is deleted out of band.
2855        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    /// `store_stats` (Area 3 / `hh stats`) reports session/event/blob counts,
2904    /// a non-empty DB footprint, and the largest sessions in descending order.
2905    #[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            // A referenced blob on `a` (a 4th event).
2919            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        // An orphan blob file on disk (no row) so blobs_dir_bytes > blobs row.
2932        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    /// `for_each_event_detail` (Area 2) streams a session's events in
2948    /// `(ts_ms, id)` order, resolves a blob-overflow body inline, and attaches
2949    /// the `file_changes` row — the constant-memory primitive behind
2950    /// `hh inspect --json`.
2951    #[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    /// Area 2 big-session hardening: a synthetic 100k-event session must
3019    /// stream through `for_each_event_detail` (the `hh inspect --json` path)
3020    /// and load its event index (the `hh replay` open path) at this scale
3021    /// without collecting every row in RAM. `#[ignore]`d because 100k rows are
3022    /// too heavy for the default per-PR suite — run with `cargo test -p hh-core
3023    /// -- --ignored for_each_event_detail_streams_100k` to re-verify (the
3024    /// manual scale check documented in `docs/performance.md`). Events are
3025    /// bulk-inserted in one transaction for speed; the system under test is
3026    /// the *read* path, which is unaffected by how the rows got there.
3027    #[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        // Release the store's connection before bulk-inserting so a single
3036        // connection owns the write transaction.
3037        drop(store);
3038
3039        // Bulk-insert 100k events in one transaction. The writer thread's
3040        // per-event channel round-trip would make this test unacceptably slow;
3041        // this is fixture setup, not the system under test.
3042        {
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        // Reopen so the read path starts from a clean snapshot of all 100k
3069        // committed rows (and so `Store::open` re-runs `heal_steps`, which the
3070        // migration 0002 partial index keeps O(rows-needing-heal)=O(0) here:
3071        // every event has a non-null step).
3072        let store = Store::open(&db_path, &blobs_path).unwrap();
3073
3074        // The replay-open path: the event index + timeline over 100k rows.
3075        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        // The `hh inspect --json` path: stream 100k events in constant memory
3081        // — the closure touches one EventDetail at a time; nothing accumulates.
3082        // A regression that re-introduced a `Vec::with_capacity(index.len())`
3083        // would still pass the count but balloon memory; the ordered-stream
3084        // assertion plus the closure's O(1) shape is the structural guard.
3085        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        // Passing None for model/usage must not clobber (COALESCE); status is
3142        // always overwritten.
3143        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            // Do NOT call assign_steps: steps stay NULL.
3227            created.id
3228        };
3229        // Reopen: Store::open self-heals the NULL steps (ADR-0002).
3230        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            // call + result share step 1; agent_message is step 2. The old
3253            // COUNT(*) WHERE kind != 'terminal_output' would report 3; the new
3254            // COUNT(DISTINCT step) reports 2.
3255            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        // R7: events.correlates is a self-FK with no ON DELETE clause. Deleting
3288        // a session whose events are correlated must not trip RESTRICT.
3289        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        // From the result (has `correlates` set): resolves to the call.
3481        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        // From the call (no `correlates`): resolves via reverse lookup to the result.
3488        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    /// FR-3.5/NFR-1: the replay TUI loads the full per-event index eagerly at
3506    /// startup. Benchmark-ish: a synthetic 10k-event session (half plain
3507    /// messages, half correlated tool_call/tool_result pairs) must load and
3508    /// group into a timeline well within a generous CI bound. Not a tight
3509    /// perf assertion (CI machines vary widely) — it exists to catch an
3510    /// accidental O(n²) regression (e.g. a linear scan per row), not to police
3511    /// exact timings.
3512    #[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        // Seed 10k events (5k correlated tool_call/tool_result pairs) directly
3518        // via one transaction on the store's own connection, bypassing the
3519        // single-writer channel's per-event round trip: that round trip (one
3520        // mpsc send/recv + one autocommit per call) is real and correct for a
3521        // live recorder, but would make *this* test measure writer throughput
3522        // instead of the eager-index-load path FR-3.5 actually cares about.
3523        {
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    /// Property tests for blob refcounting + GC (`delete_session`'s
3558    /// decrement-then-GC logic). For any sequence of session creates, blob-
3559    /// referencing appends (from a small shared content pool, so blobs are
3560    /// referenced by multiple events/sessions), and session deletes: the DB
3561    /// refcount for a blob always equals its live reference count, a blob with
3562    /// live references is never GC'd, and a blob with none is always GC'd
3563    /// (both its `blobs` row and its on-disk file).
3564    mod blob_refcount_prop {
3565        use super::*;
3566        use proptest::prelude::*;
3567        use std::collections::HashMap;
3568
3569        /// A small shared pool of blob contents so appends across sessions
3570        /// collide on the same hash (exercising the shared-refcount path).
3571        const BLOB_CONTENTS: [&[u8]; 3] = [b"blob-a-content", b"blob-b-content", b"blob-c-content"];
3572
3573        /// A small pool of session "slots" (0..4) so creates/deletes/appends
3574        /// interleave against a bounded, reusable set of sessions.
3575        #[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                // slot -> live session id (absent = not created yet / deleted).
3600                let mut sessions: HashMap<u8, String> = HashMap::new();
3601                // Our own ground-truth: hash -> count of *live* events referencing it.
3602                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                            // This session's per-blob reference counts, read before the
3636                            // delete cascades (mirrors delete_session's own query).
3637                            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    /// Property test for migration idempotency (DR-1): applying the migration
3695    /// set to a fresh DB, then reopening (which re-runs `run_migrations` and is
3696    /// a no-op past `LATEST_VERSION`) any number of extra times, always yields
3697    /// byte-identical schema.
3698    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}