Skip to main content

starweaver_cli/
local_store.rs

1//! CLI persistence facade over shared `SQLite` storage and product-owned JSON blobs.
2
3use 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
38/// Local product facade backed by the workspace-wide canonical `SQLite` schema.
39pub struct LocalStore {
40    storage: SqliteStorage,
41    file_store_path: PathBuf,
42}
43
44/// Durable artifacts captured when a CLI run finishes or waits.
45pub struct RunArtifacts {
46    /// Final context state.
47    pub state: ResumableState,
48    /// Environment state snapshot.
49    pub environment_state: Option<EnvironmentState>,
50    /// Raw runtime records.
51    pub raw_records: Vec<AgentStreamRecord>,
52    /// Display messages.
53    pub display_messages: Vec<DisplayMessage>,
54    /// Compact display snapshot.
55    pub display_snapshot: ReplaySnapshot,
56    /// Approval records.
57    pub approvals: Vec<ApprovalRecord>,
58    /// Deferred tool records.
59    pub deferred_tools: Vec<DeferredToolRecord>,
60    /// Terminal status selected by HITL and runtime policy.
61    pub status: RunStatus,
62}
63
64/// Session summary row.
65#[derive(Clone, Debug, Serialize)]
66pub struct SessionSummary {
67    /// Session id.
68    pub session_id: String,
69    /// Title.
70    pub title: Option<String>,
71    /// Profile.
72    pub profile: Option<String>,
73    /// Status.
74    pub status: String,
75    /// Head run id.
76    pub head_run_id: Option<String>,
77    /// Head successful run id.
78    pub head_success_run_id: Option<String>,
79    /// Active run id.
80    pub active_run_id: Option<String>,
81    /// Run count.
82    pub run_count: usize,
83    /// Last output preview.
84    pub last_output_preview: Option<String>,
85    /// Creation time.
86    pub created_at: String,
87    /// Last update time.
88    pub updated_at: String,
89}
90
91/// Run summary row.
92#[derive(Clone, Debug, Serialize)]
93pub struct RunSummary {
94    /// Run id.
95    pub run_id: String,
96    /// Sequence number.
97    pub sequence_no: usize,
98    /// Run status.
99    pub status: String,
100    /// Restore source run id.
101    pub restore_from_run_id: Option<String>,
102    /// Output preview.
103    pub output_preview: Option<String>,
104    /// Creation time.
105    pub created_at: String,
106    /// Last update time.
107    pub updated_at: String,
108}
109
110/// Trim report.
111#[derive(Clone, Debug, Default, Serialize)]
112pub struct TrimReport {
113    /// Sessions scanned.
114    pub sessions_scanned: usize,
115    /// Runs selected for trimming.
116    pub runs_to_trim: usize,
117    /// Runs trimmed.
118    pub runs_trimmed: usize,
119    /// Bytes reclaimed from file store.
120    pub bytes_reclaimed: u64,
121    /// Dry-run flag.
122    pub dry_run: bool,
123}
124
125impl LocalStore {
126    /// Open canonical shared storage and the CLI-owned blob directory.
127    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    /// Create a session.
136    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    /// Load a session.
147    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    /// Resolve an exact session id or unique prefix.
154    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    /// Delete one session and its shared durable evidence plus CLI-owned blobs.
168    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    /// Load a run.
187    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    /// Latest active session.
197    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    /// Append a queued run and update session pointers atomically.
207    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    /// Complete or pause a run and atomically commit shared durable evidence.
224    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        // Compatibility mirrors are written only after the canonical SQLite evidence commit.
280        // No durable record points at these mutable files, so a mirror failure cannot expose a
281        // partially committed run or invalidate the previous database revision.
282        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    /// Fail a run atomically.
292    pub fn fail_run(&mut self, run: &mut RunRecord, message: String) -> CliResult<()> {
293        self.fail_run_with_messages(run, message, &[])
294    }
295
296    /// Fail a run and persist terminal display evidence.
297    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    /// Load the latest saved state for a continuation source.
318    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    /// List session summaries.
465    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    /// List run summaries in sequence order, retaining the newest `limit` runs.
496    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    /// Replay display messages for a session or run.
508    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    /// Trim old runs for selected sessions.
522    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    /// Trim old runs for selected sessions and optional age horizon.
532    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    /// Return all session ids.
583    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    /// List persisted approval records.
634    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    /// Load one approval record.
647    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    /// Record an approval decision.
654    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    /// List persisted deferred tool records.
671    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    /// Load one deferred tool record.
684    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    /// Complete one deferred tool record.
691    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    /// Fail one deferred tool record.
702    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}