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