Skip to main content

starweaver_cli/local_store/
archive.rs

1//! `StreamArchive` adapter over the CLI local store.
2
3use async_trait::async_trait;
4use chrono::Utc;
5use rusqlite::{OptionalExtension, params};
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    DisplayReplayWindow, LocalStore,
15    db::insert_raw_stream_records_tx,
16    db::{insert_display_messages_for_run_tx, insert_display_messages_tx},
17};
18use crate::{CliResult, config::CliConfig};
19
20/// Shared stream archive adapter backed by the CLI local `SQLite` store.
21#[derive(Clone, Debug)]
22pub struct LocalStreamArchive {
23    config: CliConfig,
24}
25
26enum ParsedReplayScope<'a> {
27    Run(&'a str),
28    Session(&'a str),
29}
30
31enum DisplayAppendTarget {
32    Run((SessionId, RunId)),
33    Session,
34}
35
36impl LocalStreamArchive {
37    /// Create a local stream archive adapter from resolved CLI config.
38    #[must_use]
39    pub const fn new(config: CliConfig) -> Self {
40        Self { config }
41    }
42
43    fn open_store(&self) -> ReplayResult<LocalStore> {
44        LocalStore::open(&self.config).map_err(replay_failed)
45    }
46
47    /// Replay display messages as scoped replay events for local RPC and TUI hosts.
48    pub fn replay_display_window(
49        &self,
50        session_id: &str,
51        run_id: Option<&str>,
52        cursor: Option<&ReplayCursor>,
53    ) -> CliResult<DisplayReplayWindow> {
54        LocalStore::open(&self.config)?.replay_display_window(session_id, run_id, cursor)
55    }
56}
57
58#[async_trait]
59impl StreamArchive for LocalStreamArchive {
60    async fn append_raw_records(
61        &self,
62        session_id: &SessionId,
63        run_id: &RunId,
64        records: Vec<AgentStreamRecord>,
65    ) -> ReplayResult<()> {
66        let mut store = self.open_store()?;
67        let run = store
68            .load_run(session_id.as_str(), run_id.as_str())
69            .map_err(replay_failed)?;
70        let tx = store
71            .conn
72            .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)
73            .map_err(replay_failed)?;
74        insert_raw_stream_records_tx(&tx, &run, &records).map_err(replay_failed)?;
75        tx.commit().map_err(replay_failed)
76    }
77
78    async fn replay_raw_after(
79        &self,
80        session_id: &SessionId,
81        run_id: &RunId,
82        cursor: Option<ReplayCursor>,
83    ) -> ReplayResult<Vec<AgentStreamRecord>> {
84        let scope = ReplayScope::run(run_id.as_str());
85        if let Some(cursor) = cursor.as_ref() {
86            cursor.validate_scope(&scope)?;
87        }
88        let after = cursor.map_or(0, |cursor| cursor.sequence.saturating_add(1));
89        let store = self.open_store()?;
90        let mut stmt = store
91            .conn
92            .prepare(
93                r"
94                SELECT record_json
95                FROM raw_stream_records
96                WHERE session_id = ?1 AND run_id = ?2 AND sequence_no >= ?3
97                ORDER BY sequence_no ASC
98                ",
99            )
100            .map_err(replay_failed)?;
101        let rows = stmt
102            .query_map(
103                params![
104                    session_id.as_str(),
105                    run_id.as_str(),
106                    i64::try_from(after).map_err(replay_failed)?
107                ],
108                |row| row.get::<_, String>(0),
109            )
110            .map_err(replay_failed)?;
111        rows.collect::<Result<Vec<_>, _>>()
112            .map_err(replay_failed)?
113            .into_iter()
114            .map(|json| serde_json::from_str(&json).map_err(replay_failed))
115            .collect()
116    }
117
118    async fn append_display_messages(
119        &self,
120        scope: ReplayScope,
121        messages: Vec<DisplayMessage>,
122    ) -> ReplayResult<()> {
123        if messages.is_empty() {
124            return Ok(());
125        }
126        let mut store = self.open_store()?;
127        let append_target = match parse_scope(&scope)? {
128            ParsedReplayScope::Run(run_id) => {
129                let storage_run_ref = storage_run_ref_for_scope(&store, run_id)?;
130                validate_run_scoped_display_messages(&storage_run_ref.0, &messages)?;
131                DisplayAppendTarget::Run(storage_run_ref)
132            }
133            ParsedReplayScope::Session(session_id) => {
134                validate_session_scoped_display_messages(&store, session_id, &messages)?;
135                DisplayAppendTarget::Session
136            }
137        };
138        let tx = store
139            .conn
140            .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)
141            .map_err(replay_failed)?;
142        match append_target {
143            DisplayAppendTarget::Run((storage_session_id, storage_run_id)) => {
144                insert_display_messages_for_run_tx(
145                    &tx,
146                    &storage_session_id,
147                    &storage_run_id,
148                    &messages,
149                )
150                .map_err(replay_failed)?;
151            }
152            DisplayAppendTarget::Session => {
153                insert_display_messages_tx(&tx, &messages).map_err(replay_failed)?;
154            }
155        }
156        tx.commit().map_err(replay_failed)
157    }
158
159    async fn replay_display_after(
160        &self,
161        scope: &ReplayScope,
162        cursor: Option<ReplayCursor>,
163    ) -> ReplayResult<Vec<DisplayMessage>> {
164        if let Some(cursor) = cursor.as_ref() {
165            cursor.validate_scope(scope)?;
166        }
167        let store = self.open_store()?;
168        match parse_scope(scope)? {
169            ParsedReplayScope::Run(run_id) => {
170                let (session_id, run_id) = storage_run_ref_for_scope(&store, run_id)?;
171                replay_run_display(&store, &session_id, &run_id, cursor.as_ref())
172            }
173            ParsedReplayScope::Session(session_id) => {
174                replay_session_display(&store, session_id, cursor.as_ref())
175            }
176        }
177    }
178
179    async fn append_snapshot(
180        &self,
181        scope: ReplayScope,
182        snapshot: ReplaySnapshot,
183    ) -> ReplayResult<()> {
184        let store = self.open_store()?;
185        store
186            .conn
187            .execute(
188                "INSERT OR REPLACE INTO replay_snapshots (scope, snapshot_json, updated_at)
189                 VALUES (?1, ?2, ?3)",
190                params![
191                    scope.as_str(),
192                    serde_json::to_string(&snapshot).map_err(replay_failed)?,
193                    Utc::now().to_rfc3339()
194                ],
195            )
196            .map_err(replay_failed)?;
197        Ok(())
198    }
199
200    async fn latest_snapshot(&self, scope: &ReplayScope) -> ReplayResult<Option<ReplaySnapshot>> {
201        let store = self.open_store()?;
202        store
203            .conn
204            .query_row(
205                "SELECT snapshot_json FROM replay_snapshots WHERE scope = ?1",
206                params![scope.as_str()],
207                |row| row.get::<_, String>(0),
208            )
209            .optional()
210            .map_err(replay_failed)?
211            .map(|json| serde_json::from_str(&json).map_err(replay_failed))
212            .transpose()
213    }
214
215    async fn cursor_range(
216        &self,
217        scope: &ReplayScope,
218    ) -> ReplayResult<Option<(ReplayCursor, ReplayCursor)>> {
219        let store = self.open_store()?;
220        match parse_scope(scope)? {
221            ParsedReplayScope::Run(run_id) => {
222                let (session_id, run_id) = storage_run_ref_for_scope(&store, run_id)?;
223                run_cursor_range(&store, scope, &session_id, &run_id)
224            }
225            ParsedReplayScope::Session(session_id) => {
226                session_cursor_range(&store, scope, session_id)
227            }
228        }
229    }
230}
231
232fn storage_run_ref_for_scope(store: &LocalStore, run_id: &str) -> ReplayResult<(SessionId, RunId)> {
233    let mut stmt = store
234        .conn
235        .prepare("SELECT session_id FROM runs WHERE run_id = ?1 ORDER BY updated_at DESC LIMIT 2")
236        .map_err(replay_failed)?;
237    let session_ids = stmt
238        .query_map(params![run_id], |row| row.get::<_, String>(0))
239        .map_err(replay_failed)?
240        .collect::<Result<Vec<_>, _>>()
241        .map_err(replay_failed)?;
242    match session_ids.as_slice() {
243        [] => Err(ReplayError::NotFound(format!("run:{run_id}"))),
244        [session_id] => Ok((
245            SessionId::from_string(session_id.clone()),
246            RunId::from_string(run_id.to_string()),
247        )),
248        _ => Err(ReplayError::Failed(format!(
249            "run scope run:{run_id} is ambiguous across multiple sessions"
250        ))),
251    }
252}
253
254fn validate_run_scoped_display_messages(
255    storage_session_id: &SessionId,
256    messages: &[DisplayMessage],
257) -> ReplayResult<()> {
258    for (index, message) in messages.iter().enumerate() {
259        if message.session_id.as_str() != storage_session_id.as_str() {
260            return Err(ReplayError::Failed(format!(
261                "display message at index {index} has session_id {}, but run scope belongs to session_id {}",
262                message.session_id.as_str(),
263                storage_session_id.as_str()
264            )));
265        }
266    }
267    Ok(())
268}
269
270fn validate_session_scoped_display_messages(
271    store: &LocalStore,
272    session_id: &str,
273    messages: &[DisplayMessage],
274) -> ReplayResult<()> {
275    for (index, message) in messages.iter().enumerate() {
276        if message.session_id.as_str() != session_id {
277            return Err(ReplayError::Failed(format!(
278                "display message at index {index} has session_id {}, but session scope is session:{session_id}",
279                message.session_id.as_str()
280            )));
281        }
282        let run_exists = store
283            .conn
284            .query_row(
285                "SELECT EXISTS(SELECT 1 FROM runs WHERE session_id = ?1 AND run_id = ?2)",
286                params![session_id, message.run_id.as_str()],
287                |row| row.get::<_, i64>(0),
288            )
289            .map_err(replay_failed)?
290            != 0;
291        if !run_exists {
292            return Err(ReplayError::Failed(format!(
293                "display message at index {index} has run_id {}, which is not a run in session scope session:{session_id}",
294                message.run_id.as_str()
295            )));
296        }
297    }
298    Ok(())
299}
300
301fn replay_run_display(
302    store: &LocalStore,
303    session_id: &SessionId,
304    run_id: &RunId,
305    cursor: Option<&ReplayCursor>,
306) -> ReplayResult<Vec<DisplayMessage>> {
307    let after = cursor.map_or(0, |cursor| cursor.sequence.saturating_add(1));
308    let mut stmt = store
309        .conn
310        .prepare(
311            r"
312            SELECT message_json
313            FROM display_messages
314            WHERE session_id = ?1 AND run_id = ?2 AND sequence_no >= ?3
315            ORDER BY sequence_no ASC
316            ",
317        )
318        .map_err(replay_failed)?;
319    let rows = stmt
320        .query_map(
321            params![
322                session_id.as_str(),
323                run_id.as_str(),
324                i64::try_from(after).map_err(replay_failed)?
325            ],
326            |row| row.get::<_, String>(0),
327        )
328        .map_err(replay_failed)?;
329    collect_display_messages(rows)
330}
331
332fn replay_session_display(
333    store: &LocalStore,
334    session_id: &str,
335    cursor: Option<&ReplayCursor>,
336) -> ReplayResult<Vec<DisplayMessage>> {
337    let after = cursor.map_or(0, |cursor| cursor.sequence.saturating_add(1));
338    let mut stmt = store
339        .conn
340        .prepare(
341            r"
342            SELECT dm.message_json
343            FROM display_messages dm
344            JOIN runs r ON r.session_id = dm.session_id AND r.run_id = dm.run_id
345            WHERE dm.session_id = ?1
346            ORDER BY r.sequence_no ASC, dm.sequence_no ASC
347            ",
348        )
349        .map_err(replay_failed)?;
350    let rows = stmt
351        .query_map(params![session_id], |row| row.get::<_, String>(0))
352        .map_err(replay_failed)?;
353    let messages = collect_display_messages(rows)?;
354    Ok(messages
355        .into_iter()
356        .enumerate()
357        .filter_map(|(sequence, message)| (sequence >= after).then_some(message))
358        .collect())
359}
360
361fn collect_display_messages(
362    rows: rusqlite::MappedRows<'_, impl FnMut(&rusqlite::Row<'_>) -> rusqlite::Result<String>>,
363) -> ReplayResult<Vec<DisplayMessage>> {
364    rows.collect::<Result<Vec<_>, _>>()
365        .map_err(replay_failed)?
366        .into_iter()
367        .map(|json| serde_json::from_str(&json).map_err(replay_failed))
368        .collect()
369}
370
371fn run_cursor_range(
372    store: &LocalStore,
373    scope: &ReplayScope,
374    session_id: &SessionId,
375    run_id: &RunId,
376) -> ReplayResult<Option<(ReplayCursor, ReplayCursor)>> {
377    let range = store
378        .conn
379        .query_row(
380            "SELECT MIN(sequence_no), MAX(sequence_no) FROM display_messages WHERE session_id = ?1 AND run_id = ?2",
381            params![session_id.as_str(), run_id.as_str()],
382            |row| Ok((row.get::<_, Option<i64>>(0)?, row.get::<_, Option<i64>>(1)?)),
383        )
384        .map_err(replay_failed)?;
385    let (Some(first), Some(last)) = range else {
386        return Ok(None);
387    };
388    Ok(Some((
389        ReplayCursor::new(
390            scope.clone(),
391            usize::try_from(first).map_err(replay_failed)?,
392        ),
393        ReplayCursor::new(scope.clone(), usize::try_from(last).map_err(replay_failed)?),
394    )))
395}
396
397fn session_cursor_range(
398    store: &LocalStore,
399    scope: &ReplayScope,
400    session_id: &str,
401) -> ReplayResult<Option<(ReplayCursor, ReplayCursor)>> {
402    let count = store
403        .conn
404        .query_row(
405            "SELECT COUNT(*) FROM display_messages WHERE session_id = ?1",
406            params![session_id],
407            |row| row.get::<_, i64>(0),
408        )
409        .map_err(replay_failed)?;
410    let count = usize::try_from(count).map_err(replay_failed)?;
411    if count == 0 {
412        return Ok(None);
413    }
414    Ok(Some((
415        ReplayCursor::new(scope.clone(), 0),
416        ReplayCursor::new(scope.clone(), count.saturating_sub(1)),
417    )))
418}
419
420fn parse_scope(scope: &ReplayScope) -> ReplayResult<ParsedReplayScope<'_>> {
421    if let Some(run_id) = scope.as_str().strip_prefix("run:") {
422        return Ok(ParsedReplayScope::Run(run_id));
423    }
424    if let Some(session_id) = scope.as_str().strip_prefix("session:") {
425        return Ok(ParsedReplayScope::Session(session_id));
426    }
427    Err(ReplayError::InvalidCursor(format!(
428        "unsupported replay scope {}",
429        scope.as_str()
430    )))
431}
432
433fn replay_failed(error: impl std::fmt::Display) -> ReplayError {
434    ReplayError::Failed(error.to_string())
435}