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