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