zagens_runtime_adapters/persist/
compaction_artifact_store.rs1use anyhow::Context;
33use rusqlite::{Connection, params};
34use zagens_core::compaction::CompactionArtifact;
35
36pub 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
58pub 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
87pub 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
116pub 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 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"); 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 #[test]
212 fn reversibility_original_messages_preserved() {
213 let db = test_db();
214
215 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 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 assert_eq!(
250 recovered_msgs[0]["content"][0]["text"], "hello",
251 "original message content preserved"
252 );
253
254 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}