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, sync::Arc};
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, SessionSearchError, SessionSearchPage,
16    SessionSearchProvider, SessionSearchQuery, SessionSearchScope, SessionStatus,
17    SessionStoreError, StreamCursorRef,
18};
19use starweaver_storage::{LocalSessionSearchProvider, RunEvidenceCommit, SqliteStorage};
20use starweaver_stream::{DisplayMessage, ReplayCursor, ReplayScope, ReplaySnapshot};
21use uuid::Uuid;
22
23use crate::{CliError, CliResult, config::CliConfig, error::io_error};
24
25mod archive;
26mod hitl;
27mod replay;
28mod session_store;
29
30pub use archive::LocalStreamArchive;
31use hitl::{
32    approval_tool_return, deferred_status_is_unresolved, deferred_tool_return,
33    existing_resume_tool_return_ids, latest_tool_call_order, pending_hitl_resume_error,
34    tool_return_control_flow,
35};
36pub use replay::DisplayReplayWindow;
37pub use session_store::LocalSessionStore;
38
39/// Local product facade backed by the workspace-wide canonical `SQLite` schema.
40pub struct LocalStore {
41    storage: SqliteStorage,
42    file_store_path: PathBuf,
43    search_scope: SessionSearchScope,
44}
45
46/// Durable artifacts captured when a CLI run finishes or waits.
47pub struct RunArtifacts {
48    /// Final context state.
49    pub state: ResumableState,
50    /// Environment state snapshot.
51    pub environment_state: Option<EnvironmentState>,
52    /// Raw runtime records.
53    pub raw_records: Vec<AgentStreamRecord>,
54    /// Display messages.
55    pub display_messages: Vec<DisplayMessage>,
56    /// Compact display snapshot.
57    pub display_snapshot: ReplaySnapshot,
58    /// Approval records.
59    pub approvals: Vec<ApprovalRecord>,
60    /// Deferred tool records.
61    pub deferred_tools: Vec<DeferredToolRecord>,
62    /// Terminal status selected by HITL and runtime policy.
63    pub status: RunStatus,
64}
65
66/// Session summary row.
67#[derive(Clone, Debug, Serialize)]
68pub struct SessionSummary {
69    /// Session id.
70    pub session_id: String,
71    /// Title.
72    pub title: Option<String>,
73    /// Profile.
74    pub profile: Option<String>,
75    /// Status.
76    pub status: String,
77    /// Head run id.
78    pub head_run_id: Option<String>,
79    /// Head successful run id.
80    pub head_success_run_id: Option<String>,
81    /// Active run id.
82    pub active_run_id: Option<String>,
83    /// Run count.
84    pub run_count: usize,
85    /// Last output preview.
86    pub last_output_preview: Option<String>,
87    /// Creation time.
88    pub created_at: String,
89    /// Last update time.
90    pub updated_at: String,
91}
92
93/// Run summary row.
94#[derive(Clone, Debug, Serialize)]
95pub struct RunSummary {
96    /// Run id.
97    pub run_id: String,
98    /// Sequence number.
99    pub sequence_no: usize,
100    /// Run status.
101    pub status: String,
102    /// Restore source run id.
103    pub restore_from_run_id: Option<String>,
104    /// Output preview.
105    pub output_preview: Option<String>,
106    /// Creation time.
107    pub created_at: String,
108    /// Last update time.
109    pub updated_at: String,
110}
111
112/// Trim report.
113#[derive(Clone, Debug, Default, Serialize)]
114pub struct TrimReport {
115    /// Sessions scanned.
116    pub sessions_scanned: usize,
117    /// Runs selected for trimming.
118    pub runs_to_trim: usize,
119    /// Runs trimmed.
120    pub runs_trimmed: usize,
121    /// Bytes reclaimed from file store.
122    pub bytes_reclaimed: u64,
123    /// Dry-run flag.
124    pub dry_run: bool,
125}
126
127impl LocalStore {
128    /// Open canonical shared storage and the CLI-owned blob directory.
129    pub fn open(config: &CliConfig) -> CliResult<Self> {
130        crate::config::ensure_config_dirs(config)?;
131        Ok(Self {
132            storage: SqliteStorage::open(&config.database_path).map_err(storage_error)?,
133            file_store_path: config.file_store_path.clone(),
134            search_scope: SessionSearchScope::local(
135                config.database_path.to_string_lossy().into_owned(),
136            ),
137        })
138    }
139
140    /// Create a session.
141    pub fn create_session(
142        &mut self,
143        profile: &str,
144        title: Option<String>,
145    ) -> CliResult<SessionRecord> {
146        self.storage
147            .create_session(Some(profile.to_string()), title)
148            .map_err(storage_error)
149    }
150
151    /// Load a session.
152    pub fn load_session(&self, session_id: &str) -> CliResult<SessionRecord> {
153        self.storage
154            .load_session(&SessionId::from_string(session_id))
155            .map_err(storage_error)
156    }
157
158    /// Resolve an exact session id or unique prefix.
159    pub fn resolve_session_prefix(&self, value: &str) -> CliResult<String> {
160        self.storage
161            .resolve_session_prefix(value)
162            .map(|session_id| session_id.as_str().to_string())
163            .map_err(|error| match error {
164                SessionStoreError::NotFound(_) => CliError::NotFound(value.to_string()),
165                SessionStoreError::Failed(message) if message.contains("ambiguous") => {
166                    CliError::Usage(message)
167                }
168                other => storage_error(other),
169            })
170    }
171
172    /// Delete one session and its shared durable evidence plus CLI-owned blobs.
173    pub fn delete_session(&mut self, session_id: &str) -> CliResult<bool> {
174        let session_id = SessionId::from_string(session_id);
175        let deleted = self
176            .storage
177            .delete_session(&session_id)
178            .map_err(storage_error)?;
179        if deleted {
180            let path = self
181                .file_store_path
182                .join("sessions")
183                .join(session_id.as_str());
184            if path.exists() {
185                fs::remove_dir_all(&path).map_err(|error| io_error(&path, error))?;
186            }
187        }
188        Ok(deleted)
189    }
190
191    /// Load a run.
192    pub fn load_run(&self, session_id: &str, run_id: &str) -> CliResult<RunRecord> {
193        self.storage
194            .load_run(
195                &SessionId::from_string(session_id),
196                &RunId::from_string(run_id),
197            )
198            .map_err(storage_error)
199    }
200
201    /// Latest active session.
202    pub fn latest_session(&self) -> CliResult<Option<SessionRecord>> {
203        Ok(self
204            .storage
205            .list_sessions()
206            .map_err(storage_error)?
207            .into_iter()
208            .find(|session| session.status == SessionStatus::Active))
209    }
210
211    /// Append a queued run and update session pointers atomically.
212    pub fn append_run(
213        &mut self,
214        session_id: &str,
215        prompt: String,
216        restore_from_run_id: Option<String>,
217        profile: &str,
218    ) -> CliResult<RunRecord> {
219        let session = self.load_session(session_id)?;
220        let mut run = RunRecord::new(session.session_id, RunId::new(), ConversationId::new());
221        run.restore_from_run_id = restore_from_run_id.map(RunId::from_string);
222        run.trigger_type = Some("cli".to_string());
223        run.profile = Some(profile.to_string());
224        run.input = vec![InputPart::text(prompt)];
225        self.storage.begin_run(run).map_err(storage_error)
226    }
227
228    /// Complete or pause a run and atomically commit shared durable evidence.
229    pub fn complete_run(
230        &mut self,
231        run: &mut RunRecord,
232        output: String,
233        artifacts: RunArtifacts,
234    ) -> CliResult<Vec<DisplayMessage>> {
235        let scope = ReplayScope::run(run.run_id.as_str());
236        let mut display_snapshot = artifacts.display_snapshot.clone();
237        if display_snapshot.scope.is_none() {
238            display_snapshot.scope = Some(scope.clone());
239        }
240        let raw_cursor = artifacts.raw_records.last().map(|record| {
241            StreamCursorRef::new(ReplayCursor::raw_runtime(scope.clone(), record.sequence))
242        });
243        let display_cursor = artifacts.display_messages.last().map(|message| {
244            StreamCursorRef::new(ReplayCursor::display(scope.clone(), message.sequence))
245        });
246        let environment_ref =
247            artifacts
248                .environment_state
249                .as_ref()
250                .map(|state| EnvironmentStateRef {
251                    provider: state.provider_id.clone(),
252                    reference: format!(
253                        "sqlite:run_environment_records/{}/{}",
254                        run.session_id.as_str(),
255                        run.run_id.as_str()
256                    ),
257                    revision: Some(format!("{}", state.files.len() + state.resources.len())),
258                    metadata: state.metadata.clone(),
259                });
260        run.status = artifacts.status;
261        run.output_preview = Some(output);
262        run.updated_at = Utc::now();
263        run.environment_state.clone_from(&environment_ref);
264        run.stream_cursors = raw_cursor.into_iter().chain(display_cursor).collect();
265
266        let mut commit = RunEvidenceCommit::new(run.clone(), artifacts.state.clone());
267        commit.environment_state = artifacts
268            .environment_state
269            .as_ref()
270            .map(EnvironmentState::to_json);
271        commit.stream_records.clone_from(&artifacts.raw_records);
272        commit.approvals.clone_from(&artifacts.approvals);
273        commit.deferred_tools.clone_from(&artifacts.deferred_tools);
274        commit.stream_cursors.clone_from(&run.stream_cursors);
275        commit
276            .display_messages
277            .clone_from(&artifacts.display_messages);
278        commit.display_snapshot = Some(display_snapshot.clone());
279        *run = self
280            .storage
281            .commit_run_evidence(commit)
282            .map_err(storage_error)?;
283
284        // Compatibility mirrors are written only after the canonical SQLite evidence commit.
285        // No durable record points at these mutable files, so a mirror failure cannot expose a
286        // partially committed run or invalidate the previous database revision.
287        self.write_run_blob(run, "raw.stream.json", &artifacts.raw_records)?;
288        self.write_run_blob(run, "display.compact.json", &display_snapshot)?;
289        self.write_run_blob(run, "context.state.json", &artifacts.state)?;
290        if let Some(environment_state) = artifacts.environment_state.as_ref() {
291            self.write_run_blob(run, "environment.state.json", &environment_state.to_json())?;
292        }
293        Ok(artifacts.display_messages)
294    }
295
296    /// Fail a run atomically.
297    pub fn fail_run(&mut self, run: &mut RunRecord, message: String) -> CliResult<()> {
298        self.fail_run_with_messages(run, message, &[])
299    }
300
301    /// Fail a run and persist terminal display evidence.
302    pub fn fail_run_with_messages(
303        &mut self,
304        run: &mut RunRecord,
305        message: String,
306        messages: &[DisplayMessage],
307    ) -> CliResult<()> {
308        let session = self.load_session(run.session_id.as_str())?;
309        run.status = RunStatus::Failed;
310        run.output_preview = Some(message);
311        run.updated_at = Utc::now();
312        let mut commit = RunEvidenceCommit::new(run.clone(), session.state);
313        commit.display_messages = messages.to_vec();
314        *run = self
315            .storage
316            .commit_run_evidence(commit)
317            .map_err(storage_error)?;
318        self.write_run_blob(run, "display.compact.json", &messages)?;
319        Ok(())
320    }
321
322    /// Load the latest saved state for a continuation source.
323    pub fn load_restore_state(
324        &self,
325        session_id: &str,
326        run_id: Option<&str>,
327    ) -> CliResult<Option<ResumableState>> {
328        let Some(run_id) = run_id else {
329            return Ok(Some(self.load_session(session_id)?.state));
330        };
331        let mut state = self
332            .storage
333            .load_run_context(
334                &SessionId::from_string(session_id),
335                &RunId::from_string(run_id),
336            )
337            .map_err(storage_error)?;
338        if let Some(state) = state.as_mut() {
339            self.inject_resolved_hitl_tool_returns(session_id, run_id, state)?;
340        }
341        Ok(state)
342    }
343
344    fn inject_resolved_hitl_tool_returns(
345        &self,
346        session_id: &str,
347        run_id: &str,
348        state: &mut ResumableState,
349    ) -> CliResult<()> {
350        let mut existing_returns = existing_resume_tool_return_ids(&state.message_history);
351        let tool_call_order = latest_tool_call_order(&state.message_history);
352        let latest_tool_call_ids = tool_call_order.iter().cloned().collect::<BTreeSet<_>>();
353        let approvals = self.list_approvals(Some(session_id), Some(run_id))?;
354        let deferred_tools = self.list_deferred_tools(Some(session_id), Some(run_id))?;
355        let pending_approvals = approvals
356            .iter()
357            .filter(|approval| {
358                approval.status == ApprovalStatus::Pending
359                    && !existing_returns.contains(&approval.action_id)
360            })
361            .map(|approval| approval.approval_id.clone())
362            .collect::<Vec<_>>();
363        let pending_deferred = deferred_tools
364            .iter()
365            .filter(|deferred| {
366                deferred_status_is_unresolved(deferred.status)
367                    && !existing_returns.contains(&deferred.tool_call_id)
368            })
369            .map(|deferred| deferred.deferred_id.clone())
370            .collect::<Vec<_>>();
371        if !pending_approvals.is_empty() || !pending_deferred.is_empty() {
372            return Err(pending_hitl_resume_error(
373                run_id,
374                &pending_approvals,
375                &pending_deferred,
376            ));
377        }
378
379        let mut resolved = Vec::<(String, ModelRequestPart)>::new();
380        for tool_return in self.list_run_tool_returns(session_id, run_id)? {
381            if !latest_tool_call_ids.contains(&tool_return.tool_call_id)
382                || tool_return_control_flow(&tool_return).is_some()
383                || existing_returns.contains(&tool_return.tool_call_id)
384            {
385                continue;
386            }
387            existing_returns.insert(tool_return.tool_call_id.clone());
388            resolved.push((
389                tool_return.tool_call_id.clone(),
390                ModelRequestPart::ToolReturn(tool_return),
391            ));
392        }
393        for approval in approvals {
394            if existing_returns.contains(&approval.action_id) {
395                continue;
396            }
397            if let Some(tool_return) = approval_tool_return(&approval) {
398                existing_returns.insert(approval.action_id.clone());
399                resolved.push((
400                    approval.action_id.clone(),
401                    ModelRequestPart::ToolReturn(tool_return),
402                ));
403            }
404        }
405        for deferred in deferred_tools {
406            if existing_returns.contains(&deferred.tool_call_id) {
407                continue;
408            }
409            if let Some(tool_return) = deferred_tool_return(&deferred) {
410                existing_returns.insert(deferred.tool_call_id.clone());
411                resolved.push((
412                    deferred.tool_call_id.clone(),
413                    ModelRequestPart::ToolReturn(tool_return),
414                ));
415            }
416        }
417        if resolved.is_empty() {
418            return Ok(());
419        }
420        resolved.sort_by_key(|(tool_call_id, _)| {
421            tool_call_order
422                .iter()
423                .position(|known| known == tool_call_id)
424                .unwrap_or(usize::MAX)
425        });
426        let mut metadata = serde_json::Map::new();
427        metadata.insert(
428            "starweaver.resume.hitl_results".to_string(),
429            serde_json::json!(true),
430        );
431        metadata.insert(
432            "starweaver.resume.source_run_id".to_string(),
433            serde_json::json!(run_id),
434        );
435        state
436            .message_history
437            .push(ModelMessage::Request(ModelRequest {
438                parts: resolved.into_iter().map(|(_, part)| part).collect(),
439                timestamp: Some(Utc::now()),
440                instructions: None,
441                run_id: Some(RunId::from_string(run_id)),
442                conversation_id: state.conversation_id.clone(),
443                metadata,
444            }));
445        Ok(())
446    }
447
448    fn list_run_tool_returns(
449        &self,
450        session_id: &str,
451        run_id: &str,
452    ) -> CliResult<Vec<ToolReturnPart>> {
453        let records = self
454            .storage
455            .load_stream_records(
456                &SessionId::from_string(session_id),
457                &RunId::from_string(run_id),
458            )
459            .map_err(storage_error)?;
460        Ok(records
461            .into_iter()
462            .filter_map(|record| match record.event {
463                AgentStreamEvent::ToolReturn { tool_return, .. } => Some(tool_return),
464                _ => None,
465            })
466            .collect())
467    }
468
469    /// List session summaries.
470    pub fn list_sessions(&self, limit: usize) -> CliResult<Vec<SessionSummary>> {
471        self.storage
472            .list_sessions()
473            .map_err(storage_error)?
474            .into_iter()
475            .take(limit)
476            .map(|session| {
477                let runs = self
478                    .storage
479                    .list_runs(&session.session_id)
480                    .map_err(storage_error)?;
481                Ok(SessionSummary {
482                    session_id: session.session_id.as_str().to_string(),
483                    title: session.title,
484                    profile: session.profile,
485                    status: session_status_name(session.status).to_string(),
486                    head_run_id: session.head_run_id.map(|id| id.as_str().to_string()),
487                    head_success_run_id: session
488                        .head_success_run_id
489                        .map(|id| id.as_str().to_string()),
490                    active_run_id: session.active_run_id.map(|id| id.as_str().to_string()),
491                    run_count: runs.len(),
492                    last_output_preview: runs.last().and_then(|run| run.output_preview.clone()),
493                    created_at: session.created_at.to_rfc3339(),
494                    updated_at: session.updated_at.to_rfc3339(),
495                })
496            })
497            .collect()
498    }
499
500    /// Search canonical local sessions and approved compatibility display projections.
501    pub fn search_sessions(&self, query: SessionSearchQuery) -> CliResult<SessionSearchPage> {
502        let provider = LocalSessionSearchProvider::new(
503            Arc::new(self.storage.session_store()),
504            &self.search_scope,
505        )
506        .with_display_root(self.file_store_path.clone());
507        let runtime = tokio::runtime::Builder::new_current_thread()
508            .enable_all()
509            .build()
510            .map_err(|error| CliError::Run(error.to_string()))?;
511        runtime
512            .block_on(provider.search(&self.search_scope, query))
513            .map_err(search_error)
514    }
515
516    /// List run summaries in sequence order, retaining the newest `limit` runs.
517    pub fn list_runs(&self, session_id: &str, limit: usize) -> CliResult<Vec<RunSummary>> {
518        let mut runs = self
519            .storage
520            .list_runs(&SessionId::from_string(session_id))
521            .map_err(storage_error)?;
522        if runs.len() > limit {
523            runs.drain(..runs.len() - limit);
524        }
525        Ok(runs.into_iter().map(run_summary).collect())
526    }
527
528    /// Replay display messages for a session or run.
529    pub fn replay_display(
530        &self,
531        session_id: &str,
532        run_id: Option<&str>,
533        after: Option<usize>,
534    ) -> CliResult<Vec<DisplayMessage>> {
535        let session_id = SessionId::from_string(session_id);
536        let run_id = run_id.map(RunId::from_string);
537        self.storage
538            .load_display_messages(&session_id, run_id.as_ref(), after)
539            .map_err(storage_error)
540    }
541
542    /// Trim old runs for selected sessions.
543    pub fn trim(
544        &mut self,
545        sessions: Vec<String>,
546        keep_runs: usize,
547        dry_run: bool,
548    ) -> CliResult<TrimReport> {
549        self.trim_with_age(sessions, keep_runs, None, dry_run)
550    }
551
552    /// Trim old runs for selected sessions and optional age horizon.
553    pub fn trim_with_age(
554        &mut self,
555        sessions: Vec<String>,
556        keep_runs: usize,
557        older_than: Option<chrono::Duration>,
558        dry_run: bool,
559    ) -> CliResult<TrimReport> {
560        let mut report = TrimReport {
561            sessions_scanned: sessions.len(),
562            dry_run,
563            ..TrimReport::default()
564        };
565        let cutoff = older_than.map(|duration| Utc::now() - duration);
566        for session_id in sessions {
567            let session_id = SessionId::from_string(session_id);
568            let session = self
569                .storage
570                .load_session(&session_id)
571                .map_err(storage_error)?;
572            let runs = self.storage.list_runs(&session_id).map_err(storage_error)?;
573            let keep_from = runs.len().saturating_sub(keep_runs);
574            let candidates = runs
575                .into_iter()
576                .take(keep_from)
577                .filter(|run| session.active_run_id.as_ref() != Some(&run.run_id))
578                .filter(|run| cutoff.is_none_or(|cutoff| run.updated_at < cutoff))
579                .collect::<Vec<_>>();
580            report.runs_to_trim += candidates.len();
581            for run in &candidates {
582                report.bytes_reclaimed = report
583                    .bytes_reclaimed
584                    .saturating_add(self.run_file_bytes(session_id.as_str(), run.run_id.as_str())?);
585            }
586            if !dry_run && !candidates.is_empty() {
587                let run_ids = candidates
588                    .iter()
589                    .map(|run| run.run_id.clone())
590                    .collect::<Vec<_>>();
591                report.runs_trimmed += self
592                    .storage
593                    .prune_runs(&session_id, &run_ids)
594                    .map_err(storage_error)?;
595                for run_id in run_ids {
596                    self.remove_run_files(session_id.as_str(), run_id.as_str())?;
597                }
598            }
599        }
600        Ok(report)
601    }
602
603    /// Return all session ids.
604    pub fn all_session_ids(&self) -> CliResult<Vec<String>> {
605        Ok(self
606            .storage
607            .list_sessions()
608            .map_err(storage_error)?
609            .into_iter()
610            .map(|session| session.session_id.as_str().to_string())
611            .collect())
612    }
613
614    fn write_run_blob<T: Serialize>(
615        &self,
616        run: &RunRecord,
617        name: &str,
618        value: &T,
619    ) -> CliResult<()> {
620        let path = self
621            .file_store_path
622            .join("sessions")
623            .join(run.session_id.as_str())
624            .join("runs")
625            .join(run.run_id.as_str())
626            .join(name);
627        atomic_write_json(&path, value)
628    }
629
630    fn run_file_bytes(&self, session_id: &str, run_id: &str) -> CliResult<u64> {
631        directory_bytes(
632            &self
633                .file_store_path
634                .join("sessions")
635                .join(session_id)
636                .join("runs")
637                .join(run_id),
638        )
639    }
640
641    fn remove_run_files(&self, session_id: &str, run_id: &str) -> CliResult<()> {
642        let path = self
643            .file_store_path
644            .join("sessions")
645            .join(session_id)
646            .join("runs")
647            .join(run_id);
648        if path.exists() {
649            fs::remove_dir_all(&path).map_err(|error| io_error(&path, error))?;
650        }
651        Ok(())
652    }
653
654    /// List persisted approval records.
655    pub fn list_approvals(
656        &self,
657        session_id: Option<&str>,
658        run_id: Option<&str>,
659    ) -> CliResult<Vec<ApprovalRecord>> {
660        let session_id = session_id.map(SessionId::from_string);
661        let run_id = run_id.map(RunId::from_string);
662        self.storage
663            .list_approvals(session_id.as_ref(), run_id.as_ref())
664            .map_err(storage_error)
665    }
666
667    /// Load one approval record.
668    pub fn load_approval(&self, approval_id: &str) -> CliResult<ApprovalRecord> {
669        self.storage
670            .load_approval(approval_id)
671            .map_err(storage_error)
672    }
673
674    /// Record an approval decision.
675    pub fn decide_approval(
676        &mut self,
677        approval_id: &str,
678        status: ApprovalStatus,
679        reason: Option<String>,
680    ) -> CliResult<ApprovalRecord> {
681        self.storage
682            .decide_approval(
683                approval_id,
684                status,
685                Some("starweaver-cli".to_string()),
686                reason,
687            )
688            .map_err(storage_error)
689    }
690
691    /// List persisted deferred tool records.
692    pub fn list_deferred_tools(
693        &self,
694        session_id: Option<&str>,
695        run_id: Option<&str>,
696    ) -> CliResult<Vec<DeferredToolRecord>> {
697        let session_id = session_id.map(SessionId::from_string);
698        let run_id = run_id.map(RunId::from_string);
699        self.storage
700            .list_deferred_tools(session_id.as_ref(), run_id.as_ref())
701            .map_err(storage_error)
702    }
703
704    /// Load one deferred tool record.
705    pub fn load_deferred_tool(&self, deferred_id: &str) -> CliResult<DeferredToolRecord> {
706        self.storage
707            .load_deferred_tool(deferred_id)
708            .map_err(storage_error)
709    }
710
711    /// Complete one deferred tool record.
712    pub fn complete_deferred_tool(
713        &mut self,
714        deferred_id: &str,
715        response: Value,
716    ) -> CliResult<DeferredToolRecord> {
717        self.storage
718            .resolve_deferred_tool(deferred_id, ExecutionStatus::Completed, response)
719            .map_err(storage_error)
720    }
721
722    /// Fail one deferred tool record.
723    pub fn fail_deferred_tool(
724        &mut self,
725        deferred_id: &str,
726        error: &str,
727    ) -> CliResult<DeferredToolRecord> {
728        self.storage
729            .resolve_deferred_tool(
730                deferred_id,
731                ExecutionStatus::Failed,
732                serde_json::json!({"error": error}),
733            )
734            .map_err(storage_error)
735    }
736}
737
738fn run_summary(run: RunRecord) -> RunSummary {
739    RunSummary {
740        run_id: run.run_id.as_str().to_string(),
741        sequence_no: run.sequence_no,
742        status: run_status_name(run.status).to_string(),
743        restore_from_run_id: run
744            .restore_from_run_id
745            .map(|run_id| run_id.as_str().to_string()),
746        output_preview: run.output_preview,
747        created_at: run.created_at.to_rfc3339(),
748        updated_at: run.updated_at.to_rfc3339(),
749    }
750}
751
752const fn run_status_name(status: RunStatus) -> &'static str {
753    status.as_str()
754}
755
756const fn session_status_name(status: SessionStatus) -> &'static str {
757    match status {
758        SessionStatus::Active => "active",
759        SessionStatus::Archived => "archived",
760        SessionStatus::Failed => "failed",
761        SessionStatus::Deleted => "deleted",
762    }
763}
764
765fn storage_error(error: SessionStoreError) -> CliError {
766    match error {
767        SessionStoreError::NotFound(value) => CliError::NotFound(value),
768        other => CliError::Storage(other.to_string()),
769    }
770}
771
772fn search_error(error: SessionSearchError) -> CliError {
773    match error {
774        SessionSearchError::InvalidQuery(message) | SessionSearchError::InvalidCursor(message) => {
775            CliError::Usage(message)
776        }
777        SessionSearchError::Unsupported(message) => CliError::Unsupported(message),
778        SessionSearchError::Unavailable(message) | SessionSearchError::Failed(message) => {
779            CliError::Storage(message)
780        }
781        SessionSearchError::PermissionDenied => {
782            CliError::Storage("session search permission denied".to_string())
783        }
784    }
785}
786
787fn atomic_write_json<T: Serialize>(path: &Path, value: &T) -> CliResult<()> {
788    let parent = path
789        .parent()
790        .ok_or_else(|| CliError::Storage("missing parent path".to_string()))?;
791    fs::create_dir_all(parent).map_err(|error| io_error(parent, error))?;
792    let temp = path.with_extension(format!("{}.tmp", Uuid::new_v4()));
793    fs::write(&temp, serde_json::to_vec_pretty(value)?).map_err(|error| io_error(&temp, error))?;
794    fs::rename(&temp, path).map_err(|error| io_error(path, error))
795}
796
797fn directory_bytes(path: &Path) -> CliResult<u64> {
798    if !path.exists() {
799        return Ok(0);
800    }
801    let mut total = 0_u64;
802    for entry in fs::read_dir(path).map_err(|error| io_error(path, error))? {
803        let entry = entry.map_err(|error| io_error(path, error))?;
804        let entry_path = entry.path();
805        let metadata = entry
806            .metadata()
807            .map_err(|error| io_error(&entry_path, error))?;
808        if metadata.is_dir() {
809            total = total.saturating_add(directory_bytes(&entry_path)?);
810        } else {
811            total = total.saturating_add(metadata.len());
812        }
813    }
814    Ok(total)
815}