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