1use 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
40pub 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
55pub struct RunArtifacts {
57 pub state: ResumableState,
59 pub environment_state: Option<EnvironmentState>,
61 pub raw_records: Vec<AgentStreamRecord>,
63 pub display_messages: Vec<DisplayMessage>,
65 pub display_snapshot: ReplaySnapshot,
67 pub approvals: Vec<ApprovalRecord>,
69 pub deferred_tools: Vec<DeferredToolRecord>,
71 pub status: RunStatus,
73}
74
75#[derive(Clone, Debug, Serialize)]
77pub struct SessionSummary {
78 pub session_id: String,
80 pub title: Option<String>,
82 pub profile: Option<String>,
84 pub status: String,
86 pub head_run_id: Option<String>,
88 pub head_success_run_id: Option<String>,
90 pub active_run_id: Option<String>,
92 pub run_count: usize,
94 pub last_output_preview: Option<String>,
96 pub created_at: String,
98 pub updated_at: String,
100}
101
102#[derive(Clone, Debug, Serialize)]
104pub struct RunSummary {
105 pub run_id: String,
107 pub sequence_no: usize,
109 pub status: String,
111 pub restore_from_run_id: Option<String>,
113 pub output_preview: Option<String>,
115 pub created_at: String,
117 pub updated_at: String,
119}
120
121#[derive(Clone, Debug, Default, Serialize)]
123pub struct TrimReport {
124 pub sessions_scanned: usize,
126 pub runs_to_trim: usize,
128 pub runs_trimmed: usize,
130 pub bytes_reclaimed: u64,
132 pub dry_run: bool,
134}
135
136impl LocalStore {
137 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}