1use std::{collections::BTreeSet, fs, path::Path, path::PathBuf};
4
5use chrono::Utc;
6use serde::Serialize;
7use serde_json::Value;
8use starweaver_agent::ResumableState;
9use starweaver_core::{ConversationId, RunId, SessionId};
10use starweaver_environment::EnvironmentState;
11use starweaver_model::{ModelMessage, ModelRequest, ModelRequestPart, ToolReturnPart};
12use starweaver_runtime::{AgentStreamEvent, AgentStreamRecord};
13use starweaver_session::{
14 ApprovalRecord, ApprovalStatus, DeferredToolRecord, EnvironmentStateRef, ExecutionStatus,
15 InputPart, RunRecord, RunStatus, SessionRecord, SessionStatus, SessionStoreError,
16 StreamCursorRef,
17};
18use starweaver_storage::{RunEvidenceCommit, SqliteStorage};
19use starweaver_stream::{DisplayMessage, ReplayCursor, ReplayScope, ReplaySnapshot};
20use uuid::Uuid;
21
22use crate::{CliError, CliResult, config::CliConfig, error::io_error};
23
24mod archive;
25mod hitl;
26mod replay;
27mod session_store;
28
29pub use archive::LocalStreamArchive;
30use hitl::{
31 approval_tool_return, deferred_status_is_unresolved, deferred_tool_return,
32 existing_resume_tool_return_ids, latest_tool_call_order, pending_hitl_resume_error,
33 tool_return_control_flow,
34};
35pub use replay::DisplayReplayWindow;
36pub use session_store::LocalSessionStore;
37
38pub struct LocalStore {
40 storage: SqliteStorage,
41 file_store_path: PathBuf,
42}
43
44pub struct RunArtifacts {
46 pub state: ResumableState,
48 pub environment_state: Option<EnvironmentState>,
50 pub raw_records: Vec<AgentStreamRecord>,
52 pub display_messages: Vec<DisplayMessage>,
54 pub display_snapshot: ReplaySnapshot,
56 pub approvals: Vec<ApprovalRecord>,
58 pub deferred_tools: Vec<DeferredToolRecord>,
60 pub status: RunStatus,
62}
63
64#[derive(Clone, Debug, Serialize)]
66pub struct SessionSummary {
67 pub session_id: String,
69 pub title: Option<String>,
71 pub profile: Option<String>,
73 pub status: String,
75 pub head_run_id: Option<String>,
77 pub head_success_run_id: Option<String>,
79 pub active_run_id: Option<String>,
81 pub run_count: usize,
83 pub last_output_preview: Option<String>,
85 pub created_at: String,
87 pub updated_at: String,
89}
90
91#[derive(Clone, Debug, Serialize)]
93pub struct RunSummary {
94 pub run_id: String,
96 pub sequence_no: usize,
98 pub status: String,
100 pub restore_from_run_id: Option<String>,
102 pub output_preview: Option<String>,
104 pub created_at: String,
106 pub updated_at: String,
108}
109
110#[derive(Clone, Debug, Default, Serialize)]
112pub struct TrimReport {
113 pub sessions_scanned: usize,
115 pub runs_to_trim: usize,
117 pub runs_trimmed: usize,
119 pub bytes_reclaimed: u64,
121 pub dry_run: bool,
123}
124
125impl LocalStore {
126 pub fn open(config: &CliConfig) -> CliResult<Self> {
128 crate::config::ensure_config_dirs(config)?;
129 Ok(Self {
130 storage: SqliteStorage::open(&config.database_path).map_err(storage_error)?,
131 file_store_path: config.file_store_path.clone(),
132 })
133 }
134
135 pub fn create_session(
137 &mut self,
138 profile: &str,
139 title: Option<String>,
140 ) -> CliResult<SessionRecord> {
141 self.storage
142 .create_session(Some(profile.to_string()), title)
143 .map_err(storage_error)
144 }
145
146 pub fn load_session(&self, session_id: &str) -> CliResult<SessionRecord> {
148 self.storage
149 .load_session(&SessionId::from_string(session_id))
150 .map_err(storage_error)
151 }
152
153 pub fn resolve_session_prefix(&self, value: &str) -> CliResult<String> {
155 self.storage
156 .resolve_session_prefix(value)
157 .map(|session_id| session_id.as_str().to_string())
158 .map_err(|error| match error {
159 SessionStoreError::NotFound(_) => CliError::NotFound(value.to_string()),
160 SessionStoreError::Failed(message) if message.contains("ambiguous") => {
161 CliError::Usage(message)
162 }
163 other => storage_error(other),
164 })
165 }
166
167 pub fn delete_session(&mut self, session_id: &str) -> CliResult<bool> {
169 let session_id = SessionId::from_string(session_id);
170 let deleted = self
171 .storage
172 .delete_session(&session_id)
173 .map_err(storage_error)?;
174 if deleted {
175 let path = self
176 .file_store_path
177 .join("sessions")
178 .join(session_id.as_str());
179 if path.exists() {
180 fs::remove_dir_all(&path).map_err(|error| io_error(&path, error))?;
181 }
182 }
183 Ok(deleted)
184 }
185
186 pub fn load_run(&self, session_id: &str, run_id: &str) -> CliResult<RunRecord> {
188 self.storage
189 .load_run(
190 &SessionId::from_string(session_id),
191 &RunId::from_string(run_id),
192 )
193 .map_err(storage_error)
194 }
195
196 pub fn latest_session(&self) -> CliResult<Option<SessionRecord>> {
198 Ok(self
199 .storage
200 .list_sessions()
201 .map_err(storage_error)?
202 .into_iter()
203 .find(|session| session.status == SessionStatus::Active))
204 }
205
206 pub fn append_run(
208 &mut self,
209 session_id: &str,
210 prompt: String,
211 restore_from_run_id: Option<String>,
212 profile: &str,
213 ) -> CliResult<RunRecord> {
214 let session = self.load_session(session_id)?;
215 let mut run = RunRecord::new(session.session_id, RunId::new(), ConversationId::new());
216 run.restore_from_run_id = restore_from_run_id.map(RunId::from_string);
217 run.trigger_type = Some("cli".to_string());
218 run.profile = Some(profile.to_string());
219 run.input = vec![InputPart::text(prompt)];
220 self.storage.begin_run(run).map_err(storage_error)
221 }
222
223 pub fn complete_run(
225 &mut self,
226 run: &mut RunRecord,
227 output: String,
228 artifacts: RunArtifacts,
229 ) -> CliResult<Vec<DisplayMessage>> {
230 let scope = ReplayScope::run(run.run_id.as_str());
231 let mut display_snapshot = artifacts.display_snapshot.clone();
232 if display_snapshot.scope.is_none() {
233 display_snapshot.scope = Some(scope.clone());
234 }
235 let raw_cursor = artifacts.raw_records.last().map(|record| {
236 StreamCursorRef::new(ReplayCursor::raw_runtime(scope.clone(), record.sequence))
237 });
238 let display_cursor = artifacts.display_messages.last().map(|message| {
239 StreamCursorRef::new(ReplayCursor::display(scope.clone(), message.sequence))
240 });
241 let environment_ref =
242 artifacts
243 .environment_state
244 .as_ref()
245 .map(|state| EnvironmentStateRef {
246 provider: state.provider_id.clone(),
247 reference: format!(
248 "sqlite:run_environment_records/{}/{}",
249 run.session_id.as_str(),
250 run.run_id.as_str()
251 ),
252 revision: Some(format!("{}", state.files.len() + state.resources.len())),
253 metadata: state.metadata.clone(),
254 });
255 run.status = artifacts.status;
256 run.output_preview = Some(output);
257 run.updated_at = Utc::now();
258 run.environment_state.clone_from(&environment_ref);
259 run.stream_cursors = raw_cursor.into_iter().chain(display_cursor).collect();
260
261 let mut commit = RunEvidenceCommit::new(run.clone(), artifacts.state.clone());
262 commit.environment_state = artifacts
263 .environment_state
264 .as_ref()
265 .map(EnvironmentState::to_json);
266 commit.stream_records.clone_from(&artifacts.raw_records);
267 commit.approvals.clone_from(&artifacts.approvals);
268 commit.deferred_tools.clone_from(&artifacts.deferred_tools);
269 commit.stream_cursors.clone_from(&run.stream_cursors);
270 commit
271 .display_messages
272 .clone_from(&artifacts.display_messages);
273 commit.display_snapshot = Some(display_snapshot.clone());
274 *run = self
275 .storage
276 .commit_run_evidence(commit)
277 .map_err(storage_error)?;
278
279 self.write_run_blob(run, "raw.stream.json", &artifacts.raw_records)?;
283 self.write_run_blob(run, "display.compact.json", &display_snapshot)?;
284 self.write_run_blob(run, "context.state.json", &artifacts.state)?;
285 if let Some(environment_state) = artifacts.environment_state.as_ref() {
286 self.write_run_blob(run, "environment.state.json", &environment_state.to_json())?;
287 }
288 Ok(artifacts.display_messages)
289 }
290
291 pub fn fail_run(&mut self, run: &mut RunRecord, message: String) -> CliResult<()> {
293 self.fail_run_with_messages(run, message, &[])
294 }
295
296 pub fn fail_run_with_messages(
298 &mut self,
299 run: &mut RunRecord,
300 message: String,
301 messages: &[DisplayMessage],
302 ) -> CliResult<()> {
303 let session = self.load_session(run.session_id.as_str())?;
304 run.status = RunStatus::Failed;
305 run.output_preview = Some(message);
306 run.updated_at = Utc::now();
307 let mut commit = RunEvidenceCommit::new(run.clone(), session.state);
308 commit.display_messages = messages.to_vec();
309 *run = self
310 .storage
311 .commit_run_evidence(commit)
312 .map_err(storage_error)?;
313 self.write_run_blob(run, "display.compact.json", &messages)?;
314 Ok(())
315 }
316
317 pub fn load_restore_state(
319 &self,
320 session_id: &str,
321 run_id: Option<&str>,
322 ) -> CliResult<Option<ResumableState>> {
323 let Some(run_id) = run_id else {
324 return Ok(Some(self.load_session(session_id)?.state));
325 };
326 let mut state = self
327 .storage
328 .load_run_context(
329 &SessionId::from_string(session_id),
330 &RunId::from_string(run_id),
331 )
332 .map_err(storage_error)?;
333 if let Some(state) = state.as_mut() {
334 self.inject_resolved_hitl_tool_returns(session_id, run_id, state)?;
335 }
336 Ok(state)
337 }
338
339 fn inject_resolved_hitl_tool_returns(
340 &self,
341 session_id: &str,
342 run_id: &str,
343 state: &mut ResumableState,
344 ) -> CliResult<()> {
345 let mut existing_returns = existing_resume_tool_return_ids(&state.message_history);
346 let tool_call_order = latest_tool_call_order(&state.message_history);
347 let latest_tool_call_ids = tool_call_order.iter().cloned().collect::<BTreeSet<_>>();
348 let approvals = self.list_approvals(Some(session_id), Some(run_id))?;
349 let deferred_tools = self.list_deferred_tools(Some(session_id), Some(run_id))?;
350 let pending_approvals = approvals
351 .iter()
352 .filter(|approval| {
353 approval.status == ApprovalStatus::Pending
354 && !existing_returns.contains(&approval.action_id)
355 })
356 .map(|approval| approval.approval_id.clone())
357 .collect::<Vec<_>>();
358 let pending_deferred = deferred_tools
359 .iter()
360 .filter(|deferred| {
361 deferred_status_is_unresolved(deferred.status)
362 && !existing_returns.contains(&deferred.tool_call_id)
363 })
364 .map(|deferred| deferred.deferred_id.clone())
365 .collect::<Vec<_>>();
366 if !pending_approvals.is_empty() || !pending_deferred.is_empty() {
367 return Err(pending_hitl_resume_error(
368 run_id,
369 &pending_approvals,
370 &pending_deferred,
371 ));
372 }
373
374 let mut resolved = Vec::<(String, ModelRequestPart)>::new();
375 for tool_return in self.list_run_tool_returns(session_id, run_id)? {
376 if !latest_tool_call_ids.contains(&tool_return.tool_call_id)
377 || tool_return_control_flow(&tool_return).is_some()
378 || existing_returns.contains(&tool_return.tool_call_id)
379 {
380 continue;
381 }
382 existing_returns.insert(tool_return.tool_call_id.clone());
383 resolved.push((
384 tool_return.tool_call_id.clone(),
385 ModelRequestPart::ToolReturn(tool_return),
386 ));
387 }
388 for approval in approvals {
389 if existing_returns.contains(&approval.action_id) {
390 continue;
391 }
392 if let Some(tool_return) = approval_tool_return(&approval) {
393 existing_returns.insert(approval.action_id.clone());
394 resolved.push((
395 approval.action_id.clone(),
396 ModelRequestPart::ToolReturn(tool_return),
397 ));
398 }
399 }
400 for deferred in deferred_tools {
401 if existing_returns.contains(&deferred.tool_call_id) {
402 continue;
403 }
404 if let Some(tool_return) = deferred_tool_return(&deferred) {
405 existing_returns.insert(deferred.tool_call_id.clone());
406 resolved.push((
407 deferred.tool_call_id.clone(),
408 ModelRequestPart::ToolReturn(tool_return),
409 ));
410 }
411 }
412 if resolved.is_empty() {
413 return Ok(());
414 }
415 resolved.sort_by_key(|(tool_call_id, _)| {
416 tool_call_order
417 .iter()
418 .position(|known| known == tool_call_id)
419 .unwrap_or(usize::MAX)
420 });
421 let mut metadata = serde_json::Map::new();
422 metadata.insert(
423 "starweaver.resume.hitl_results".to_string(),
424 serde_json::json!(true),
425 );
426 metadata.insert(
427 "starweaver.resume.source_run_id".to_string(),
428 serde_json::json!(run_id),
429 );
430 state
431 .message_history
432 .push(ModelMessage::Request(ModelRequest {
433 parts: resolved.into_iter().map(|(_, part)| part).collect(),
434 timestamp: Some(Utc::now()),
435 instructions: None,
436 run_id: Some(RunId::from_string(run_id)),
437 conversation_id: state.conversation_id.clone(),
438 metadata,
439 }));
440 Ok(())
441 }
442
443 fn list_run_tool_returns(
444 &self,
445 session_id: &str,
446 run_id: &str,
447 ) -> CliResult<Vec<ToolReturnPart>> {
448 let records = self
449 .storage
450 .load_stream_records(
451 &SessionId::from_string(session_id),
452 &RunId::from_string(run_id),
453 )
454 .map_err(storage_error)?;
455 Ok(records
456 .into_iter()
457 .filter_map(|record| match record.event {
458 AgentStreamEvent::ToolReturn { tool_return, .. } => Some(tool_return),
459 _ => None,
460 })
461 .collect())
462 }
463
464 pub fn list_sessions(&self, limit: usize) -> CliResult<Vec<SessionSummary>> {
466 self.storage
467 .list_sessions()
468 .map_err(storage_error)?
469 .into_iter()
470 .take(limit)
471 .map(|session| {
472 let runs = self
473 .storage
474 .list_runs(&session.session_id)
475 .map_err(storage_error)?;
476 Ok(SessionSummary {
477 session_id: session.session_id.as_str().to_string(),
478 title: session.title,
479 profile: session.profile,
480 status: session_status_name(session.status).to_string(),
481 head_run_id: session.head_run_id.map(|id| id.as_str().to_string()),
482 head_success_run_id: session
483 .head_success_run_id
484 .map(|id| id.as_str().to_string()),
485 active_run_id: session.active_run_id.map(|id| id.as_str().to_string()),
486 run_count: runs.len(),
487 last_output_preview: runs.last().and_then(|run| run.output_preview.clone()),
488 created_at: session.created_at.to_rfc3339(),
489 updated_at: session.updated_at.to_rfc3339(),
490 })
491 })
492 .collect()
493 }
494
495 pub fn list_runs(&self, session_id: &str, limit: usize) -> CliResult<Vec<RunSummary>> {
497 let mut runs = self
498 .storage
499 .list_runs(&SessionId::from_string(session_id))
500 .map_err(storage_error)?;
501 if runs.len() > limit {
502 runs.drain(..runs.len() - limit);
503 }
504 Ok(runs.into_iter().map(run_summary).collect())
505 }
506
507 pub fn replay_display(
509 &self,
510 session_id: &str,
511 run_id: Option<&str>,
512 after: Option<usize>,
513 ) -> CliResult<Vec<DisplayMessage>> {
514 let session_id = SessionId::from_string(session_id);
515 let run_id = run_id.map(RunId::from_string);
516 self.storage
517 .load_display_messages(&session_id, run_id.as_ref(), after)
518 .map_err(storage_error)
519 }
520
521 pub fn trim(
523 &mut self,
524 sessions: Vec<String>,
525 keep_runs: usize,
526 dry_run: bool,
527 ) -> CliResult<TrimReport> {
528 self.trim_with_age(sessions, keep_runs, None, dry_run)
529 }
530
531 pub fn trim_with_age(
533 &mut self,
534 sessions: Vec<String>,
535 keep_runs: usize,
536 older_than: Option<chrono::Duration>,
537 dry_run: bool,
538 ) -> CliResult<TrimReport> {
539 let mut report = TrimReport {
540 sessions_scanned: sessions.len(),
541 dry_run,
542 ..TrimReport::default()
543 };
544 let cutoff = older_than.map(|duration| Utc::now() - duration);
545 for session_id in sessions {
546 let session_id = SessionId::from_string(session_id);
547 let session = self
548 .storage
549 .load_session(&session_id)
550 .map_err(storage_error)?;
551 let runs = self.storage.list_runs(&session_id).map_err(storage_error)?;
552 let keep_from = runs.len().saturating_sub(keep_runs);
553 let candidates = runs
554 .into_iter()
555 .take(keep_from)
556 .filter(|run| session.active_run_id.as_ref() != Some(&run.run_id))
557 .filter(|run| cutoff.is_none_or(|cutoff| run.updated_at < cutoff))
558 .collect::<Vec<_>>();
559 report.runs_to_trim += candidates.len();
560 for run in &candidates {
561 report.bytes_reclaimed = report
562 .bytes_reclaimed
563 .saturating_add(self.run_file_bytes(session_id.as_str(), run.run_id.as_str())?);
564 }
565 if !dry_run && !candidates.is_empty() {
566 let run_ids = candidates
567 .iter()
568 .map(|run| run.run_id.clone())
569 .collect::<Vec<_>>();
570 report.runs_trimmed += self
571 .storage
572 .prune_runs(&session_id, &run_ids)
573 .map_err(storage_error)?;
574 for run_id in run_ids {
575 self.remove_run_files(session_id.as_str(), run_id.as_str())?;
576 }
577 }
578 }
579 Ok(report)
580 }
581
582 pub fn all_session_ids(&self) -> CliResult<Vec<String>> {
584 Ok(self
585 .storage
586 .list_sessions()
587 .map_err(storage_error)?
588 .into_iter()
589 .map(|session| session.session_id.as_str().to_string())
590 .collect())
591 }
592
593 fn write_run_blob<T: Serialize>(
594 &self,
595 run: &RunRecord,
596 name: &str,
597 value: &T,
598 ) -> CliResult<()> {
599 let path = self
600 .file_store_path
601 .join("sessions")
602 .join(run.session_id.as_str())
603 .join("runs")
604 .join(run.run_id.as_str())
605 .join(name);
606 atomic_write_json(&path, value)
607 }
608
609 fn run_file_bytes(&self, session_id: &str, run_id: &str) -> CliResult<u64> {
610 directory_bytes(
611 &self
612 .file_store_path
613 .join("sessions")
614 .join(session_id)
615 .join("runs")
616 .join(run_id),
617 )
618 }
619
620 fn remove_run_files(&self, session_id: &str, run_id: &str) -> CliResult<()> {
621 let path = self
622 .file_store_path
623 .join("sessions")
624 .join(session_id)
625 .join("runs")
626 .join(run_id);
627 if path.exists() {
628 fs::remove_dir_all(&path).map_err(|error| io_error(&path, error))?;
629 }
630 Ok(())
631 }
632
633 pub fn list_approvals(
635 &self,
636 session_id: Option<&str>,
637 run_id: Option<&str>,
638 ) -> CliResult<Vec<ApprovalRecord>> {
639 let session_id = session_id.map(SessionId::from_string);
640 let run_id = run_id.map(RunId::from_string);
641 self.storage
642 .list_approvals(session_id.as_ref(), run_id.as_ref())
643 .map_err(storage_error)
644 }
645
646 pub fn load_approval(&self, approval_id: &str) -> CliResult<ApprovalRecord> {
648 self.storage
649 .load_approval(approval_id)
650 .map_err(storage_error)
651 }
652
653 pub fn decide_approval(
655 &mut self,
656 approval_id: &str,
657 status: ApprovalStatus,
658 reason: Option<String>,
659 ) -> CliResult<ApprovalRecord> {
660 self.storage
661 .decide_approval(
662 approval_id,
663 status,
664 Some("starweaver-cli".to_string()),
665 reason,
666 )
667 .map_err(storage_error)
668 }
669
670 pub fn list_deferred_tools(
672 &self,
673 session_id: Option<&str>,
674 run_id: Option<&str>,
675 ) -> CliResult<Vec<DeferredToolRecord>> {
676 let session_id = session_id.map(SessionId::from_string);
677 let run_id = run_id.map(RunId::from_string);
678 self.storage
679 .list_deferred_tools(session_id.as_ref(), run_id.as_ref())
680 .map_err(storage_error)
681 }
682
683 pub fn load_deferred_tool(&self, deferred_id: &str) -> CliResult<DeferredToolRecord> {
685 self.storage
686 .load_deferred_tool(deferred_id)
687 .map_err(storage_error)
688 }
689
690 pub fn complete_deferred_tool(
692 &mut self,
693 deferred_id: &str,
694 response: Value,
695 ) -> CliResult<DeferredToolRecord> {
696 self.storage
697 .resolve_deferred_tool(deferred_id, ExecutionStatus::Completed, response)
698 .map_err(storage_error)
699 }
700
701 pub fn fail_deferred_tool(
703 &mut self,
704 deferred_id: &str,
705 error: &str,
706 ) -> CliResult<DeferredToolRecord> {
707 self.storage
708 .resolve_deferred_tool(
709 deferred_id,
710 ExecutionStatus::Failed,
711 serde_json::json!({"error": error}),
712 )
713 .map_err(storage_error)
714 }
715}
716
717fn run_summary(run: RunRecord) -> RunSummary {
718 RunSummary {
719 run_id: run.run_id.as_str().to_string(),
720 sequence_no: run.sequence_no,
721 status: run_status_name(run.status).to_string(),
722 restore_from_run_id: run
723 .restore_from_run_id
724 .map(|run_id| run_id.as_str().to_string()),
725 output_preview: run.output_preview,
726 created_at: run.created_at.to_rfc3339(),
727 updated_at: run.updated_at.to_rfc3339(),
728 }
729}
730
731const fn run_status_name(status: RunStatus) -> &'static str {
732 status.as_str()
733}
734
735const fn session_status_name(status: SessionStatus) -> &'static str {
736 match status {
737 SessionStatus::Active => "active",
738 SessionStatus::Archived => "archived",
739 SessionStatus::Failed => "failed",
740 }
741}
742
743fn storage_error(error: SessionStoreError) -> CliError {
744 match error {
745 SessionStoreError::NotFound(value) => CliError::NotFound(value),
746 other => CliError::Storage(other.to_string()),
747 }
748}
749
750fn atomic_write_json<T: Serialize>(path: &Path, value: &T) -> CliResult<()> {
751 let parent = path
752 .parent()
753 .ok_or_else(|| CliError::Storage("missing parent path".to_string()))?;
754 fs::create_dir_all(parent).map_err(|error| io_error(parent, error))?;
755 let temp = path.with_extension(format!("{}.tmp", Uuid::new_v4()));
756 fs::write(&temp, serde_json::to_vec_pretty(value)?).map_err(|error| io_error(&temp, error))?;
757 fs::rename(&temp, path).map_err(|error| io_error(path, error))
758}
759
760fn directory_bytes(path: &Path) -> CliResult<u64> {
761 if !path.exists() {
762 return Ok(0);
763 }
764 let mut total = 0_u64;
765 for entry in fs::read_dir(path).map_err(|error| io_error(path, error))? {
766 let entry = entry.map_err(|error| io_error(path, error))?;
767 let entry_path = entry.path();
768 let metadata = entry
769 .metadata()
770 .map_err(|error| io_error(&entry_path, error))?;
771 if metadata.is_dir() {
772 total = total.saturating_add(directory_bytes(&entry_path)?);
773 } else {
774 total = total.saturating_add(metadata.len());
775 }
776 }
777 Ok(total)
778}