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::{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/// Shared stream archive adapter backed by the CLI local `SQLite` store.
20#[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    /// Create a local stream archive adapter from resolved CLI config.
32    #[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    /// Replay display messages as scoped replay events for local RPC and TUI hosts.
42    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}