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