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::{Connection, OptionalExtension, TransactionBehavior, params};
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::{CliError, CliResult, config::CliConfig, error::io_error};
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_for_run_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_for_run_tx(
382            &tx,
383            &run.session_id,
384            &run.run_id,
385            &artifacts.display_messages,
386        )?;
387        insert_file_ref_tx(&tx, run, &raw_ref)?;
388        insert_file_ref_tx(&tx, run, &display_ref)?;
389        insert_file_ref_tx(&tx, run, &state_ref)?;
390        if let Some(env_ref) = env_ref {
391            insert_file_ref_tx(&tx, run, &env_ref)?;
392        }
393        insert_context_state_tx(&tx, run, &artifacts.state)?;
394        if let Some(environment_state) = artifacts.environment_state.as_ref() {
395            insert_environment_state_tx(&tx, run, environment_state)?;
396        }
397        insert_stream_cursor_tx(&tx, run, &raw_cursor)?;
398        insert_stream_cursor_tx(&tx, run, &display_cursor)?;
399        tx.execute(
400            "INSERT OR REPLACE INTO replay_snapshots (scope, snapshot_json, updated_at)
401             VALUES (?1, ?2, ?3)",
402            params![
403                format!("run:{}", run.run_id.as_str()),
404                serde_json::to_string(&artifacts.display_snapshot)?,
405                Utc::now().to_rfc3339(),
406            ],
407        )?;
408        insert_checkpoint_refs_tx(&tx, run, &checkpoint_refs)?;
409        insert_approval_records_tx(&tx, &artifacts.approvals)?;
410        insert_deferred_tool_records_tx(&tx, &artifacts.deferred_tools)?;
411        tx.commit()?;
412        Ok(artifacts.display_messages)
413    }
414
415    /// Fail a run atomically.
416    pub fn fail_run(&mut self, run: &mut RunRecord, message: String) -> CliResult<()> {
417        let tx = self
418            .conn
419            .transaction_with_behavior(TransactionBehavior::Immediate)?;
420        let mut session = load_session_tx(&tx, run.session_id.as_str())?;
421        run.status = RunStatus::Failed;
422        run.output_preview = Some(message);
423        run.updated_at = Utc::now();
424        session.head_run_id = Some(run.run_id.clone());
425        if session.active_run_id.as_ref() == Some(&run.run_id) {
426            session.active_run_id = None;
427        }
428        session.updated_at = run.updated_at;
429        upsert_run_tx(&tx, run)?;
430        upsert_session_tx(&tx, &session)?;
431        tx.commit()?;
432        Ok(())
433    }
434
435    /// Fail a run and persist terminal display evidence.
436    pub fn fail_run_with_messages(
437        &mut self,
438        run: &mut RunRecord,
439        message: String,
440        messages: &[DisplayMessage],
441    ) -> CliResult<()> {
442        let display_ref = self.write_run_blob(run, "display.compact.json", &messages)?;
443        let tx = self
444            .conn
445            .transaction_with_behavior(TransactionBehavior::Immediate)?;
446        let mut session = load_session_tx(&tx, run.session_id.as_str())?;
447        run.status = RunStatus::Failed;
448        run.output_preview = Some(message);
449        run.updated_at = Utc::now();
450        session.head_run_id = Some(run.run_id.clone());
451        if session.active_run_id.as_ref() == Some(&run.run_id) {
452            session.active_run_id = None;
453        }
454        session.updated_at = run.updated_at;
455        upsert_run_tx(&tx, run)?;
456        upsert_session_tx(&tx, &session)?;
457        insert_display_messages_for_run_tx(&tx, &run.session_id, &run.run_id, messages)?;
458        insert_file_ref_tx(&tx, run, &display_ref)?;
459        tx.commit()?;
460        Ok(())
461    }
462
463    /// Load the latest saved state for a run selected as continuation source.
464    pub fn load_restore_state(
465        &self,
466        session_id: &str,
467        run_id: Option<&str>,
468    ) -> CliResult<Option<ResumableState>> {
469        let Some(run_id) = run_id else {
470            return Ok(Some(self.load_session(session_id)?.state));
471        };
472        let mut state = self
473            .conn
474            .query_row(
475                "SELECT state_json FROM context_states WHERE session_id = ?1 AND run_id = ?2",
476                params![session_id, run_id],
477                |row| row.get::<_, String>(0),
478            )
479            .optional()?
480            .map(|json| serde_json::from_str(&json).map_err(CliError::from))
481            .transpose()?;
482        if let Some(state) = state.as_mut() {
483            self.inject_resolved_hitl_tool_returns(session_id, run_id, state)?;
484        }
485        Ok(state)
486    }
487
488    fn inject_resolved_hitl_tool_returns(
489        &self,
490        session_id: &str,
491        run_id: &str,
492        state: &mut ResumableState,
493    ) -> CliResult<()> {
494        let mut existing_returns = existing_resume_tool_return_ids(&state.message_history);
495        let tool_call_order = latest_tool_call_order(&state.message_history);
496        let latest_tool_call_ids = tool_call_order.iter().cloned().collect::<BTreeSet<_>>();
497        let approvals = self.list_approvals(Some(session_id), Some(run_id))?;
498        let deferred_tools = self.list_deferred_tools(Some(session_id), Some(run_id))?;
499        let pending_approvals = approvals
500            .iter()
501            .filter(|approval| {
502                approval.status == ApprovalStatus::Pending
503                    && !existing_returns.contains(&approval.action_id)
504            })
505            .map(|approval| approval.approval_id.clone())
506            .collect::<Vec<_>>();
507        let pending_deferred = deferred_tools
508            .iter()
509            .filter(|deferred| {
510                deferred_status_is_unresolved(deferred.status)
511                    && !existing_returns.contains(&deferred.tool_call_id)
512            })
513            .map(|deferred| deferred.deferred_id.clone())
514            .collect::<Vec<_>>();
515        if !pending_approvals.is_empty() || !pending_deferred.is_empty() {
516            return Err(pending_hitl_resume_error(
517                run_id,
518                &pending_approvals,
519                &pending_deferred,
520            ));
521        }
522
523        let mut resolved = Vec::<(String, ModelRequestPart)>::new();
524        for tool_return in self.list_run_tool_returns(session_id, run_id)? {
525            if !latest_tool_call_ids.contains(&tool_return.tool_call_id)
526                || tool_return_control_flow(&tool_return).is_some()
527                || existing_returns.contains(&tool_return.tool_call_id)
528            {
529                continue;
530            }
531            existing_returns.insert(tool_return.tool_call_id.clone());
532            resolved.push((
533                tool_return.tool_call_id.clone(),
534                ModelRequestPart::ToolReturn(tool_return),
535            ));
536        }
537        for approval in approvals {
538            if existing_returns.contains(&approval.action_id) {
539                continue;
540            }
541            if let Some(tool_return) = approval_tool_return(&approval) {
542                existing_returns.insert(approval.action_id.clone());
543                resolved.push((
544                    approval.action_id.clone(),
545                    ModelRequestPart::ToolReturn(tool_return),
546                ));
547            }
548        }
549        for deferred in deferred_tools {
550            if existing_returns.contains(&deferred.tool_call_id) {
551                continue;
552            }
553            if let Some(tool_return) = deferred_tool_return(&deferred) {
554                existing_returns.insert(deferred.tool_call_id.clone());
555                resolved.push((
556                    deferred.tool_call_id.clone(),
557                    ModelRequestPart::ToolReturn(tool_return),
558                ));
559            }
560        }
561        if resolved.is_empty() {
562            return Ok(());
563        }
564        resolved.sort_by_key(|(tool_call_id, _)| {
565            tool_call_order
566                .iter()
567                .position(|known| known == tool_call_id)
568                .unwrap_or(usize::MAX)
569        });
570        let mut metadata = serde_json::Map::new();
571        metadata.insert(
572            "starweaver.resume.hitl_results".to_string(),
573            serde_json::json!(true),
574        );
575        metadata.insert(
576            "starweaver.resume.source_run_id".to_string(),
577            serde_json::json!(run_id),
578        );
579        state
580            .message_history
581            .push(ModelMessage::Request(ModelRequest {
582                parts: resolved.into_iter().map(|(_, part)| part).collect(),
583                timestamp: Some(Utc::now()),
584                instructions: None,
585                run_id: Some(RunId::from_string(run_id)),
586                conversation_id: state.conversation_id.clone(),
587                metadata,
588            }));
589        Ok(())
590    }
591
592    fn list_run_tool_returns(
593        &self,
594        session_id: &str,
595        run_id: &str,
596    ) -> CliResult<Vec<ToolReturnPart>> {
597        let mut stmt = self.conn.prepare(
598            r"
599            SELECT record_json FROM raw_stream_records
600            WHERE session_id = ?1 AND run_id = ?2 AND kind = 'tool_return'
601            ORDER BY sequence_no
602            ",
603        )?;
604        let rows = stmt.query_map(params![session_id, run_id], |row| row.get::<_, String>(0))?;
605        let mut tool_returns = Vec::new();
606        for json in rows.collect::<Result<Vec<_>, _>>()? {
607            let record: AgentStreamRecord = serde_json::from_str(&json)?;
608            if let AgentStreamEvent::ToolReturn { tool_return, .. } = record.event {
609                tool_returns.push(tool_return);
610            }
611        }
612        Ok(tool_returns)
613    }
614
615    /// List session summaries.
616    pub fn list_sessions(&self, limit: usize) -> CliResult<Vec<SessionSummary>> {
617        let mut stmt = self.conn.prepare(
618            r"
619            SELECT s.session_id, s.title, s.profile, s.status, s.head_run_id, s.head_success_run_id,
620                   s.active_run_id, s.created_at, s.updated_at, COUNT(r.run_id),
621                   (SELECT output_preview FROM runs lr WHERE lr.session_id = s.session_id ORDER BY lr.sequence_no DESC LIMIT 1)
622            FROM sessions s
623            LEFT JOIN runs r ON r.session_id = s.session_id
624            GROUP BY s.session_id
625            ORDER BY s.updated_at DESC
626            LIMIT ?1
627            ",
628        )?;
629        let rows = stmt.query_map([usize_to_i64(limit)?], |row| {
630            Ok(SessionSummary {
631                session_id: row.get(0)?,
632                title: row.get(1)?,
633                profile: row.get(2)?,
634                status: row.get(3)?,
635                head_run_id: row.get(4)?,
636                head_success_run_id: row.get(5)?,
637                active_run_id: row.get(6)?,
638                created_at: row.get(7)?,
639                updated_at: row.get(8)?,
640                run_count: i64_to_usize(row.get::<_, i64>(9)?)?,
641                last_output_preview: row.get(10)?,
642            })
643        })?;
644        rows.collect::<Result<Vec<_>, _>>().map_err(CliError::from)
645    }
646
647    /// List run summaries.
648    pub fn list_runs(&self, session_id: &str, limit: usize) -> CliResult<Vec<RunSummary>> {
649        let mut stmt = self.conn.prepare(
650            r"
651            SELECT run_id, sequence_no, status, restore_from_run_id, output_preview, created_at, updated_at
652            FROM runs
653            WHERE session_id = ?1
654            ORDER BY sequence_no DESC
655            LIMIT ?2
656            ",
657        )?;
658        let rows = stmt.query_map(params![session_id, usize_to_i64(limit)?], |row| {
659            Ok(RunSummary {
660                run_id: row.get(0)?,
661                sequence_no: i64_to_usize(row.get::<_, i64>(1)?)?,
662                status: row.get(2)?,
663                restore_from_run_id: row.get(3)?,
664                output_preview: row.get(4)?,
665                created_at: row.get(5)?,
666                updated_at: row.get(6)?,
667            })
668        })?;
669        let mut runs = rows.collect::<Result<Vec<_>, _>>()?;
670        runs.sort_by_key(|run| run.sequence_no);
671        Ok(runs)
672    }
673
674    /// Replay display messages for a session or run.
675    pub fn replay_display(
676        &self,
677        session_id: &str,
678        run_id: Option<&str>,
679        after: Option<usize>,
680    ) -> CliResult<Vec<DisplayMessage>> {
681        let after = after.map_or(-1_i64, |value| i64::try_from(value).unwrap_or(i64::MAX));
682        let sql = if run_id.is_some() {
683            r"
684            SELECT dm.message_json
685            FROM display_messages dm
686            JOIN runs r ON r.session_id = dm.session_id AND r.run_id = dm.run_id
687            WHERE dm.session_id = ?1 AND dm.run_id = ?2 AND dm.sequence_no > ?3
688            ORDER BY r.sequence_no, dm.sequence_no
689            "
690        } else {
691            r"
692            SELECT dm.message_json
693            FROM display_messages dm
694            JOIN runs r ON r.session_id = dm.session_id AND r.run_id = dm.run_id
695            WHERE dm.session_id = ?1 AND dm.sequence_no > ?2
696            ORDER BY r.sequence_no, dm.sequence_no
697            "
698        };
699        let mut stmt = self.conn.prepare(sql)?;
700        let mapped = if let Some(run_id) = run_id {
701            stmt.query_map(params![session_id, run_id, after], |row| {
702                row.get::<_, String>(0)
703            })?
704            .collect::<Result<Vec<_>, _>>()?
705        } else {
706            stmt.query_map(params![session_id, after], |row| row.get::<_, String>(0))?
707                .collect::<Result<Vec<_>, _>>()?
708        };
709        mapped
710            .into_iter()
711            .map(|json| serde_json::from_str(&json).map_err(CliError::from))
712            .collect()
713    }
714
715    /// Trim old runs for selected sessions.
716    pub fn trim(
717        &mut self,
718        sessions: Vec<String>,
719        keep_runs: usize,
720        dry_run: bool,
721    ) -> CliResult<TrimReport> {
722        self.trim_with_age(sessions, keep_runs, None, dry_run)
723    }
724
725    /// Trim old runs for selected sessions and optional age horizon.
726    pub fn trim_with_age(
727        &mut self,
728        sessions: Vec<String>,
729        keep_runs: usize,
730        older_than: Option<chrono::Duration>,
731        dry_run: bool,
732    ) -> CliResult<TrimReport> {
733        let mut report = TrimReport {
734            dry_run,
735            ..TrimReport::default()
736        };
737        report.sessions_scanned = sessions.len();
738        for session_id in sessions {
739            let trim_runs = self.trim_candidates(&session_id, keep_runs, older_than)?;
740            report.runs_to_trim += trim_runs.len();
741            for run_id in trim_runs {
742                let bytes = self.run_file_bytes(&session_id, &run_id)?;
743                report.bytes_reclaimed = report.bytes_reclaimed.saturating_add(bytes);
744                if !dry_run {
745                    self.delete_run(&session_id, &run_id)?;
746                    self.remove_run_files(&session_id, &run_id)?;
747                    report.runs_trimmed += 1;
748                }
749            }
750        }
751        Ok(report)
752    }
753
754    /// Return all session ids.
755    pub fn all_session_ids(&self) -> CliResult<Vec<String>> {
756        let mut stmt = self
757            .conn
758            .prepare("SELECT session_id FROM sessions ORDER BY updated_at DESC")?;
759        let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
760        rows.collect::<Result<Vec<_>, _>>().map_err(CliError::from)
761    }
762
763    fn trim_candidates(
764        &self,
765        session_id: &str,
766        keep_runs: usize,
767        older_than: Option<chrono::Duration>,
768    ) -> CliResult<Vec<String>> {
769        let cutoff = older_than.map(|duration| (Utc::now() - duration).to_rfc3339());
770        let mut stmt = self.conn.prepare(
771            r"
772            SELECT r.run_id
773            FROM runs r
774            JOIN sessions s ON s.session_id = r.session_id
775            WHERE r.session_id = ?1
776              AND r.sequence_no <= (
777                  SELECT COALESCE(MAX(sequence_no), 0) FROM runs WHERE session_id = ?1
778              ) - ?2
779              AND (?3 IS NULL OR r.updated_at < ?3)
780              AND (s.active_run_id IS NULL OR r.run_id != s.active_run_id)
781            ORDER BY r.sequence_no
782            ",
783        )?;
784        let rows = stmt.query_map(
785            params![session_id, usize_to_i64(keep_runs)?, cutoff],
786            |row| row.get::<_, String>(0),
787        )?;
788        rows.collect::<Result<Vec<_>, _>>().map_err(CliError::from)
789    }
790
791    fn delete_run(&mut self, session_id: &str, run_id: &str) -> CliResult<()> {
792        let tx = self
793            .conn
794            .transaction_with_behavior(TransactionBehavior::Immediate)?;
795        tx.execute(
796            "DELETE FROM replay_snapshots WHERE scope = ?1",
797            params![format!("run:{run_id}")],
798        )?;
799        for table in [
800            "display_messages",
801            "raw_stream_records",
802            "context_states",
803            "environment_states",
804            "stream_cursors",
805            "checkpoints",
806            "approvals",
807            "deferred_tools",
808            "file_refs",
809        ] {
810            tx.execute(
811                &format!("DELETE FROM {table} WHERE session_id = ?1 AND run_id = ?2"),
812                params![session_id, run_id],
813            )?;
814        }
815        tx.execute(
816            "DELETE FROM runs WHERE session_id = ?1 AND run_id = ?2",
817            params![session_id, run_id],
818        )?;
819        tx.commit()?;
820        Ok(())
821    }
822
823    fn write_run_blob<T: Serialize>(
824        &self,
825        run: &RunRecord,
826        name: &str,
827        value: &T,
828    ) -> CliResult<FileRefRecord> {
829        let relative = PathBuf::from("sessions")
830            .join(run.session_id.as_str())
831            .join("runs")
832            .join(run.run_id.as_str())
833            .join(name);
834        let path = self.file_store_path.join(&relative);
835        atomic_write_json(&path, value)?;
836        let data = fs::read(&path).map_err(|error| io_error(&path, error))?;
837        let bytes = data.len();
838        Ok(FileRefRecord {
839            ref_id: format!(
840                "{}:{}:{}",
841                run.session_id.as_str(),
842                run.run_id.as_str(),
843                name
844            ),
845            relative_path: relative.to_string_lossy().to_string(),
846            byte_size: i64::try_from(bytes)
847                .map_err(|error| CliError::Storage(error.to_string()))?,
848            checksum: cheap_checksum(&data),
849            content_type: "application/json".to_string(),
850            created_at: Utc::now().to_rfc3339(),
851        })
852    }
853
854    fn run_file_bytes(&self, session_id: &str, run_id: &str) -> CliResult<u64> {
855        let mut stmt = self.conn.prepare("SELECT COALESCE(SUM(byte_size), 0) FROM file_refs WHERE session_id = ?1 AND run_id = ?2")?;
856        let bytes = stmt.query_row(params![session_id, run_id], |row| row.get::<_, i64>(0))?;
857        Ok(u64::try_from(bytes).unwrap_or(0))
858    }
859
860    fn remove_run_files(&self, session_id: &str, run_id: &str) -> CliResult<()> {
861        let path = self
862            .file_store_path
863            .join("sessions")
864            .join(session_id)
865            .join("runs")
866            .join(run_id);
867        if path.exists() {
868            fs::remove_dir_all(&path).map_err(|error| io_error(&path, error))?;
869        }
870        Ok(())
871    }
872
873    /// List persisted approval records.
874    pub fn list_approvals(
875        &self,
876        session_id: Option<&str>,
877        run_id: Option<&str>,
878    ) -> CliResult<Vec<ApprovalRecord>> {
879        let mut stmt = self.conn.prepare(
880            r"
881            SELECT record_json FROM approvals
882            WHERE (?1 IS NULL OR session_id = ?1)
883              AND (?2 IS NULL OR run_id = ?2)
884            ORDER BY updated_at DESC, created_at DESC
885            ",
886        )?;
887        let rows = stmt.query_map(params![session_id, run_id], |row| row.get::<_, String>(0))?;
888        rows.collect::<Result<Vec<_>, _>>()?
889            .into_iter()
890            .map(|json| serde_json::from_str(&json).map_err(CliError::from))
891            .collect()
892    }
893
894    /// Load one approval record.
895    pub fn load_approval(&self, approval_id: &str) -> CliResult<ApprovalRecord> {
896        self.conn
897            .query_row(
898                "SELECT record_json FROM approvals WHERE approval_id = ?1",
899                [approval_id],
900                |row| row.get::<_, String>(0),
901            )
902            .optional()?
903            .map(|json| serde_json::from_str(&json).map_err(CliError::from))
904            .transpose()?
905            .ok_or_else(|| CliError::NotFound(approval_id.to_string()))
906    }
907
908    /// Record an approval decision.
909    pub fn decide_approval(
910        &mut self,
911        approval_id: &str,
912        status: ApprovalStatus,
913        reason: Option<String>,
914    ) -> CliResult<ApprovalRecord> {
915        let mut approval = self.load_approval(approval_id)?;
916        approval.status = status;
917        approval.decision = Some(ApprovalDecision {
918            status,
919            decided_by: Some("starweaver-cli".to_string()),
920            decided_at: Utc::now(),
921            reason,
922            metadata: serde_json::Map::default(),
923        });
924        approval.updated_at = Utc::now();
925        let tx = self
926            .conn
927            .transaction_with_behavior(TransactionBehavior::Immediate)?;
928        insert_approval_records_tx(&tx, &[approval.clone()])?;
929        tx.commit()?;
930        Ok(approval)
931    }
932
933    /// List persisted deferred tool records.
934    pub fn list_deferred_tools(
935        &self,
936        session_id: Option<&str>,
937        run_id: Option<&str>,
938    ) -> CliResult<Vec<DeferredToolRecord>> {
939        let mut stmt = self.conn.prepare(
940            r"
941            SELECT record_json FROM deferred_tools
942            WHERE (?1 IS NULL OR session_id = ?1)
943              AND (?2 IS NULL OR run_id = ?2)
944            ORDER BY updated_at DESC, created_at DESC
945            ",
946        )?;
947        let rows = stmt.query_map(params![session_id, run_id], |row| row.get::<_, String>(0))?;
948        rows.collect::<Result<Vec<_>, _>>()?
949            .into_iter()
950            .map(|json| serde_json::from_str(&json).map_err(CliError::from))
951            .collect()
952    }
953
954    /// Load one deferred tool record.
955    pub fn load_deferred_tool(&self, deferred_id: &str) -> CliResult<DeferredToolRecord> {
956        self.conn
957            .query_row(
958                "SELECT record_json FROM deferred_tools WHERE deferred_id = ?1",
959                [deferred_id],
960                |row| row.get::<_, String>(0),
961            )
962            .optional()?
963            .map(|json| serde_json::from_str(&json).map_err(CliError::from))
964            .transpose()?
965            .ok_or_else(|| CliError::NotFound(deferred_id.to_string()))
966    }
967
968    /// Complete one deferred tool record.
969    pub fn complete_deferred_tool(
970        &mut self,
971        deferred_id: &str,
972        response: Value,
973    ) -> CliResult<DeferredToolRecord> {
974        self.update_deferred_tool(deferred_id, ExecutionStatus::Completed, response)
975    }
976
977    /// Fail one deferred tool record.
978    pub fn fail_deferred_tool(
979        &mut self,
980        deferred_id: &str,
981        error: &str,
982    ) -> CliResult<DeferredToolRecord> {
983        self.update_deferred_tool(
984            deferred_id,
985            ExecutionStatus::Failed,
986            serde_json::json!({"error": error}),
987        )
988    }
989
990    fn update_deferred_tool(
991        &mut self,
992        deferred_id: &str,
993        status: ExecutionStatus,
994        response: Value,
995    ) -> CliResult<DeferredToolRecord> {
996        let mut deferred = self.load_deferred_tool(deferred_id)?;
997        deferred.status = status;
998        deferred.response = response;
999        deferred.updated_at = Utc::now();
1000        let tx = self
1001            .conn
1002            .transaction_with_behavior(TransactionBehavior::Immediate)?;
1003        insert_deferred_tool_records_tx(&tx, &[deferred.clone()])?;
1004        tx.commit()?;
1005        Ok(deferred)
1006    }
1007}