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;
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
42pub struct LocalStore {
44 storage: SqliteStorage,
45 file_store_path: PathBuf,
46 workspace: String,
47 search_scope: SessionSearchScope,
48}
49
50pub struct RunArtifacts {
52 pub state: ResumableState,
54 pub environment_state: Option<EnvironmentState>,
56 pub raw_records: Vec<AgentStreamRecord>,
58 pub checkpoints: Vec<AgentCheckpoint>,
60 pub display_messages: Vec<DisplayMessage>,
62 pub display_snapshot: ReplaySnapshot,
64 pub approvals: Vec<ApprovalRecord>,
66 pub deferred_tools: Vec<DeferredToolRecord>,
68 pub status: RunStatus,
70 pub terminal_error: Option<RunTerminalError>,
72}
73
74#[derive(Clone, Debug, Serialize)]
76pub struct SessionSummary {
77 pub session_id: String,
79 pub title: Option<String>,
81 pub profile: Option<String>,
83 pub status: String,
85 pub head_run_id: Option<String>,
87 pub head_success_run_id: Option<String>,
89 pub active_run_id: Option<String>,
91 pub run_count: usize,
93 pub last_output_preview: Option<String>,
95 pub created_at: String,
97 pub updated_at: String,
99}
100
101#[derive(Clone, Debug, Serialize)]
103pub struct RunSummary {
104 pub run_id: String,
106 pub sequence_no: usize,
108 pub status: String,
110 pub restore_from_run_id: Option<String>,
112 pub output_preview: Option<String>,
114 pub created_at: String,
116 pub updated_at: String,
118}
119
120#[derive(Clone, Debug, Default, Serialize)]
122pub struct TrimReport {
123 pub sessions_scanned: usize,
125 pub runs_to_trim: usize,
127 pub runs_trimmed: usize,
129 pub bytes_reclaimed: u64,
131 pub dry_run: bool,
133}
134
135impl LocalStore {
136 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 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 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 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 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 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 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 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 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 #[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 #[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 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 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 #[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 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 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 #[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 #[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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}