Skip to main content

starweaver_cli/
local_store.rs

1//! Local `SQLite` and file-store persistence for CLI sessions.
2
3use std::{collections::BTreeSet, fs, path::PathBuf, time::Duration};
4
5use chrono::Utc;
6use rusqlite::{params, Connection, OptionalExtension, TransactionBehavior};
7use serde::Serialize;
8use serde_json::Value;
9use starweaver_agent::ResumableState;
10use starweaver_core::{ConversationId, RunId, SessionId};
11use starweaver_environment::EnvironmentState;
12use starweaver_model::{ModelMessage, ModelRequest, ModelRequestPart, ToolReturnPart};
13use starweaver_runtime::{AgentStreamEvent, AgentStreamRecord};
14use starweaver_session::{
15    ApprovalDecision, ApprovalRecord, ApprovalStatus, DeferredToolRecord, EnvironmentStateRef,
16    ExecutionStatus, InputPart, RunRecord, RunStatus, SessionRecord, StreamCursorRef,
17};
18use starweaver_stream::{DisplayMessage, ReplaySnapshot};
19use uuid::Uuid;
20
21use crate::{config::CliConfig, error::io_error, CliError, CliResult};
22
23mod archive;
24mod db;
25mod hitl;
26mod replay;
27mod schema;
28mod session_store;
29
30pub use archive::LocalStreamArchive;
31use db::{
32    atomic_write_json, cheap_checksum, checkpoint_refs, i64_to_usize, insert_approval_records_tx,
33    insert_checkpoint_refs_tx, insert_context_state_tx, insert_deferred_tool_records_tx,
34    insert_display_messages_tx, insert_environment_state_tx, insert_file_ref_tx,
35    insert_raw_stream_records_tx, insert_stream_cursor_tx, load_session_tx, next_sequence_tx,
36    upsert_run_tx, upsert_session_tx, usize_to_i64,
37};
38use hitl::{
39    approval_tool_return, deferred_status_is_unresolved, deferred_tool_return,
40    existing_resume_tool_return_ids, latest_tool_call_order, pending_hitl_resume_error,
41    tool_return_control_flow,
42};
43pub use replay::DisplayReplayWindow;
44pub use session_store::LocalSessionStore;
45
46/// Local `SQLite` and file-store handle.
47pub struct LocalStore {
48    conn: Connection,
49    file_store_path: PathBuf,
50}
51
52pub struct FileRefRecord {
53    pub(super) ref_id: String,
54    pub(super) relative_path: String,
55    pub(super) byte_size: i64,
56    pub(super) checksum: String,
57    pub(super) content_type: String,
58    pub(super) created_at: String,
59}
60
61/// Durable artifacts captured when a CLI run finishes or waits.
62pub struct RunArtifacts {
63    /// Final context state.
64    pub state: ResumableState,
65    /// Environment state snapshot.
66    pub environment_state: Option<EnvironmentState>,
67    /// Raw runtime records.
68    pub raw_records: Vec<AgentStreamRecord>,
69    /// Display messages.
70    pub display_messages: Vec<DisplayMessage>,
71    /// Compact display snapshot.
72    pub display_snapshot: ReplaySnapshot,
73    /// Approval records.
74    pub approvals: Vec<ApprovalRecord>,
75    /// Deferred tool records.
76    pub deferred_tools: Vec<DeferredToolRecord>,
77    /// Terminal status selected by HITL and runtime policy.
78    pub status: RunStatus,
79}
80
81/// Session summary row.
82#[derive(Clone, Debug, Serialize)]
83pub struct SessionSummary {
84    /// Session id.
85    pub session_id: String,
86    /// Title.
87    pub title: Option<String>,
88    /// Profile.
89    pub profile: Option<String>,
90    /// Status.
91    pub status: String,
92    /// Head run id.
93    pub head_run_id: Option<String>,
94    /// Head successful run id.
95    pub head_success_run_id: Option<String>,
96    /// Active run id.
97    pub active_run_id: Option<String>,
98    /// Run count.
99    pub run_count: usize,
100    /// Last output preview.
101    pub last_output_preview: Option<String>,
102    /// Creation time.
103    pub created_at: String,
104    /// Last update time.
105    pub updated_at: String,
106}
107
108/// Run summary row.
109#[derive(Clone, Debug, Serialize)]
110pub struct RunSummary {
111    /// Run id.
112    pub run_id: String,
113    /// Sequence number.
114    pub sequence_no: usize,
115    /// Run status.
116    pub status: String,
117    /// Restore source run id.
118    pub restore_from_run_id: Option<String>,
119    /// Output preview.
120    pub output_preview: Option<String>,
121    /// Creation time.
122    pub created_at: String,
123    /// Last update time.
124    pub updated_at: String,
125}
126
127/// Trim report.
128#[derive(Clone, Debug, Default, Serialize)]
129pub struct TrimReport {
130    /// Sessions scanned.
131    pub sessions_scanned: usize,
132    /// Runs selected for trimming.
133    pub runs_to_trim: usize,
134    /// Runs trimmed.
135    pub runs_trimmed: usize,
136    /// Bytes reclaimed from file store.
137    pub bytes_reclaimed: u64,
138    /// Dry-run flag.
139    pub dry_run: bool,
140}
141
142impl LocalStore {
143    /// Open a local store and initialize schema.
144    pub fn open(config: &CliConfig) -> CliResult<Self> {
145        crate::config::ensure_config_dirs(config)?;
146        let conn = Connection::open(&config.database_path)?;
147        conn.busy_timeout(Duration::from_secs(10))?;
148        conn.pragma_update(None, "journal_mode", "WAL")?;
149        conn.pragma_update(None, "foreign_keys", "ON")?;
150        conn.pragma_update(None, "synchronous", "NORMAL")?;
151        let store = Self {
152            conn,
153            file_store_path: config.file_store_path.clone(),
154        };
155        store.init_schema()?;
156        Ok(store)
157    }
158
159    /// Create or load a session.
160    pub fn create_session(
161        &mut self,
162        profile: &str,
163        title: Option<String>,
164    ) -> CliResult<SessionRecord> {
165        let tx = self
166            .conn
167            .transaction_with_behavior(TransactionBehavior::Immediate)?;
168        let session_id = SessionId::from_string(format!("session_{}", Uuid::new_v4()));
169        let mut session = SessionRecord::new(session_id);
170        session.profile = Some(profile.to_string());
171        session.title = title;
172        upsert_session_tx(&tx, &session)?;
173        tx.commit()?;
174        Ok(session)
175    }
176
177    /// Load a session.
178    pub fn load_session(&self, session_id: &str) -> CliResult<SessionRecord> {
179        self.conn
180            .query_row(
181                "SELECT record_json FROM sessions WHERE session_id = ?1",
182                [session_id],
183                |row| row.get::<_, String>(0),
184            )
185            .optional()?
186            .map(|json| serde_json::from_str(&json).map_err(CliError::from))
187            .transpose()?
188            .ok_or_else(|| CliError::NotFound(session_id.to_string()))
189    }
190
191    /// Resolve a session id or unique session id prefix.
192    pub fn resolve_session_prefix(&self, session_id_or_prefix: &str) -> CliResult<String> {
193        if self.load_session(session_id_or_prefix).is_ok() {
194            return Ok(session_id_or_prefix.to_string());
195        }
196        let mut stmt = self.conn.prepare(
197            "SELECT session_id FROM sessions WHERE session_id LIKE ?1 ORDER BY updated_at DESC",
198        )?;
199        let rows = stmt.query_map([format!("{session_id_or_prefix}%")], |row| {
200            row.get::<_, String>(0)
201        })?;
202        let matches = rows.collect::<Result<Vec<_>, _>>()?;
203        match matches.as_slice() {
204            [session_id] => Ok(session_id.clone()),
205            [] => Err(CliError::NotFound(session_id_or_prefix.to_string())),
206            _ => Err(CliError::Usage(format!(
207                "session prefix '{session_id_or_prefix}' is ambiguous"
208            ))),
209        }
210    }
211
212    /// Delete one session and its retained evidence.
213    pub fn delete_session(&mut self, session_id: &str) -> CliResult<bool> {
214        self.load_session(session_id)?;
215        let path = self.file_store_path.join("sessions").join(session_id);
216        let tx = self
217            .conn
218            .transaction_with_behavior(TransactionBehavior::Immediate)?;
219        tx.execute(
220            "DELETE FROM replay_snapshots
221             WHERE scope = ?1
222                OR scope IN (SELECT 'run:' || run_id FROM runs WHERE session_id = ?2)",
223            params![format!("session:{session_id}"), session_id],
224        )?;
225        for table in [
226            "display_messages",
227            "raw_stream_records",
228            "context_states",
229            "environment_states",
230            "stream_cursors",
231            "checkpoints",
232            "approvals",
233            "deferred_tools",
234            "file_refs",
235            "runs",
236            "sessions",
237        ] {
238            tx.execute(
239                &format!("DELETE FROM {table} WHERE session_id = ?1"),
240                params![session_id],
241            )?;
242        }
243        tx.commit()?;
244        if path.exists() {
245            fs::remove_dir_all(&path).map_err(|error| io_error(&path, error))?;
246        }
247        Ok(true)
248    }
249
250    /// Load a run.
251    pub fn load_run(&self, session_id: &str, run_id: &str) -> CliResult<RunRecord> {
252        self.conn
253            .query_row(
254                "SELECT record_json FROM runs WHERE session_id = ?1 AND run_id = ?2",
255                params![session_id, run_id],
256                |row| row.get::<_, String>(0),
257            )
258            .optional()?
259            .map(|json| serde_json::from_str(&json).map_err(CliError::from))
260            .transpose()?
261            .ok_or_else(|| CliError::NotFound(run_id.to_string()))
262    }
263
264    /// Latest active session.
265    pub fn latest_session(&self) -> CliResult<Option<SessionRecord>> {
266        self.conn
267            .query_row(
268                "SELECT record_json FROM sessions WHERE status = 'active' ORDER BY updated_at DESC LIMIT 1",
269                [],
270                |row| row.get::<_, String>(0),
271            )
272            .optional()?
273            .map(|json| serde_json::from_str(&json).map_err(CliError::from))
274            .transpose()
275    }
276
277    /// Append a new queued run atomically and update session pointers.
278    pub fn append_run(
279        &mut self,
280        session_id: &str,
281        prompt: String,
282        restore_from_run_id: Option<String>,
283        profile: &str,
284    ) -> CliResult<RunRecord> {
285        let tx = self
286            .conn
287            .transaction_with_behavior(TransactionBehavior::Immediate)?;
288        let mut session = load_session_tx(&tx, session_id)?;
289        let sequence_no = next_sequence_tx(&tx, session_id)?;
290        let run_id = RunId::new();
291        let mut run = RunRecord::new(session.session_id.clone(), run_id, ConversationId::new());
292        run.sequence_no = sequence_no;
293        run.restore_from_run_id = restore_from_run_id.map(RunId::from_string);
294        run.trigger_type = Some("cli".to_string());
295        run.profile = Some(profile.to_string());
296        run.input = vec![InputPart::text(prompt)];
297        session.head_run_id = Some(run.run_id.clone());
298        session.active_run_id = Some(run.run_id.clone());
299        session.updated_at = Utc::now();
300        upsert_run_tx(&tx, &run)?;
301        upsert_session_tx(&tx, &session)?;
302        tx.commit()?;
303        Ok(run)
304    }
305
306    /// Complete or pause a run, persist display messages, archive stream blobs, and update pointers.
307    pub fn complete_run(
308        &mut self,
309        run: &mut RunRecord,
310        output: String,
311        artifacts: RunArtifacts,
312    ) -> CliResult<Vec<DisplayMessage>> {
313        let raw_ref = self.write_run_blob(run, "raw.stream.json", &artifacts.raw_records)?;
314        let display_ref =
315            self.write_run_blob(run, "display.compact.json", &artifacts.display_snapshot)?;
316        let state_ref = self.write_run_blob(run, "context.state.json", &artifacts.state)?;
317        let env_ref = artifacts
318            .environment_state
319            .as_ref()
320            .map(|state| self.write_run_blob(run, "environment.state.json", state))
321            .transpose()?;
322        let checkpoint_refs = checkpoint_refs(run, &artifacts.raw_records);
323        let latest_checkpoint = checkpoint_refs.last().cloned();
324        let raw_cursor = StreamCursorRef::new(
325            "raw_runtime",
326            format!("run:{}", run.run_id.as_str()),
327            artifacts
328                .raw_records
329                .last()
330                .map_or(0, |record| record.sequence),
331        );
332        let display_cursor = StreamCursorRef::new(
333            "display",
334            format!("run:{}", run.run_id.as_str()),
335            artifacts
336                .display_messages
337                .last()
338                .map_or(0, |message| message.sequence),
339        );
340        let environment_ref =
341            artifacts
342                .environment_state
343                .as_ref()
344                .map(|state| EnvironmentStateRef {
345                    provider: state.provider_id.clone(),
346                    reference: format!(
347                        "sessions/{}/runs/{}/environment.state.json",
348                        run.session_id.as_str(),
349                        run.run_id.as_str()
350                    ),
351                    revision: Some(format!("{}", state.files.len() + state.resources.len())),
352                    metadata: state.metadata.clone(),
353                });
354        let tx = self
355            .conn
356            .transaction_with_behavior(TransactionBehavior::Immediate)?;
357        let mut session = load_session_tx(&tx, run.session_id.as_str())?;
358        run.status = artifacts.status;
359        run.output_preview = Some(output);
360        run.updated_at = Utc::now();
361        run.latest_checkpoint = latest_checkpoint;
362        run.environment_state.clone_from(&environment_ref);
363        run.stream_cursors = vec![raw_cursor.clone(), display_cursor.clone()];
364        session.state = artifacts.state.clone();
365        session.environment_state = environment_ref;
366        session.stream_cursors.clone_from(&run.stream_cursors);
367        session.profile.clone_from(&run.profile);
368        session.head_run_id = Some(run.run_id.clone());
369        if artifacts.status == RunStatus::Completed {
370            session.head_success_run_id = Some(run.run_id.clone());
371        }
372        if session.active_run_id.as_ref() == Some(&run.run_id)
373            && artifacts.status != RunStatus::Waiting
374        {
375            session.active_run_id = None;
376        }
377        session.updated_at = run.updated_at;
378        upsert_run_tx(&tx, run)?;
379        upsert_session_tx(&tx, &session)?;
380        insert_raw_stream_records_tx(&tx, run, &artifacts.raw_records)?;
381        insert_display_messages_tx(&tx, &artifacts.display_messages)?;
382        insert_file_ref_tx(&tx, run, &raw_ref)?;
383        insert_file_ref_tx(&tx, run, &display_ref)?;
384        insert_file_ref_tx(&tx, run, &state_ref)?;
385        if let Some(env_ref) = env_ref {
386            insert_file_ref_tx(&tx, run, &env_ref)?;
387        }
388        insert_context_state_tx(&tx, run, &artifacts.state)?;
389        if let Some(environment_state) = artifacts.environment_state.as_ref() {
390            insert_environment_state_tx(&tx, run, environment_state)?;
391        }
392        insert_stream_cursor_tx(&tx, run, &raw_cursor)?;
393        insert_stream_cursor_tx(&tx, run, &display_cursor)?;
394        tx.execute(
395            "INSERT OR REPLACE INTO replay_snapshots (scope, snapshot_json, updated_at)
396             VALUES (?1, ?2, ?3)",
397            params![
398                format!("run:{}", run.run_id.as_str()),
399                serde_json::to_string(&artifacts.display_snapshot)?,
400                Utc::now().to_rfc3339(),
401            ],
402        )?;
403        insert_checkpoint_refs_tx(&tx, run, &checkpoint_refs)?;
404        insert_approval_records_tx(&tx, &artifacts.approvals)?;
405        insert_deferred_tool_records_tx(&tx, &artifacts.deferred_tools)?;
406        tx.commit()?;
407        Ok(artifacts.display_messages)
408    }
409
410    /// Fail a run atomically.
411    pub fn fail_run(&mut self, run: &mut RunRecord, message: String) -> CliResult<()> {
412        let tx = self
413            .conn
414            .transaction_with_behavior(TransactionBehavior::Immediate)?;
415        let mut session = load_session_tx(&tx, run.session_id.as_str())?;
416        run.status = RunStatus::Failed;
417        run.output_preview = Some(message);
418        run.updated_at = Utc::now();
419        session.head_run_id = Some(run.run_id.clone());
420        if session.active_run_id.as_ref() == Some(&run.run_id) {
421            session.active_run_id = None;
422        }
423        session.updated_at = run.updated_at;
424        upsert_run_tx(&tx, run)?;
425        upsert_session_tx(&tx, &session)?;
426        tx.commit()?;
427        Ok(())
428    }
429
430    /// Fail a run and persist terminal display evidence.
431    pub fn fail_run_with_messages(
432        &mut self,
433        run: &mut RunRecord,
434        message: String,
435        messages: &[DisplayMessage],
436    ) -> CliResult<()> {
437        let display_ref = self.write_run_blob(run, "display.compact.json", &messages)?;
438        let tx = self
439            .conn
440            .transaction_with_behavior(TransactionBehavior::Immediate)?;
441        let mut session = load_session_tx(&tx, run.session_id.as_str())?;
442        run.status = RunStatus::Failed;
443        run.output_preview = Some(message);
444        run.updated_at = Utc::now();
445        session.head_run_id = Some(run.run_id.clone());
446        if session.active_run_id.as_ref() == Some(&run.run_id) {
447            session.active_run_id = None;
448        }
449        session.updated_at = run.updated_at;
450        upsert_run_tx(&tx, run)?;
451        upsert_session_tx(&tx, &session)?;
452        insert_display_messages_tx(&tx, messages)?;
453        insert_file_ref_tx(&tx, run, &display_ref)?;
454        tx.commit()?;
455        Ok(())
456    }
457
458    /// Load the latest saved state for a run selected as continuation source.
459    pub fn load_restore_state(
460        &self,
461        session_id: &str,
462        run_id: Option<&str>,
463    ) -> CliResult<Option<ResumableState>> {
464        let Some(run_id) = run_id else {
465            return Ok(Some(self.load_session(session_id)?.state));
466        };
467        let mut state = self
468            .conn
469            .query_row(
470                "SELECT state_json FROM context_states WHERE session_id = ?1 AND run_id = ?2",
471                params![session_id, run_id],
472                |row| row.get::<_, String>(0),
473            )
474            .optional()?
475            .map(|json| serde_json::from_str(&json).map_err(CliError::from))
476            .transpose()?;
477        if let Some(state) = state.as_mut() {
478            self.inject_resolved_hitl_tool_returns(session_id, run_id, state)?;
479        }
480        Ok(state)
481    }
482
483    fn inject_resolved_hitl_tool_returns(
484        &self,
485        session_id: &str,
486        run_id: &str,
487        state: &mut ResumableState,
488    ) -> CliResult<()> {
489        let mut existing_returns = existing_resume_tool_return_ids(&state.message_history);
490        let tool_call_order = latest_tool_call_order(&state.message_history);
491        let latest_tool_call_ids = tool_call_order.iter().cloned().collect::<BTreeSet<_>>();
492        let approvals = self.list_approvals(Some(session_id), Some(run_id))?;
493        let deferred_tools = self.list_deferred_tools(Some(session_id), Some(run_id))?;
494        let pending_approvals = approvals
495            .iter()
496            .filter(|approval| {
497                approval.status == ApprovalStatus::Pending
498                    && !existing_returns.contains(&approval.action_id)
499            })
500            .map(|approval| approval.approval_id.clone())
501            .collect::<Vec<_>>();
502        let pending_deferred = deferred_tools
503            .iter()
504            .filter(|deferred| {
505                deferred_status_is_unresolved(deferred.status)
506                    && !existing_returns.contains(&deferred.tool_call_id)
507            })
508            .map(|deferred| deferred.deferred_id.clone())
509            .collect::<Vec<_>>();
510        if !pending_approvals.is_empty() || !pending_deferred.is_empty() {
511            return Err(pending_hitl_resume_error(
512                run_id,
513                &pending_approvals,
514                &pending_deferred,
515            ));
516        }
517
518        let mut resolved = Vec::<(String, ModelRequestPart)>::new();
519        for tool_return in self.list_run_tool_returns(session_id, run_id)? {
520            if !latest_tool_call_ids.contains(&tool_return.tool_call_id)
521                || tool_return_control_flow(&tool_return).is_some()
522                || existing_returns.contains(&tool_return.tool_call_id)
523            {
524                continue;
525            }
526            existing_returns.insert(tool_return.tool_call_id.clone());
527            resolved.push((
528                tool_return.tool_call_id.clone(),
529                ModelRequestPart::ToolReturn(tool_return),
530            ));
531        }
532        for approval in approvals {
533            if existing_returns.contains(&approval.action_id) {
534                continue;
535            }
536            if let Some(tool_return) = approval_tool_return(&approval) {
537                existing_returns.insert(approval.action_id.clone());
538                resolved.push((
539                    approval.action_id.clone(),
540                    ModelRequestPart::ToolReturn(tool_return),
541                ));
542            }
543        }
544        for deferred in deferred_tools {
545            if existing_returns.contains(&deferred.tool_call_id) {
546                continue;
547            }
548            if let Some(tool_return) = deferred_tool_return(&deferred) {
549                existing_returns.insert(deferred.tool_call_id.clone());
550                resolved.push((
551                    deferred.tool_call_id.clone(),
552                    ModelRequestPart::ToolReturn(tool_return),
553                ));
554            }
555        }
556        if resolved.is_empty() {
557            return Ok(());
558        }
559        resolved.sort_by_key(|(tool_call_id, _)| {
560            tool_call_order
561                .iter()
562                .position(|known| known == tool_call_id)
563                .unwrap_or(usize::MAX)
564        });
565        let mut metadata = serde_json::Map::new();
566        metadata.insert(
567            "starweaver.resume.hitl_results".to_string(),
568            serde_json::json!(true),
569        );
570        metadata.insert(
571            "starweaver.resume.source_run_id".to_string(),
572            serde_json::json!(run_id),
573        );
574        state
575            .message_history
576            .push(ModelMessage::Request(ModelRequest {
577                parts: resolved.into_iter().map(|(_, part)| part).collect(),
578                timestamp: Some(Utc::now()),
579                instructions: None,
580                run_id: Some(RunId::from_string(run_id)),
581                conversation_id: state.conversation_id.clone(),
582                metadata,
583            }));
584        Ok(())
585    }
586
587    fn list_run_tool_returns(
588        &self,
589        session_id: &str,
590        run_id: &str,
591    ) -> CliResult<Vec<ToolReturnPart>> {
592        let mut stmt = self.conn.prepare(
593            r"
594            SELECT record_json FROM raw_stream_records
595            WHERE session_id = ?1 AND run_id = ?2 AND kind = 'tool_return'
596            ORDER BY sequence_no
597            ",
598        )?;
599        let rows = stmt.query_map(params![session_id, run_id], |row| row.get::<_, String>(0))?;
600        let mut tool_returns = Vec::new();
601        for json in rows.collect::<Result<Vec<_>, _>>()? {
602            let record: AgentStreamRecord = serde_json::from_str(&json)?;
603            if let AgentStreamEvent::ToolReturn { tool_return, .. } = record.event {
604                tool_returns.push(tool_return);
605            }
606        }
607        Ok(tool_returns)
608    }
609
610    /// List session summaries.
611    pub fn list_sessions(&self, limit: usize) -> CliResult<Vec<SessionSummary>> {
612        let mut stmt = self.conn.prepare(
613            r"
614            SELECT s.session_id, s.title, s.profile, s.status, s.head_run_id, s.head_success_run_id,
615                   s.active_run_id, s.created_at, s.updated_at, COUNT(r.run_id),
616                   (SELECT output_preview FROM runs lr WHERE lr.session_id = s.session_id ORDER BY lr.sequence_no DESC LIMIT 1)
617            FROM sessions s
618            LEFT JOIN runs r ON r.session_id = s.session_id
619            GROUP BY s.session_id
620            ORDER BY s.updated_at DESC
621            LIMIT ?1
622            ",
623        )?;
624        let rows = stmt.query_map([usize_to_i64(limit)?], |row| {
625            Ok(SessionSummary {
626                session_id: row.get(0)?,
627                title: row.get(1)?,
628                profile: row.get(2)?,
629                status: row.get(3)?,
630                head_run_id: row.get(4)?,
631                head_success_run_id: row.get(5)?,
632                active_run_id: row.get(6)?,
633                created_at: row.get(7)?,
634                updated_at: row.get(8)?,
635                run_count: i64_to_usize(row.get::<_, i64>(9)?)?,
636                last_output_preview: row.get(10)?,
637            })
638        })?;
639        rows.collect::<Result<Vec<_>, _>>().map_err(CliError::from)
640    }
641
642    /// List run summaries.
643    pub fn list_runs(&self, session_id: &str, limit: usize) -> CliResult<Vec<RunSummary>> {
644        let mut stmt = self.conn.prepare(
645            r"
646            SELECT run_id, sequence_no, status, restore_from_run_id, output_preview, created_at, updated_at
647            FROM runs
648            WHERE session_id = ?1
649            ORDER BY sequence_no DESC
650            LIMIT ?2
651            ",
652        )?;
653        let rows = stmt.query_map(params![session_id, usize_to_i64(limit)?], |row| {
654            Ok(RunSummary {
655                run_id: row.get(0)?,
656                sequence_no: i64_to_usize(row.get::<_, i64>(1)?)?,
657                status: row.get(2)?,
658                restore_from_run_id: row.get(3)?,
659                output_preview: row.get(4)?,
660                created_at: row.get(5)?,
661                updated_at: row.get(6)?,
662            })
663        })?;
664        let mut runs = rows.collect::<Result<Vec<_>, _>>()?;
665        runs.sort_by_key(|run| run.sequence_no);
666        Ok(runs)
667    }
668
669    /// Replay display messages for a session or run.
670    pub fn replay_display(
671        &self,
672        session_id: &str,
673        run_id: Option<&str>,
674        after: Option<usize>,
675    ) -> CliResult<Vec<DisplayMessage>> {
676        let after = after.map_or(-1_i64, |value| i64::try_from(value).unwrap_or(i64::MAX));
677        let sql = if run_id.is_some() {
678            r"
679            SELECT dm.message_json
680            FROM display_messages dm
681            JOIN runs r ON r.session_id = dm.session_id AND r.run_id = dm.run_id
682            WHERE dm.session_id = ?1 AND dm.run_id = ?2 AND dm.sequence_no > ?3
683            ORDER BY r.sequence_no, dm.sequence_no
684            "
685        } else {
686            r"
687            SELECT dm.message_json
688            FROM display_messages dm
689            JOIN runs r ON r.session_id = dm.session_id AND r.run_id = dm.run_id
690            WHERE dm.session_id = ?1 AND dm.sequence_no > ?2
691            ORDER BY r.sequence_no, dm.sequence_no
692            "
693        };
694        let mut stmt = self.conn.prepare(sql)?;
695        let mapped = if let Some(run_id) = run_id {
696            stmt.query_map(params![session_id, run_id, after], |row| {
697                row.get::<_, String>(0)
698            })?
699            .collect::<Result<Vec<_>, _>>()?
700        } else {
701            stmt.query_map(params![session_id, after], |row| row.get::<_, String>(0))?
702                .collect::<Result<Vec<_>, _>>()?
703        };
704        mapped
705            .into_iter()
706            .map(|json| serde_json::from_str(&json).map_err(CliError::from))
707            .collect()
708    }
709
710    /// Trim old runs for selected sessions.
711    pub fn trim(
712        &mut self,
713        sessions: Vec<String>,
714        keep_runs: usize,
715        dry_run: bool,
716    ) -> CliResult<TrimReport> {
717        self.trim_with_age(sessions, keep_runs, None, dry_run)
718    }
719
720    /// Trim old runs for selected sessions and optional age horizon.
721    pub fn trim_with_age(
722        &mut self,
723        sessions: Vec<String>,
724        keep_runs: usize,
725        older_than: Option<chrono::Duration>,
726        dry_run: bool,
727    ) -> CliResult<TrimReport> {
728        let mut report = TrimReport {
729            dry_run,
730            ..TrimReport::default()
731        };
732        report.sessions_scanned = sessions.len();
733        for session_id in sessions {
734            let trim_runs = self.trim_candidates(&session_id, keep_runs, older_than)?;
735            report.runs_to_trim += trim_runs.len();
736            for run_id in trim_runs {
737                let bytes = self.run_file_bytes(&session_id, &run_id)?;
738                report.bytes_reclaimed = report.bytes_reclaimed.saturating_add(bytes);
739                if !dry_run {
740                    self.delete_run(&session_id, &run_id)?;
741                    self.remove_run_files(&session_id, &run_id)?;
742                    report.runs_trimmed += 1;
743                }
744            }
745        }
746        Ok(report)
747    }
748
749    /// Return all session ids.
750    pub fn all_session_ids(&self) -> CliResult<Vec<String>> {
751        let mut stmt = self
752            .conn
753            .prepare("SELECT session_id FROM sessions ORDER BY updated_at DESC")?;
754        let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
755        rows.collect::<Result<Vec<_>, _>>().map_err(CliError::from)
756    }
757
758    fn trim_candidates(
759        &self,
760        session_id: &str,
761        keep_runs: usize,
762        older_than: Option<chrono::Duration>,
763    ) -> CliResult<Vec<String>> {
764        let cutoff = older_than.map(|duration| (Utc::now() - duration).to_rfc3339());
765        let mut stmt = self.conn.prepare(
766            r"
767            SELECT r.run_id
768            FROM runs r
769            JOIN sessions s ON s.session_id = r.session_id
770            WHERE r.session_id = ?1
771              AND r.sequence_no <= (
772                  SELECT COALESCE(MAX(sequence_no), 0) FROM runs WHERE session_id = ?1
773              ) - ?2
774              AND (?3 IS NULL OR r.updated_at < ?3)
775              AND (s.active_run_id IS NULL OR r.run_id != s.active_run_id)
776            ORDER BY r.sequence_no
777            ",
778        )?;
779        let rows = stmt.query_map(
780            params![session_id, usize_to_i64(keep_runs)?, cutoff],
781            |row| row.get::<_, String>(0),
782        )?;
783        rows.collect::<Result<Vec<_>, _>>().map_err(CliError::from)
784    }
785
786    fn delete_run(&mut self, session_id: &str, run_id: &str) -> CliResult<()> {
787        let tx = self
788            .conn
789            .transaction_with_behavior(TransactionBehavior::Immediate)?;
790        tx.execute(
791            "DELETE FROM replay_snapshots WHERE scope = ?1",
792            params![format!("run:{run_id}")],
793        )?;
794        for table in [
795            "display_messages",
796            "raw_stream_records",
797            "context_states",
798            "environment_states",
799            "stream_cursors",
800            "checkpoints",
801            "approvals",
802            "deferred_tools",
803            "file_refs",
804        ] {
805            tx.execute(
806                &format!("DELETE FROM {table} WHERE session_id = ?1 AND run_id = ?2"),
807                params![session_id, run_id],
808            )?;
809        }
810        tx.execute(
811            "DELETE FROM runs WHERE session_id = ?1 AND run_id = ?2",
812            params![session_id, run_id],
813        )?;
814        tx.commit()?;
815        Ok(())
816    }
817
818    fn write_run_blob<T: Serialize>(
819        &self,
820        run: &RunRecord,
821        name: &str,
822        value: &T,
823    ) -> CliResult<FileRefRecord> {
824        let relative = PathBuf::from("sessions")
825            .join(run.session_id.as_str())
826            .join("runs")
827            .join(run.run_id.as_str())
828            .join(name);
829        let path = self.file_store_path.join(&relative);
830        atomic_write_json(&path, value)?;
831        let data = fs::read(&path).map_err(|error| io_error(&path, error))?;
832        let bytes = data.len();
833        Ok(FileRefRecord {
834            ref_id: format!(
835                "{}:{}:{}",
836                run.session_id.as_str(),
837                run.run_id.as_str(),
838                name
839            ),
840            relative_path: relative.to_string_lossy().to_string(),
841            byte_size: i64::try_from(bytes)
842                .map_err(|error| CliError::Storage(error.to_string()))?,
843            checksum: cheap_checksum(&data),
844            content_type: "application/json".to_string(),
845            created_at: Utc::now().to_rfc3339(),
846        })
847    }
848
849    fn run_file_bytes(&self, session_id: &str, run_id: &str) -> CliResult<u64> {
850        let mut stmt = self.conn.prepare("SELECT COALESCE(SUM(byte_size), 0) FROM file_refs WHERE session_id = ?1 AND run_id = ?2")?;
851        let bytes = stmt.query_row(params![session_id, run_id], |row| row.get::<_, i64>(0))?;
852        Ok(u64::try_from(bytes).unwrap_or(0))
853    }
854
855    fn remove_run_files(&self, session_id: &str, run_id: &str) -> CliResult<()> {
856        let path = self
857            .file_store_path
858            .join("sessions")
859            .join(session_id)
860            .join("runs")
861            .join(run_id);
862        if path.exists() {
863            fs::remove_dir_all(&path).map_err(|error| io_error(&path, error))?;
864        }
865        Ok(())
866    }
867
868    /// List persisted approval records.
869    pub fn list_approvals(
870        &self,
871        session_id: Option<&str>,
872        run_id: Option<&str>,
873    ) -> CliResult<Vec<ApprovalRecord>> {
874        let mut stmt = self.conn.prepare(
875            r"
876            SELECT record_json FROM approvals
877            WHERE (?1 IS NULL OR session_id = ?1)
878              AND (?2 IS NULL OR run_id = ?2)
879            ORDER BY updated_at DESC, created_at DESC
880            ",
881        )?;
882        let rows = stmt.query_map(params![session_id, run_id], |row| row.get::<_, String>(0))?;
883        rows.collect::<Result<Vec<_>, _>>()?
884            .into_iter()
885            .map(|json| serde_json::from_str(&json).map_err(CliError::from))
886            .collect()
887    }
888
889    /// Load one approval record.
890    pub fn load_approval(&self, approval_id: &str) -> CliResult<ApprovalRecord> {
891        self.conn
892            .query_row(
893                "SELECT record_json FROM approvals WHERE approval_id = ?1",
894                [approval_id],
895                |row| row.get::<_, String>(0),
896            )
897            .optional()?
898            .map(|json| serde_json::from_str(&json).map_err(CliError::from))
899            .transpose()?
900            .ok_or_else(|| CliError::NotFound(approval_id.to_string()))
901    }
902
903    /// Record an approval decision.
904    pub fn decide_approval(
905        &mut self,
906        approval_id: &str,
907        status: ApprovalStatus,
908        reason: Option<String>,
909    ) -> CliResult<ApprovalRecord> {
910        let mut approval = self.load_approval(approval_id)?;
911        approval.status = status;
912        approval.decision = Some(ApprovalDecision {
913            status,
914            decided_by: Some("starweaver-cli".to_string()),
915            decided_at: Utc::now(),
916            reason,
917            metadata: serde_json::Map::default(),
918        });
919        approval.updated_at = Utc::now();
920        let tx = self
921            .conn
922            .transaction_with_behavior(TransactionBehavior::Immediate)?;
923        insert_approval_records_tx(&tx, &[approval.clone()])?;
924        tx.commit()?;
925        Ok(approval)
926    }
927
928    /// List persisted deferred tool records.
929    pub fn list_deferred_tools(
930        &self,
931        session_id: Option<&str>,
932        run_id: Option<&str>,
933    ) -> CliResult<Vec<DeferredToolRecord>> {
934        let mut stmt = self.conn.prepare(
935            r"
936            SELECT record_json FROM deferred_tools
937            WHERE (?1 IS NULL OR session_id = ?1)
938              AND (?2 IS NULL OR run_id = ?2)
939            ORDER BY updated_at DESC, created_at DESC
940            ",
941        )?;
942        let rows = stmt.query_map(params![session_id, run_id], |row| row.get::<_, String>(0))?;
943        rows.collect::<Result<Vec<_>, _>>()?
944            .into_iter()
945            .map(|json| serde_json::from_str(&json).map_err(CliError::from))
946            .collect()
947    }
948
949    /// Load one deferred tool record.
950    pub fn load_deferred_tool(&self, deferred_id: &str) -> CliResult<DeferredToolRecord> {
951        self.conn
952            .query_row(
953                "SELECT record_json FROM deferred_tools WHERE deferred_id = ?1",
954                [deferred_id],
955                |row| row.get::<_, String>(0),
956            )
957            .optional()?
958            .map(|json| serde_json::from_str(&json).map_err(CliError::from))
959            .transpose()?
960            .ok_or_else(|| CliError::NotFound(deferred_id.to_string()))
961    }
962
963    /// Complete one deferred tool record.
964    pub fn complete_deferred_tool(
965        &mut self,
966        deferred_id: &str,
967        response: Value,
968    ) -> CliResult<DeferredToolRecord> {
969        self.update_deferred_tool(deferred_id, ExecutionStatus::Completed, response)
970    }
971
972    /// Fail one deferred tool record.
973    pub fn fail_deferred_tool(
974        &mut self,
975        deferred_id: &str,
976        error: &str,
977    ) -> CliResult<DeferredToolRecord> {
978        self.update_deferred_tool(
979            deferred_id,
980            ExecutionStatus::Failed,
981            serde_json::json!({"error": error}),
982        )
983    }
984
985    fn update_deferred_tool(
986        &mut self,
987        deferred_id: &str,
988        status: ExecutionStatus,
989        response: Value,
990    ) -> CliResult<DeferredToolRecord> {
991        let mut deferred = self.load_deferred_tool(deferred_id)?;
992        deferred.status = status;
993        deferred.response = response;
994        deferred.updated_at = Utc::now();
995        let tx = self
996            .conn
997            .transaction_with_behavior(TransactionBehavior::Immediate)?;
998        insert_deferred_tool_records_tx(&tx, &[deferred.clone()])?;
999        tx.commit()?;
1000        Ok(deferred)
1001    }
1002}