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