1use 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
43pub struct LocalStore {
45 storage: SqliteStorage,
46 file_store_path: PathBuf,
47 workspace: String,
48 search_scope: SessionSearchScope,
49}
50
51pub struct RunArtifacts {
53 pub state: ResumableState,
55 pub environment_state: Option<EnvironmentState>,
57 pub raw_records: Vec<AgentStreamRecord>,
59 pub checkpoints: Vec<AgentCheckpoint>,
61 pub display_messages: Vec<DisplayMessage>,
63 pub display_snapshot: ReplaySnapshot,
65 pub approvals: Vec<ApprovalRecord>,
67 pub deferred_tools: Vec<DeferredToolRecord>,
69 pub status: RunStatus,
71 pub terminal_error: Option<RunTerminalError>,
73}
74
75#[derive(Clone, Debug, Serialize)]
77pub struct SessionSummary {
78 pub session_id: String,
80 pub title: Option<String>,
82 pub profile: Option<String>,
84 pub status: String,
86 pub head_run_id: Option<String>,
88 pub head_success_run_id: Option<String>,
90 pub active_run_id: Option<String>,
92 pub run_count: usize,
94 pub last_output_preview: Option<String>,
96 pub created_at: String,
98 pub updated_at: String,
100}
101
102#[derive(Clone, Debug, Serialize)]
104pub struct RunSummary {
105 pub run_id: String,
107 pub sequence_no: usize,
109 pub status: String,
111 pub restore_from_run_id: Option<String>,
113 pub output_preview: Option<String>,
115 pub created_at: String,
117 pub updated_at: String,
119}
120
121#[derive(Clone, Debug, Default, Serialize)]
123pub struct TrimReport {
124 pub sessions_scanned: usize,
126 pub runs_to_trim: usize,
128 pub runs_trimmed: usize,
130 pub bytes_reclaimed: u64,
132 pub dry_run: bool,
134}
135
136impl LocalStore {
137 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 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 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 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 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 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 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 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 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 #[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 #[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 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 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 #[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 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 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 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 #[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 #[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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}