starweaver_cli/local_store/
archive.rs1use async_trait::async_trait;
4use chrono::Utc;
5use rusqlite::{params, OptionalExtension};
6use starweaver_core::{RunId, SessionId};
7use starweaver_runtime::AgentStreamRecord;
8use starweaver_stream::{
9 DisplayMessage, ReplayCursor, ReplayError, ReplayResult, ReplayScope, ReplaySnapshot,
10 StreamArchive,
11};
12
13use super::{
14 db::insert_display_messages_tx, db::insert_raw_stream_records_tx, DisplayReplayWindow,
15 LocalStore,
16};
17use crate::{config::CliConfig, CliResult};
18
19#[derive(Clone, Debug)]
21pub struct LocalStreamArchive {
22 config: CliConfig,
23}
24
25enum ParsedReplayScope<'a> {
26 Run(&'a str),
27 Session(&'a str),
28}
29
30impl LocalStreamArchive {
31 #[must_use]
33 pub const fn new(config: CliConfig) -> Self {
34 Self { config }
35 }
36
37 fn open_store(&self) -> ReplayResult<LocalStore> {
38 LocalStore::open(&self.config).map_err(replay_failed)
39 }
40
41 pub fn replay_display_window(
43 &self,
44 session_id: &str,
45 run_id: Option<&str>,
46 cursor: Option<&ReplayCursor>,
47 ) -> CliResult<DisplayReplayWindow> {
48 LocalStore::open(&self.config)?.replay_display_window(session_id, run_id, cursor)
49 }
50}
51
52#[async_trait]
53impl StreamArchive for LocalStreamArchive {
54 async fn append_raw_records(
55 &self,
56 session_id: &SessionId,
57 run_id: &RunId,
58 records: Vec<AgentStreamRecord>,
59 ) -> ReplayResult<()> {
60 let mut store = self.open_store()?;
61 let run = store
62 .load_run(session_id.as_str(), run_id.as_str())
63 .map_err(replay_failed)?;
64 let tx = store
65 .conn
66 .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)
67 .map_err(replay_failed)?;
68 insert_raw_stream_records_tx(&tx, &run, &records).map_err(replay_failed)?;
69 tx.commit().map_err(replay_failed)
70 }
71
72 async fn replay_raw_after(
73 &self,
74 session_id: &SessionId,
75 run_id: &RunId,
76 cursor: Option<ReplayCursor>,
77 ) -> ReplayResult<Vec<AgentStreamRecord>> {
78 let scope = ReplayScope::run(run_id.as_str());
79 if let Some(cursor) = cursor.as_ref() {
80 cursor.validate_scope(&scope)?;
81 }
82 let after = cursor.map_or(0, |cursor| cursor.sequence.saturating_add(1));
83 let store = self.open_store()?;
84 let mut stmt = store
85 .conn
86 .prepare(
87 r"
88 SELECT record_json
89 FROM raw_stream_records
90 WHERE session_id = ?1 AND run_id = ?2 AND sequence_no >= ?3
91 ORDER BY sequence_no ASC
92 ",
93 )
94 .map_err(replay_failed)?;
95 let rows = stmt
96 .query_map(
97 params![
98 session_id.as_str(),
99 run_id.as_str(),
100 i64::try_from(after).map_err(replay_failed)?
101 ],
102 |row| row.get::<_, String>(0),
103 )
104 .map_err(replay_failed)?;
105 rows.collect::<Result<Vec<_>, _>>()
106 .map_err(replay_failed)?
107 .into_iter()
108 .map(|json| serde_json::from_str(&json).map_err(replay_failed))
109 .collect()
110 }
111
112 async fn append_display_messages(
113 &self,
114 _scope: ReplayScope,
115 messages: Vec<DisplayMessage>,
116 ) -> ReplayResult<()> {
117 let mut store = self.open_store()?;
118 let tx = store
119 .conn
120 .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)
121 .map_err(replay_failed)?;
122 insert_display_messages_tx(&tx, &messages).map_err(replay_failed)?;
123 tx.commit().map_err(replay_failed)
124 }
125
126 async fn replay_display_after(
127 &self,
128 scope: &ReplayScope,
129 cursor: Option<ReplayCursor>,
130 ) -> ReplayResult<Vec<DisplayMessage>> {
131 if let Some(cursor) = cursor.as_ref() {
132 cursor.validate_scope(scope)?;
133 }
134 let store = self.open_store()?;
135 match parse_scope(scope)? {
136 ParsedReplayScope::Run(run_id) => replay_run_display(&store, run_id, cursor.as_ref()),
137 ParsedReplayScope::Session(session_id) => {
138 replay_session_display(&store, session_id, cursor.as_ref())
139 }
140 }
141 }
142
143 async fn append_snapshot(
144 &self,
145 scope: ReplayScope,
146 snapshot: ReplaySnapshot,
147 ) -> ReplayResult<()> {
148 let store = self.open_store()?;
149 store
150 .conn
151 .execute(
152 "INSERT OR REPLACE INTO replay_snapshots (scope, snapshot_json, updated_at)
153 VALUES (?1, ?2, ?3)",
154 params![
155 scope.as_str(),
156 serde_json::to_string(&snapshot).map_err(replay_failed)?,
157 Utc::now().to_rfc3339()
158 ],
159 )
160 .map_err(replay_failed)?;
161 Ok(())
162 }
163
164 async fn latest_snapshot(&self, scope: &ReplayScope) -> ReplayResult<Option<ReplaySnapshot>> {
165 let store = self.open_store()?;
166 store
167 .conn
168 .query_row(
169 "SELECT snapshot_json FROM replay_snapshots WHERE scope = ?1",
170 params![scope.as_str()],
171 |row| row.get::<_, String>(0),
172 )
173 .optional()
174 .map_err(replay_failed)?
175 .map(|json| serde_json::from_str(&json).map_err(replay_failed))
176 .transpose()
177 }
178
179 async fn cursor_range(
180 &self,
181 scope: &ReplayScope,
182 ) -> ReplayResult<Option<(ReplayCursor, ReplayCursor)>> {
183 let store = self.open_store()?;
184 match parse_scope(scope)? {
185 ParsedReplayScope::Run(run_id) => run_cursor_range(&store, scope, run_id),
186 ParsedReplayScope::Session(session_id) => {
187 session_cursor_range(&store, scope, session_id)
188 }
189 }
190 }
191}
192
193fn replay_run_display(
194 store: &LocalStore,
195 run_id: &str,
196 cursor: Option<&ReplayCursor>,
197) -> ReplayResult<Vec<DisplayMessage>> {
198 let after = cursor.map_or(0, |cursor| cursor.sequence.saturating_add(1));
199 let mut stmt = store
200 .conn
201 .prepare(
202 r"
203 SELECT message_json
204 FROM display_messages
205 WHERE run_id = ?1 AND sequence_no >= ?2
206 ORDER BY sequence_no ASC
207 ",
208 )
209 .map_err(replay_failed)?;
210 let rows = stmt
211 .query_map(
212 params![run_id, i64::try_from(after).map_err(replay_failed)?],
213 |row| row.get::<_, String>(0),
214 )
215 .map_err(replay_failed)?;
216 collect_display_messages(rows)
217}
218
219fn replay_session_display(
220 store: &LocalStore,
221 session_id: &str,
222 cursor: Option<&ReplayCursor>,
223) -> ReplayResult<Vec<DisplayMessage>> {
224 let after = cursor.map_or(0, |cursor| cursor.sequence.saturating_add(1));
225 let mut stmt = store
226 .conn
227 .prepare(
228 r"
229 SELECT dm.message_json
230 FROM display_messages dm
231 JOIN runs r ON r.session_id = dm.session_id AND r.run_id = dm.run_id
232 WHERE dm.session_id = ?1
233 ORDER BY r.sequence_no ASC, dm.sequence_no ASC
234 ",
235 )
236 .map_err(replay_failed)?;
237 let rows = stmt
238 .query_map(params![session_id], |row| row.get::<_, String>(0))
239 .map_err(replay_failed)?;
240 let messages = collect_display_messages(rows)?;
241 Ok(messages
242 .into_iter()
243 .enumerate()
244 .filter_map(|(sequence, message)| (sequence >= after).then_some(message))
245 .collect())
246}
247
248fn collect_display_messages(
249 rows: rusqlite::MappedRows<'_, impl FnMut(&rusqlite::Row<'_>) -> rusqlite::Result<String>>,
250) -> ReplayResult<Vec<DisplayMessage>> {
251 rows.collect::<Result<Vec<_>, _>>()
252 .map_err(replay_failed)?
253 .into_iter()
254 .map(|json| serde_json::from_str(&json).map_err(replay_failed))
255 .collect()
256}
257
258fn run_cursor_range(
259 store: &LocalStore,
260 scope: &ReplayScope,
261 run_id: &str,
262) -> ReplayResult<Option<(ReplayCursor, ReplayCursor)>> {
263 let range = store
264 .conn
265 .query_row(
266 "SELECT MIN(sequence_no), MAX(sequence_no) FROM display_messages WHERE run_id = ?1",
267 params![run_id],
268 |row| Ok((row.get::<_, Option<i64>>(0)?, row.get::<_, Option<i64>>(1)?)),
269 )
270 .map_err(replay_failed)?;
271 let (Some(first), Some(last)) = range else {
272 return Ok(None);
273 };
274 Ok(Some((
275 ReplayCursor::new(
276 scope.clone(),
277 usize::try_from(first).map_err(replay_failed)?,
278 ),
279 ReplayCursor::new(scope.clone(), usize::try_from(last).map_err(replay_failed)?),
280 )))
281}
282
283fn session_cursor_range(
284 store: &LocalStore,
285 scope: &ReplayScope,
286 session_id: &str,
287) -> ReplayResult<Option<(ReplayCursor, ReplayCursor)>> {
288 let count = store
289 .conn
290 .query_row(
291 "SELECT COUNT(*) FROM display_messages WHERE session_id = ?1",
292 params![session_id],
293 |row| row.get::<_, i64>(0),
294 )
295 .map_err(replay_failed)?;
296 let count = usize::try_from(count).map_err(replay_failed)?;
297 if count == 0 {
298 return Ok(None);
299 }
300 Ok(Some((
301 ReplayCursor::new(scope.clone(), 0),
302 ReplayCursor::new(scope.clone(), count.saturating_sub(1)),
303 )))
304}
305
306fn parse_scope(scope: &ReplayScope) -> ReplayResult<ParsedReplayScope<'_>> {
307 if let Some(run_id) = scope.as_str().strip_prefix("run:") {
308 return Ok(ParsedReplayScope::Run(run_id));
309 }
310 if let Some(session_id) = scope.as_str().strip_prefix("session:") {
311 return Ok(ParsedReplayScope::Session(session_id));
312 }
313 Err(ReplayError::InvalidCursor(format!(
314 "unsupported replay scope {}",
315 scope.as_str()
316 )))
317}
318
319fn replay_failed(error: impl std::fmt::Display) -> ReplayError {
320 ReplayError::Failed(error.to_string())
321}