Skip to main content

zagens_runtime_adapters/persist/
compaction_artifact_store.rs

1//! SQLite persistence for compaction artifacts (kernel-v2 Phase 2-C).
2//!
3//! The `compaction_artifacts` table is **additive-only** — older binaries that
4//! do not import this module simply ignore the table.  Columns are never
5//! removed; new optional columns may be added via `ALTER TABLE … ADD COLUMN`
6//! migrations following the same pattern as `ensure_sessions_runtime_thread_id_column`.
7//!
8//! ## Table schema
9//!
10//! ```sql
11//! CREATE TABLE IF NOT EXISTS compaction_artifacts (
12//!     id               TEXT    PRIMARY KEY,
13//!     session_id       TEXT    NOT NULL,
14//!     created_at_ms    INTEGER NOT NULL,
15//!     replaced_start   INTEGER NOT NULL,
16//!     replaced_end     INTEGER NOT NULL,
17//!     replaced_messages_json TEXT NOT NULL DEFAULT '[]',
18//!     summary          TEXT    NOT NULL DEFAULT '',
19//!     original_tokens  INTEGER NOT NULL DEFAULT 0,
20//!     summary_tokens   INTEGER NOT NULL DEFAULT 0
21//! );
22//! ```
23//!
24//! ## Usage
25//!
26//! 1. Call [`ensure_compaction_artifacts_table`] once after opening the DB.
27//! 2. Call [`save_compaction_artifact`] after each successful compaction.
28//! 3. Call [`load_compaction_artifacts`] to read all artifacts for a session
29//!    (ordered oldest → newest).
30//! 4. Call [`delete_compaction_artifacts_for_session`] when deleting a session.
31
32use anyhow::Context;
33use rusqlite::{Connection, params};
34use zagens_core::compaction::CompactionArtifact;
35
36/// Ensures the `compaction_artifacts` table exists in `db`.
37///
38/// Safe to call on every DB open — `CREATE TABLE IF NOT EXISTS` is idempotent.
39pub fn ensure_compaction_artifacts_table(db: &Connection) -> anyhow::Result<()> {
40    db.execute_batch(
41        "CREATE TABLE IF NOT EXISTS compaction_artifacts (
42            id                     TEXT    PRIMARY KEY,
43            session_id             TEXT    NOT NULL,
44            created_at_ms          INTEGER NOT NULL,
45            replaced_start         INTEGER NOT NULL,
46            replaced_end           INTEGER NOT NULL,
47            replaced_messages_json TEXT    NOT NULL DEFAULT '[]',
48            summary                TEXT    NOT NULL DEFAULT '',
49            original_tokens        INTEGER NOT NULL DEFAULT 0,
50            summary_tokens         INTEGER NOT NULL DEFAULT 0
51        );
52        CREATE INDEX IF NOT EXISTS idx_artifacts_session_time
53            ON compaction_artifacts(session_id, created_at_ms);",
54    )
55    .context("Failed to create compaction_artifacts table")
56}
57
58/// Persist one [`CompactionArtifact`] to `db`.
59///
60/// On conflict (same `id`) replaces the existing row — this is a no-op in
61/// practice since artifact IDs are UUIDs generated at creation time.
62pub fn save_compaction_artifact(
63    db: &Connection,
64    artifact: &CompactionArtifact,
65) -> anyhow::Result<()> {
66    db.execute(
67        "INSERT OR REPLACE INTO compaction_artifacts
68             (id, session_id, created_at_ms, replaced_start, replaced_end,
69              replaced_messages_json, summary, original_tokens, summary_tokens)
70         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
71        params![
72            artifact.id,
73            artifact.session_id,
74            artifact.created_at_ms,
75            artifact.replaced_start as i64,
76            artifact.replaced_end as i64,
77            artifact.replaced_messages_json,
78            artifact.summary,
79            artifact.original_tokens as i64,
80            artifact.summary_tokens as i64,
81        ],
82    )
83    .context("Failed to save compaction artifact")?;
84    Ok(())
85}
86
87/// Load all artifacts for `session_id`, ordered oldest → newest.
88pub fn load_compaction_artifacts(
89    db: &Connection,
90    session_id: &str,
91) -> anyhow::Result<Vec<CompactionArtifact>> {
92    let mut stmt = db.prepare(
93        "SELECT id, session_id, created_at_ms, replaced_start, replaced_end,
94                replaced_messages_json, summary, original_tokens, summary_tokens
95         FROM compaction_artifacts
96         WHERE session_id = ?1
97         ORDER BY created_at_ms ASC",
98    )?;
99    let rows = stmt.query_map(params![session_id], |row| {
100        Ok(CompactionArtifact {
101            id: row.get(0)?,
102            session_id: row.get(1)?,
103            created_at_ms: row.get(2)?,
104            replaced_start: row.get::<_, i64>(3)? as usize,
105            replaced_end: row.get::<_, i64>(4)? as usize,
106            replaced_messages_json: row.get(5)?,
107            summary: row.get(6)?,
108            original_tokens: row.get::<_, i64>(7)? as u32,
109            summary_tokens: row.get::<_, i64>(8)? as u32,
110        })
111    })?;
112    rows.collect::<Result<Vec<_>, _>>()
113        .context("Failed to load compaction artifacts")
114}
115
116/// Delete all artifacts for `session_id` (call when deleting a session).
117pub fn delete_compaction_artifacts_for_session(
118    db: &Connection,
119    session_id: &str,
120) -> anyhow::Result<()> {
121    db.execute(
122        "DELETE FROM compaction_artifacts WHERE session_id = ?1",
123        params![session_id],
124    )
125    .context("Failed to delete compaction artifacts")?;
126    Ok(())
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132
133    fn test_db() -> Connection {
134        let db = Connection::open_in_memory().unwrap();
135        ensure_compaction_artifacts_table(&db).unwrap();
136        db
137    }
138
139    fn sample_artifact(id: &str, session_id: &str, start: usize, end: usize) -> CompactionArtifact {
140        CompactionArtifact {
141            id: id.to_string(),
142            session_id: session_id.to_string(),
143            created_at_ms: 1_000_000,
144            replaced_start: start,
145            replaced_end: end,
146            replaced_messages_json: "[{\"role\":\"user\",\"content\":[]}]".to_string(),
147            summary: "summary text".to_string(),
148            original_tokens: 500,
149            summary_tokens: 50,
150        }
151    }
152
153    #[test]
154    fn save_and_load_roundtrip() {
155        let db = test_db();
156        let art = sample_artifact("art-1", "sess-a", 0, 4);
157        save_compaction_artifact(&db, &art).unwrap();
158
159        let loaded = load_compaction_artifacts(&db, "sess-a").unwrap();
160        assert_eq!(loaded.len(), 1);
161        let l = &loaded[0];
162        assert_eq!(l.id, "art-1");
163        assert_eq!(l.session_id, "sess-a");
164        assert_eq!(l.replaced_start, 0);
165        assert_eq!(l.replaced_end, 4);
166        assert_eq!(l.summary, "summary text");
167        assert_eq!(l.original_tokens, 500);
168        assert_eq!(l.summary_tokens, 50);
169    }
170
171    #[test]
172    fn load_returns_empty_for_unknown_session() {
173        let db = test_db();
174        let result = load_compaction_artifacts(&db, "nonexistent").unwrap();
175        assert!(result.is_empty());
176    }
177
178    #[test]
179    fn multiple_artifacts_ordered_oldest_first() {
180        let db = test_db();
181        let mut art2 = sample_artifact("art-2", "sess-b", 5, 9);
182        art2.created_at_ms = 2_000_000;
183        let mut art1 = sample_artifact("art-1", "sess-b", 0, 4);
184        art1.created_at_ms = 1_000_000;
185        // insert out of order
186        save_compaction_artifact(&db, &art2).unwrap();
187        save_compaction_artifact(&db, &art1).unwrap();
188
189        let loaded = load_compaction_artifacts(&db, "sess-b").unwrap();
190        assert_eq!(loaded.len(), 2);
191        assert_eq!(loaded[0].id, "art-1"); // oldest first
192        assert_eq!(loaded[1].id, "art-2");
193    }
194
195    #[test]
196    fn delete_removes_only_target_session() {
197        let db = test_db();
198        save_compaction_artifact(&db, &sample_artifact("art-a1", "sess-a", 0, 3)).unwrap();
199        save_compaction_artifact(&db, &sample_artifact("art-b1", "sess-b", 0, 3)).unwrap();
200
201        delete_compaction_artifacts_for_session(&db, "sess-a").unwrap();
202
203        assert!(load_compaction_artifacts(&db, "sess-a").unwrap().is_empty());
204        assert_eq!(load_compaction_artifacts(&db, "sess-b").unwrap().len(), 1);
205    }
206
207    /// **P2-C reversibility gate.**
208    ///
209    /// Given a compaction artifact, we can recover the original messages from
210    /// `replaced_messages_json` and reconstruct the full session.
211    #[test]
212    fn reversibility_original_messages_preserved() {
213        let db = test_db();
214
215        // Simulate: 6 messages [0..6], messages 0..4 summarised, message 5 pinned.
216        let original_msgs_json = serde_json::json!([
217            {"role": "user", "content": [{"type": "text", "text": "hello"}]},
218            {"role": "assistant", "content": [{"type": "text", "text": "hi"}]},
219            {"role": "user", "content": [{"type": "text", "text": "how are you?"}]},
220            {"role": "assistant", "content": [{"type": "text", "text": "great"}]},
221        ])
222        .to_string();
223
224        let art = CompactionArtifact {
225            id: "rev-test".to_string(),
226            session_id: "sess-rev".to_string(),
227            created_at_ms: 999,
228            replaced_start: 0,
229            replaced_end: 4,
230            replaced_messages_json: original_msgs_json.clone(),
231            summary: "User greeted, assistant responded warmly.".to_string(),
232            original_tokens: 40,
233            summary_tokens: 8,
234        };
235
236        save_compaction_artifact(&db, &art).unwrap();
237        let loaded = load_compaction_artifacts(&db, "sess-rev").unwrap();
238        let recovered = &loaded[0];
239
240        // Verify: the full original messages can be recovered
241        let recovered_msgs: serde_json::Value =
242            serde_json::from_str(&recovered.replaced_messages_json).unwrap();
243        assert_eq!(
244            recovered_msgs.as_array().unwrap().len(),
245            4,
246            "should recover all 4 original messages"
247        );
248        // First message still intact
249        assert_eq!(
250            recovered_msgs[0]["content"][0]["text"], "hello",
251            "original message content preserved"
252        );
253
254        // Verify artifact range metadata
255        assert_eq!(recovered.replaced_count(), 4);
256        assert!(recovered.covers_index(0));
257        assert!(recovered.covers_index(3));
258        assert!(!recovered.covers_index(4));
259    }
260}