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