1use 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
46pub 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
61pub struct RunArtifacts {
63 pub state: ResumableState,
65 pub environment_state: Option<EnvironmentState>,
67 pub raw_records: Vec<AgentStreamRecord>,
69 pub display_messages: Vec<DisplayMessage>,
71 pub display_snapshot: ReplaySnapshot,
73 pub approvals: Vec<ApprovalRecord>,
75 pub deferred_tools: Vec<DeferredToolRecord>,
77 pub status: RunStatus,
79}
80
81#[derive(Clone, Debug, Serialize)]
83pub struct SessionSummary {
84 pub session_id: String,
86 pub title: Option<String>,
88 pub profile: Option<String>,
90 pub status: String,
92 pub head_run_id: Option<String>,
94 pub head_success_run_id: Option<String>,
96 pub active_run_id: Option<String>,
98 pub run_count: usize,
100 pub last_output_preview: Option<String>,
102 pub created_at: String,
104 pub updated_at: String,
106}
107
108#[derive(Clone, Debug, Serialize)]
110pub struct RunSummary {
111 pub run_id: String,
113 pub sequence_no: usize,
115 pub status: String,
117 pub restore_from_run_id: Option<String>,
119 pub output_preview: Option<String>,
121 pub created_at: String,
123 pub updated_at: String,
125}
126
127#[derive(Clone, Debug, Default, Serialize)]
129pub struct TrimReport {
130 pub sessions_scanned: usize,
132 pub runs_to_trim: usize,
134 pub runs_trimmed: usize,
136 pub bytes_reclaimed: u64,
138 pub dry_run: bool,
140}
141
142impl LocalStore {
143 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}