Skip to main content

starweaver_cli/local_store/
session_store.rs

1//! `SessionStore` adapter over the CLI local store.
2
3use async_trait::async_trait;
4use chrono::Utc;
5use rusqlite::{OptionalExtension, TransactionBehavior, params};
6use starweaver_context::ResumableState;
7use starweaver_core::{CheckpointId, RunId, SessionId};
8use starweaver_runtime::{AgentCheckpoint, AgentStreamRecord};
9use starweaver_session::{
10    ApprovalRecord, CheckpointRef, CompactRunTrace, CompactSessionTrace, DeferredToolRecord,
11    EnvironmentStateRef, RunRecord, RunStatus, SessionFilter, SessionRecord, SessionStatus,
12    SessionStore, SessionStoreError, SessionStoreResult, StreamCursorRef,
13};
14
15use super::{
16    LocalStore,
17    db::{
18        insert_approval_records_tx, insert_deferred_tool_records_tx, insert_raw_stream_records_tx,
19        insert_stream_cursor_tx, load_session_tx, next_sequence_tx, upsert_run_tx,
20        upsert_session_tx,
21    },
22};
23use crate::{CliError, config::CliConfig};
24
25/// Shared session store adapter backed by the CLI local `SQLite` store.
26#[derive(Clone, Debug)]
27pub struct LocalSessionStore {
28    config: CliConfig,
29}
30
31impl LocalSessionStore {
32    /// Create a local session store adapter from resolved CLI config.
33    #[must_use]
34    pub const fn new(config: CliConfig) -> Self {
35        Self { config }
36    }
37
38    fn open_store(&self) -> SessionStoreResult<LocalStore> {
39        LocalStore::open(&self.config).map_err(session_failed_cli)
40    }
41}
42
43#[async_trait]
44impl SessionStore for LocalSessionStore {
45    async fn save_session(&self, mut session: SessionRecord) -> SessionStoreResult<()> {
46        session.updated_at = Utc::now();
47        let mut store = self.open_store()?;
48        let tx = store
49            .conn
50            .transaction_with_behavior(TransactionBehavior::Immediate)
51            .map_err(session_failed)?;
52        upsert_session_tx(&tx, &session).map_err(session_failed)?;
53        tx.commit().map_err(session_failed)
54    }
55
56    async fn load_session(&self, session_id: &SessionId) -> SessionStoreResult<SessionRecord> {
57        self.open_store()?
58            .load_session(session_id.as_str())
59            .map_err(session_failed_cli)
60    }
61
62    async fn list_sessions(&self, filter: SessionFilter) -> SessionStoreResult<Vec<SessionRecord>> {
63        let store = self.open_store()?;
64        let mut stmt = store
65            .conn
66            .prepare("SELECT record_json FROM sessions ORDER BY updated_at DESC")
67            .map_err(session_failed)?;
68        let rows = stmt
69            .query_map([], |row| row.get::<_, String>(0))
70            .map_err(session_failed)?;
71        let mut sessions = Vec::new();
72        for row in rows {
73            let session: SessionRecord =
74                serde_json::from_str(&row.map_err(session_failed)?).map_err(session_failed)?;
75            if filter.status.is_some_and(|status| session.status != status) {
76                continue;
77            }
78            if filter
79                .profile
80                .as_ref()
81                .is_some_and(|profile| session.profile.as_ref() != Some(profile))
82            {
83                continue;
84            }
85            if filter
86                .workspace
87                .as_ref()
88                .is_some_and(|workspace| session.workspace.as_ref() != Some(workspace))
89            {
90                continue;
91            }
92            sessions.push(session);
93            if filter.limit.is_some_and(|limit| sessions.len() >= limit) {
94                break;
95            }
96        }
97        Ok(sessions)
98    }
99
100    async fn update_session_status(
101        &self,
102        session_id: &SessionId,
103        status: SessionStatus,
104    ) -> SessionStoreResult<()> {
105        let mut session = self.load_session(session_id).await?;
106        session.status = status;
107        self.save_session(session).await
108    }
109
110    async fn save_context_state(
111        &self,
112        session_id: &SessionId,
113        state: ResumableState,
114    ) -> SessionStoreResult<()> {
115        let mut session = self.load_session(session_id).await?;
116        session.state = state;
117        self.save_session(session).await
118    }
119
120    async fn save_environment_state(
121        &self,
122        session_id: &SessionId,
123        environment_state: EnvironmentStateRef,
124    ) -> SessionStoreResult<()> {
125        let mut session = self.load_session(session_id).await?;
126        session.environment_state = Some(environment_state);
127        self.save_session(session).await
128    }
129
130    async fn append_run(&self, mut run: RunRecord) -> SessionStoreResult<()> {
131        let mut store = self.open_store()?;
132        let tx = store
133            .conn
134            .transaction_with_behavior(TransactionBehavior::Immediate)
135            .map_err(session_failed)?;
136        let mut session = load_session_tx(&tx, run.session_id.as_str()).map_err(session_failed)?;
137        run.updated_at = Utc::now();
138        if let Some(existing_sequence) =
139            existing_run_sequence(&tx, run.session_id.as_str(), run.run_id.as_str())?
140        {
141            run.sequence_no = existing_sequence;
142        } else if run.sequence_no == 0
143            || sequence_exists(&tx, run.session_id.as_str(), run.sequence_no)?
144        {
145            run.sequence_no =
146                next_sequence_tx(&tx, run.session_id.as_str()).map_err(session_failed)?;
147        }
148        apply_run_to_session(&mut session, &run);
149        upsert_run_tx(&tx, &run).map_err(session_failed)?;
150        upsert_session_tx(&tx, &session).map_err(session_failed)?;
151        tx.commit().map_err(session_failed)
152    }
153
154    async fn load_run(
155        &self,
156        session_id: &SessionId,
157        run_id: &RunId,
158    ) -> SessionStoreResult<RunRecord> {
159        self.open_store()?
160            .load_run(session_id.as_str(), run_id.as_str())
161            .map_err(session_failed_cli)
162    }
163
164    async fn list_runs(&self, session_id: &SessionId) -> SessionStoreResult<Vec<RunRecord>> {
165        let store = self.open_store()?;
166        let mut stmt = store
167            .conn
168            .prepare("SELECT record_json FROM runs WHERE session_id = ?1 ORDER BY sequence_no ASC")
169            .map_err(session_failed)?;
170        let rows = stmt
171            .query_map(params![session_id.as_str()], |row| row.get::<_, String>(0))
172            .map_err(session_failed)?;
173        let runs = collect_json_records(rows)?;
174        Ok(runs)
175    }
176
177    async fn update_run_status(
178        &self,
179        session_id: &SessionId,
180        run_id: &RunId,
181        status: RunStatus,
182        output_preview: Option<String>,
183    ) -> SessionStoreResult<()> {
184        let mut run = self.load_run(session_id, run_id).await?;
185        run.status = status;
186        run.output_preview = output_preview;
187        run.updated_at = Utc::now();
188        self.append_run(run).await
189    }
190
191    async fn append_checkpoint(
192        &self,
193        session_id: &SessionId,
194        checkpoint: AgentCheckpoint,
195    ) -> SessionStoreResult<()> {
196        let mut store = self.open_store()?;
197        let tx = store
198            .conn
199            .transaction_with_behavior(TransactionBehavior::Immediate)
200            .map_err(session_failed)?;
201        let checkpoint_id = checkpoint.checkpoint_id.clone();
202        let checkpoint_run_id = checkpoint.run_id.clone();
203        let checkpoint_node = checkpoint.node;
204        let checkpoint_node_label = format!("{checkpoint_node:?}");
205        let checkpoint_sequence = checkpoint.run_step;
206        let stream_cursor = checkpoint.resume.cursor.stream_cursor;
207        let checkpoint_metadata = checkpoint.metadata.clone();
208        let mut run = load_run_tx(&tx, session_id.as_str(), checkpoint_run_id.as_str())?;
209        tx.execute(
210            "INSERT OR REPLACE INTO checkpoints
211             (checkpoint_id, session_id, run_id, sequence_no, node, checkpoint_json, created_at)
212             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
213            params![
214                checkpoint_id.as_str(),
215                session_id.as_str(),
216                checkpoint_run_id.as_str(),
217                i64::try_from(checkpoint_sequence).map_err(session_failed)?,
218                checkpoint_node_label,
219                serde_json::to_string(&checkpoint).map_err(session_failed)?,
220                Utc::now().to_rfc3339(),
221            ],
222        )
223        .map_err(session_failed)?;
224        run.latest_checkpoint = Some(CheckpointRef {
225            checkpoint_id,
226            run_id: checkpoint_run_id,
227            sequence: checkpoint_sequence,
228            node: format!("{checkpoint_node:?}"),
229            storage_ref: None,
230            stream_cursor,
231            created_at: Utc::now(),
232            metadata: checkpoint_metadata,
233        });
234        run.updated_at = Utc::now();
235        upsert_run_tx(&tx, &run).map_err(session_failed)?;
236        tx.commit().map_err(session_failed)
237    }
238
239    async fn load_checkpoints(
240        &self,
241        session_id: &SessionId,
242        run_id: &RunId,
243    ) -> SessionStoreResult<Vec<AgentCheckpoint>> {
244        let store = self.open_store()?;
245        let mut stmt = store
246            .conn
247            .prepare(
248                "SELECT checkpoint_json FROM checkpoints
249                 WHERE session_id = ?1 AND run_id = ?2
250                 ORDER BY sequence_no ASC, checkpoint_id ASC",
251            )
252            .map_err(session_failed)?;
253        let rows = stmt
254            .query_map(params![session_id.as_str(), run_id.as_str()], |row| {
255                row.get::<_, String>(0)
256            })
257            .map_err(session_failed)?;
258        let mut checkpoints = Vec::new();
259        for row in rows {
260            let json = row.map_err(session_failed)?;
261            if let Ok(checkpoint) = serde_json::from_str::<AgentCheckpoint>(&json) {
262                checkpoints.push(checkpoint);
263            }
264        }
265        Ok(checkpoints)
266    }
267
268    async fn append_stream_records(
269        &self,
270        session_id: &SessionId,
271        run_id: &RunId,
272        records: Vec<AgentStreamRecord>,
273    ) -> SessionStoreResult<()> {
274        let mut store = self.open_store()?;
275        let tx = store
276            .conn
277            .transaction_with_behavior(TransactionBehavior::Immediate)
278            .map_err(session_failed)?;
279        let mut run = load_run_tx(&tx, session_id.as_str(), run_id.as_str())?;
280        insert_raw_stream_records_tx(&tx, &run, &records).map_err(session_failed)?;
281        if let Some(sequence) = latest_raw_sequence(&tx, session_id.as_str(), run_id.as_str())? {
282            let cursor =
283                StreamCursorRef::new("raw_runtime", format!("run:{}", run_id.as_str()), sequence);
284            run.stream_cursors
285                .retain(|existing| existing.family != cursor.family);
286            run.stream_cursors.push(cursor.clone());
287            run.updated_at = Utc::now();
288            upsert_run_tx(&tx, &run).map_err(session_failed)?;
289            let mut session = load_session_tx(&tx, session_id.as_str()).map_err(session_failed)?;
290            upsert_session_cursor(&mut session, cursor);
291            upsert_session_tx(&tx, &session).map_err(session_failed)?;
292        }
293        tx.commit().map_err(session_failed)
294    }
295
296    async fn replay_stream_records(
297        &self,
298        session_id: &SessionId,
299        run_id: &RunId,
300    ) -> SessionStoreResult<Vec<AgentStreamRecord>> {
301        self.replay_stream_records_after(session_id, run_id, None)
302            .await
303    }
304
305    async fn replay_stream_records_after(
306        &self,
307        session_id: &SessionId,
308        run_id: &RunId,
309        after_sequence: Option<usize>,
310    ) -> SessionStoreResult<Vec<AgentStreamRecord>> {
311        let after = after_sequence.map_or(-1_i64, |value| i64::try_from(value).unwrap_or(i64::MAX));
312        let store = self.open_store()?;
313        let mut stmt = store
314            .conn
315            .prepare(
316                "SELECT record_json FROM raw_stream_records
317                 WHERE session_id = ?1 AND run_id = ?2 AND sequence_no > ?3
318                 ORDER BY sequence_no ASC",
319            )
320            .map_err(session_failed)?;
321        let rows = stmt
322            .query_map(
323                params![session_id.as_str(), run_id.as_str(), after],
324                |row| row.get::<_, String>(0),
325            )
326            .map_err(session_failed)?;
327        let records = collect_json_records(rows)?;
328        Ok(records)
329    }
330
331    async fn save_stream_cursor(
332        &self,
333        session_id: &SessionId,
334        run_id: &RunId,
335        cursor: StreamCursorRef,
336    ) -> SessionStoreResult<()> {
337        let mut store = self.open_store()?;
338        let tx = store
339            .conn
340            .transaction_with_behavior(TransactionBehavior::Immediate)
341            .map_err(session_failed)?;
342        let mut run = load_run_tx(&tx, session_id.as_str(), run_id.as_str())?;
343        run.stream_cursors
344            .retain(|existing| existing.family != cursor.family || existing.scope != cursor.scope);
345        run.stream_cursors.push(cursor.clone());
346        run.updated_at = Utc::now();
347        upsert_run_tx(&tx, &run).map_err(session_failed)?;
348        let mut session = load_session_tx(&tx, session_id.as_str()).map_err(session_failed)?;
349        upsert_session_cursor(&mut session, cursor.clone());
350        upsert_session_tx(&tx, &session).map_err(session_failed)?;
351        insert_stream_cursor_tx(&tx, &run, &cursor).map_err(session_failed)?;
352        tx.commit().map_err(session_failed)
353    }
354
355    async fn append_approval(&self, approval: ApprovalRecord) -> SessionStoreResult<()> {
356        let mut store = self.open_store()?;
357        let tx = store
358            .conn
359            .transaction_with_behavior(TransactionBehavior::Immediate)
360            .map_err(session_failed)?;
361        insert_approval_records_tx(&tx, &[approval]).map_err(session_failed)?;
362        tx.commit().map_err(session_failed)
363    }
364
365    async fn load_approvals(
366        &self,
367        session_id: &SessionId,
368        run_id: &RunId,
369    ) -> SessionStoreResult<Vec<ApprovalRecord>> {
370        self.open_store()?
371            .list_approvals(Some(session_id.as_str()), Some(run_id.as_str()))
372            .map_err(session_failed_cli)
373    }
374
375    async fn append_deferred_tool(&self, record: DeferredToolRecord) -> SessionStoreResult<()> {
376        let mut store = self.open_store()?;
377        let tx = store
378            .conn
379            .transaction_with_behavior(TransactionBehavior::Immediate)
380            .map_err(session_failed)?;
381        insert_deferred_tool_records_tx(&tx, &[record]).map_err(session_failed)?;
382        tx.commit().map_err(session_failed)
383    }
384
385    async fn load_deferred_tools(
386        &self,
387        session_id: &SessionId,
388        run_id: &RunId,
389    ) -> SessionStoreResult<Vec<DeferredToolRecord>> {
390        self.open_store()?
391            .list_deferred_tools(Some(session_id.as_str()), Some(run_id.as_str()))
392            .map_err(session_failed_cli)
393    }
394
395    async fn compact_run_trace(
396        &self,
397        session_id: &SessionId,
398        run_id: &RunId,
399    ) -> SessionStoreResult<CompactRunTrace> {
400        let store = self.open_store()?;
401        let run = store
402            .load_run(session_id.as_str(), run_id.as_str())
403            .map_err(session_failed_cli)?;
404        Ok(CompactRunTrace {
405            session_id: Some(session_id.clone()),
406            run_id: Some(run_id.clone()),
407            status: run.status,
408            parent_run_id: run.parent_run_id.clone(),
409            parent_task_id: run.parent_task_id.clone(),
410            checkpoints: checkpoint_ids(&store, session_id.as_str(), run_id.as_str())?,
411            approvals: pending_approval_count(&store, session_id.as_str(), run_id.as_str())?,
412            deferred_tools: pending_deferred_count(&store, session_id.as_str(), run_id.as_str())?,
413            latest_checkpoint: run
414                .latest_checkpoint
415                .as_ref()
416                .map(|checkpoint| checkpoint.checkpoint_id.clone()),
417            stream_cursor: latest_raw_sequence_ref(&store, session_id.as_str(), run_id.as_str())?,
418            stream_cursors: run.stream_cursors,
419            output_preview: run.output_preview,
420            trace_context: run.trace_context,
421            updated_at: Some(run.updated_at),
422            metadata: run.metadata,
423        })
424    }
425
426    async fn compact_session_trace(
427        &self,
428        session_id: &SessionId,
429    ) -> SessionStoreResult<CompactSessionTrace> {
430        let session = self.load_session(session_id).await?;
431        let runs = self.list_runs(session_id).await?;
432        let latest_run = runs.last();
433        Ok(CompactSessionTrace {
434            session_id: session.session_id,
435            title: session.title,
436            workspace: session.workspace,
437            profile: session.profile,
438            status: session.status,
439            runs: runs.len(),
440            latest_run_id: latest_run.map(|run| run.run_id.clone()),
441            last_output_preview: latest_run.and_then(|run| run.output_preview.clone()),
442            stream_cursors: session.stream_cursors,
443            trace_context: session.trace_context,
444            created_at: session.created_at,
445            updated_at: session.updated_at,
446            metadata: session.metadata,
447        })
448    }
449}
450
451fn load_run_tx(
452    tx: &rusqlite::Transaction<'_>,
453    session_id: &str,
454    run_id: &str,
455) -> SessionStoreResult<RunRecord> {
456    tx.query_row(
457        "SELECT record_json FROM runs WHERE session_id = ?1 AND run_id = ?2",
458        params![session_id, run_id],
459        |row| row.get::<_, String>(0),
460    )
461    .optional()
462    .map_err(session_failed)?
463    .map(|json| serde_json::from_str(&json).map_err(session_failed))
464    .transpose()?
465    .ok_or_else(|| SessionStoreError::NotFound(format!("{session_id}:{run_id}")))
466}
467
468fn existing_run_sequence(
469    tx: &rusqlite::Transaction<'_>,
470    session_id: &str,
471    run_id: &str,
472) -> SessionStoreResult<Option<usize>> {
473    tx.query_row(
474        "SELECT sequence_no FROM runs WHERE session_id = ?1 AND run_id = ?2",
475        params![session_id, run_id],
476        |row| row.get::<_, i64>(0),
477    )
478    .optional()
479    .map_err(session_failed)?
480    .map(|value| usize::try_from(value).map_err(session_failed))
481    .transpose()
482}
483
484fn sequence_exists(
485    tx: &rusqlite::Transaction<'_>,
486    session_id: &str,
487    sequence_no: usize,
488) -> SessionStoreResult<bool> {
489    let count = tx
490        .query_row(
491            "SELECT COUNT(*) FROM runs WHERE session_id = ?1 AND sequence_no = ?2",
492            params![
493                session_id,
494                i64::try_from(sequence_no).map_err(session_failed)?
495            ],
496            |row| row.get::<_, i64>(0),
497        )
498        .map_err(session_failed)?;
499    Ok(count > 0)
500}
501
502fn apply_run_to_session(session: &mut SessionRecord, run: &RunRecord) {
503    session.profile.clone_from(&run.profile);
504    session.head_run_id = Some(run.run_id.clone());
505    if run.status == RunStatus::Completed {
506        session.head_success_run_id = Some(run.run_id.clone());
507    }
508    if matches!(
509        run.status,
510        RunStatus::Queued | RunStatus::Running | RunStatus::Waiting
511    ) {
512        session.active_run_id = Some(run.run_id.clone());
513    } else if session.active_run_id.as_ref() == Some(&run.run_id) {
514        session.active_run_id = None;
515    }
516    session.updated_at = run.updated_at;
517}
518
519fn upsert_session_cursor(session: &mut SessionRecord, cursor: StreamCursorRef) {
520    session
521        .stream_cursors
522        .retain(|existing| existing.family != cursor.family || existing.scope != cursor.scope);
523    session.stream_cursors.push(cursor);
524    session.updated_at = Utc::now();
525}
526
527fn latest_raw_sequence(
528    tx: &rusqlite::Transaction<'_>,
529    session_id: &str,
530    run_id: &str,
531) -> SessionStoreResult<Option<usize>> {
532    tx.query_row(
533        "SELECT MAX(sequence_no) FROM raw_stream_records WHERE session_id = ?1 AND run_id = ?2",
534        params![session_id, run_id],
535        |row| row.get::<_, Option<i64>>(0),
536    )
537    .map_err(session_failed)?
538    .map(|value| usize::try_from(value).map_err(session_failed))
539    .transpose()
540}
541
542fn latest_raw_sequence_ref(
543    store: &LocalStore,
544    session_id: &str,
545    run_id: &str,
546) -> SessionStoreResult<Option<usize>> {
547    store
548        .conn
549        .query_row(
550            "SELECT MAX(sequence_no) FROM raw_stream_records WHERE session_id = ?1 AND run_id = ?2",
551            params![session_id, run_id],
552            |row| row.get::<_, Option<i64>>(0),
553        )
554        .map_err(session_failed)?
555        .map(|value| usize::try_from(value).map_err(session_failed))
556        .transpose()
557}
558
559fn checkpoint_ids(
560    store: &LocalStore,
561    session_id: &str,
562    run_id: &str,
563) -> SessionStoreResult<Vec<CheckpointId>> {
564    let mut stmt = store
565        .conn
566        .prepare(
567            "SELECT checkpoint_id FROM checkpoints
568             WHERE session_id = ?1 AND run_id = ?2
569             ORDER BY sequence_no ASC, checkpoint_id ASC",
570        )
571        .map_err(session_failed)?;
572    let rows = stmt
573        .query_map(params![session_id, run_id], |row| row.get::<_, String>(0))
574        .map_err(session_failed)?;
575    rows.collect::<Result<Vec<_>, _>>()
576        .map_err(session_failed)
577        .map(|ids| ids.into_iter().map(CheckpointId::from_string).collect())
578}
579
580fn pending_approval_count(
581    store: &LocalStore,
582    session_id: &str,
583    run_id: &str,
584) -> SessionStoreResult<usize> {
585    count_rows(
586        store,
587        "SELECT COUNT(*) FROM approvals WHERE session_id = ?1 AND run_id = ?2 AND status = 'pending'",
588        session_id,
589        run_id,
590    )
591}
592
593fn pending_deferred_count(
594    store: &LocalStore,
595    session_id: &str,
596    run_id: &str,
597) -> SessionStoreResult<usize> {
598    count_rows(
599        store,
600        "SELECT COUNT(*) FROM deferred_tools
601         WHERE session_id = ?1 AND run_id = ?2
602           AND status IN ('pending', 'running', 'waiting')",
603        session_id,
604        run_id,
605    )
606}
607
608fn count_rows(
609    store: &LocalStore,
610    sql: &str,
611    session_id: &str,
612    run_id: &str,
613) -> SessionStoreResult<usize> {
614    let count = store
615        .conn
616        .query_row(sql, params![session_id, run_id], |row| row.get::<_, i64>(0))
617        .map_err(session_failed)?;
618    usize::try_from(count).map_err(session_failed)
619}
620
621fn collect_json_records<T>(
622    rows: rusqlite::MappedRows<'_, impl FnMut(&rusqlite::Row<'_>) -> rusqlite::Result<String>>,
623) -> SessionStoreResult<Vec<T>>
624where
625    T: serde::de::DeserializeOwned,
626{
627    rows.collect::<Result<Vec<_>, _>>()
628        .map_err(session_failed)?
629        .into_iter()
630        .map(|json| serde_json::from_str(&json).map_err(session_failed))
631        .collect()
632}
633
634fn session_failed(error: impl std::fmt::Display) -> SessionStoreError {
635    SessionStoreError::Failed(error.to_string())
636}
637
638fn session_failed_cli(error: CliError) -> SessionStoreError {
639    match error {
640        CliError::NotFound(id) => SessionStoreError::NotFound(id),
641        error => SessionStoreError::Failed(error.to_string()),
642    }
643}