1use std::{collections::BTreeSet, fs, path::Path, path::PathBuf, sync::Arc};
4
5use chrono::Utc;
6use serde::Serialize;
7use serde_json::Value;
8use starweaver_agent::ResumableState;
9use starweaver_core::{ConversationId, RunId, SessionId};
10use starweaver_environment::EnvironmentState;
11use starweaver_model::{ModelMessage, ModelRequest, ModelRequestPart, ToolReturnPart};
12use starweaver_runtime::{AgentStreamEvent, AgentStreamRecord};
13use starweaver_session::{
14 ApprovalRecord, ApprovalStatus, DeferredToolRecord, EnvironmentStateRef, ExecutionStatus,
15 InputPart, RelatedRunUpdate, RunRecord, RunStatus, SessionRecord, SessionSearchError,
16 SessionSearchPage, SessionSearchProvider, SessionSearchQuery, SessionSearchScope,
17 SessionStatus, SessionStoreError, StreamCursorRef,
18};
19use starweaver_storage::{LocalSessionSearchProvider, RunEvidenceCommit, SqliteStorage};
20use starweaver_stream::{DisplayMessage, ReplayCursor, ReplayScope, ReplaySnapshot};
21use uuid::Uuid;
22
23use crate::{CliError, CliResult, config::CliConfig, error::io_error};
24
25mod archive;
26mod hitl;
27mod replay;
28mod session_store;
29
30pub use archive::LocalStreamArchive;
31use hitl::{
32 approval_tool_return, deferred_status_is_unresolved, deferred_tool_return,
33 existing_resume_tool_return_ids, latest_tool_call_order, pending_hitl_resume_error,
34 tool_return_control_flow,
35};
36pub use replay::DisplayReplayWindow;
37pub use session_store::LocalSessionStore;
38
39pub const HITL_RESUME_CLAIM_ID_METADATA_KEY: &str = "starweaver.cli.hitl_resume_claim_id";
40pub const HITL_RESUME_SOURCE_RUN_ID_METADATA_KEY: &str = "starweaver.cli.hitl_resume_source_run_id";
41pub const HITL_RESUME_PREFLIGHT_SOURCE_RUN_ID_METADATA_KEY: &str =
42 "starweaver.cli.hitl_resume_preflight_source_run_id";
43
44pub struct LocalStore {
46 storage: SqliteStorage,
47 file_store_path: PathBuf,
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 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}
70
71#[derive(Clone, Debug, Serialize)]
73pub struct SessionSummary {
74 pub session_id: String,
76 pub title: Option<String>,
78 pub profile: Option<String>,
80 pub status: String,
82 pub head_run_id: Option<String>,
84 pub head_success_run_id: Option<String>,
86 pub active_run_id: Option<String>,
88 pub run_count: usize,
90 pub last_output_preview: Option<String>,
92 pub created_at: String,
94 pub updated_at: String,
96}
97
98#[derive(Clone, Debug, Serialize)]
100pub struct RunSummary {
101 pub run_id: String,
103 pub sequence_no: usize,
105 pub status: String,
107 pub restore_from_run_id: Option<String>,
109 pub output_preview: Option<String>,
111 pub created_at: String,
113 pub updated_at: String,
115}
116
117#[derive(Clone, Debug, Default, Serialize)]
119pub struct TrimReport {
120 pub sessions_scanned: usize,
122 pub runs_to_trim: usize,
124 pub runs_trimmed: usize,
126 pub bytes_reclaimed: u64,
128 pub dry_run: bool,
130}
131
132impl LocalStore {
133 pub fn open(config: &CliConfig) -> CliResult<Self> {
135 crate::config::ensure_config_dirs(config)?;
136 Ok(Self {
137 storage: SqliteStorage::open(&config.database_path).map_err(storage_error)?,
138 file_store_path: config.file_store_path.clone(),
139 search_scope: SessionSearchScope::local(
140 config.database_path.to_string_lossy().into_owned(),
141 ),
142 })
143 }
144
145 pub fn create_session(
147 &mut self,
148 profile: &str,
149 title: Option<String>,
150 ) -> CliResult<SessionRecord> {
151 self.storage
152 .create_session(Some(profile.to_string()), title)
153 .map_err(storage_error)
154 }
155
156 pub fn load_session(&self, session_id: &str) -> CliResult<SessionRecord> {
158 self.storage
159 .load_session(&SessionId::from_string(session_id))
160 .map_err(storage_error)
161 }
162
163 pub fn resolve_session_prefix(&self, value: &str) -> CliResult<String> {
165 self.storage
166 .resolve_session_prefix(value)
167 .map(|session_id| session_id.as_str().to_string())
168 .map_err(|error| match error {
169 SessionStoreError::NotFound(_) => CliError::NotFound(value.to_string()),
170 SessionStoreError::Failed(message) if message.contains("ambiguous") => {
171 CliError::Usage(message)
172 }
173 other => storage_error(other),
174 })
175 }
176
177 pub fn delete_session(&mut self, session_id: &str) -> CliResult<bool> {
179 let session_id = SessionId::from_string(session_id);
180 let deleted = self
181 .storage
182 .delete_session(&session_id)
183 .map_err(storage_error)?;
184 if deleted {
185 let path = self
186 .file_store_path
187 .join("sessions")
188 .join(session_id.as_str());
189 if path.exists() {
190 fs::remove_dir_all(&path).map_err(|error| io_error(&path, error))?;
191 }
192 }
193 Ok(deleted)
194 }
195
196 pub fn load_run(&self, session_id: &str, run_id: &str) -> CliResult<RunRecord> {
198 self.storage
199 .load_run(
200 &SessionId::from_string(session_id),
201 &RunId::from_string(run_id),
202 )
203 .map_err(storage_error)
204 }
205
206 pub fn latest_session(&self) -> CliResult<Option<SessionRecord>> {
208 Ok(self
209 .storage
210 .list_sessions()
211 .map_err(storage_error)?
212 .into_iter()
213 .find(|session| session.status == SessionStatus::Active))
214 }
215
216 pub fn append_run(
218 &mut self,
219 session_id: &str,
220 prompt: String,
221 restore_from_run_id: Option<String>,
222 profile: &str,
223 ) -> CliResult<RunRecord> {
224 let session = self.load_session(session_id)?;
225 let mut run = RunRecord::new(session.session_id, RunId::new(), ConversationId::new());
226 run.restore_from_run_id = restore_from_run_id.map(RunId::from_string);
227 run.trigger_type = Some("cli".to_string());
228 run.profile = Some(profile.to_string());
229 run.input = vec![InputPart::text(prompt)];
230 self.storage.begin_run(run).map_err(storage_error)
231 }
232
233 pub fn complete_run(
235 &mut self,
236 run: &mut RunRecord,
237 output: String,
238 artifacts: RunArtifacts,
239 ) -> CliResult<Vec<DisplayMessage>> {
240 let scope = ReplayScope::run(run.run_id.as_str());
241 let mut display_snapshot = artifacts.display_snapshot.clone();
242 if display_snapshot.scope.is_none() {
243 display_snapshot.scope = Some(scope.clone());
244 }
245 let raw_cursor = artifacts.raw_records.last().map(|record| {
246 StreamCursorRef::new(ReplayCursor::raw_runtime(scope.clone(), record.sequence))
247 });
248 let display_cursor = artifacts.display_messages.last().map(|message| {
249 StreamCursorRef::new(ReplayCursor::display(scope.clone(), message.sequence))
250 });
251 let environment_ref =
252 artifacts
253 .environment_state
254 .as_ref()
255 .map(|state| EnvironmentStateRef {
256 provider: state.provider_id.clone(),
257 reference: format!(
258 "sqlite:run_environment_records/{}/{}",
259 run.session_id.as_str(),
260 run.run_id.as_str()
261 ),
262 revision: Some(format!("{}", state.files.len() + state.resources.len())),
263 metadata: state.metadata.clone(),
264 });
265 run.status = artifacts.status;
266 run.output_preview = Some(output);
267 run.updated_at = Utc::now();
268 run.environment_state.clone_from(&environment_ref);
269 run.stream_cursors = raw_cursor.into_iter().chain(display_cursor).collect();
270
271 let mut commit = RunEvidenceCommit::new(run.clone(), artifacts.state.clone());
272 commit.environment_state = artifacts
273 .environment_state
274 .as_ref()
275 .map(EnvironmentState::to_json);
276 commit.stream_records.clone_from(&artifacts.raw_records);
277 commit.approvals.clone_from(&artifacts.approvals);
278 commit.deferred_tools.clone_from(&artifacts.deferred_tools);
279 commit.stream_cursors.clone_from(&run.stream_cursors);
280 commit
281 .display_messages
282 .clone_from(&artifacts.display_messages);
283 commit.display_snapshot = Some(display_snapshot.clone());
284 let source_status = if run.status.is_terminal() {
285 run.status
286 } else {
287 RunStatus::Completed
288 };
289 attach_hitl_resume_update(run, &mut commit, source_status)?;
290 *run = self
291 .storage
292 .commit_run_evidence(commit)
293 .map_err(storage_error)?;
294
295 let _ = self.write_run_blob(run, "raw.stream.json", &artifacts.raw_records);
300 let _ = self.write_run_blob(run, "display.compact.json", &display_snapshot);
301 let _ = self.write_run_blob(run, "context.state.json", &artifacts.state);
302 if let Some(environment_state) = artifacts.environment_state.as_ref() {
303 let _ =
304 self.write_run_blob(run, "environment.state.json", &environment_state.to_json());
305 }
306 Ok(artifacts.display_messages)
307 }
308
309 pub fn fail_run(&mut self, run: &mut RunRecord, message: String) -> CliResult<()> {
311 self.fail_run_with_messages(run, message, &[])
312 }
313
314 pub fn fail_run_with_messages(
316 &mut self,
317 run: &mut RunRecord,
318 message: String,
319 messages: &[DisplayMessage],
320 ) -> CliResult<()> {
321 let session = self.load_session(run.session_id.as_str())?;
322 run.status = RunStatus::Failed;
323 run.output_preview = Some(message);
324 run.updated_at = Utc::now();
325 let mut state = session.state;
326 state.run_id = Some(run.run_id.clone());
327 state.conversation_id = Some(run.conversation_id.clone());
328 let mut commit = RunEvidenceCommit::new(run.clone(), state);
329 commit.display_messages = messages.to_vec();
330 attach_hitl_resume_update(run, &mut commit, RunStatus::Failed)?;
331 *run = self
332 .storage
333 .commit_run_evidence(commit)
334 .map_err(storage_error)?;
335 self.write_run_blob(run, "display.compact.json", &messages)?;
336 Ok(())
337 }
338
339 pub fn load_restore_state(
341 &self,
342 session_id: &str,
343 run_id: Option<&str>,
344 ) -> CliResult<Option<ResumableState>> {
345 let Some(run_id) = run_id else {
346 return Ok(Some(self.load_session(session_id)?.state));
347 };
348 let mut state = self
349 .storage
350 .load_run_context(
351 &SessionId::from_string(session_id),
352 &RunId::from_string(run_id),
353 )
354 .map_err(storage_error)?;
355 if let Some(state) = state.as_mut() {
356 self.inject_resolved_hitl_tool_returns(session_id, run_id, state)?;
357 }
358 Ok(state)
359 }
360
361 fn inject_resolved_hitl_tool_returns(
362 &self,
363 session_id: &str,
364 run_id: &str,
365 state: &mut ResumableState,
366 ) -> CliResult<()> {
367 let mut existing_returns = existing_resume_tool_return_ids(&state.message_history);
368 let tool_call_order = latest_tool_call_order(&state.message_history);
369 let latest_tool_call_ids = tool_call_order.iter().cloned().collect::<BTreeSet<_>>();
370 let approvals = self.list_approvals(Some(session_id), Some(run_id))?;
371 let deferred_tools = self.list_deferred_tools(Some(session_id), Some(run_id))?;
372 let pending_approvals = approvals
373 .iter()
374 .filter(|approval| {
375 approval.status == ApprovalStatus::Pending
376 && !existing_returns.contains(&approval.action_id)
377 })
378 .map(|approval| approval.approval_id.clone())
379 .collect::<Vec<_>>();
380 let pending_deferred = deferred_tools
381 .iter()
382 .filter(|deferred| {
383 deferred_status_is_unresolved(deferred.status)
384 && !existing_returns.contains(&deferred.tool_call_id)
385 })
386 .map(|deferred| deferred.deferred_id.clone())
387 .collect::<Vec<_>>();
388 if !pending_approvals.is_empty() || !pending_deferred.is_empty() {
389 return Err(pending_hitl_resume_error(
390 run_id,
391 &pending_approvals,
392 &pending_deferred,
393 ));
394 }
395
396 let mut resolved = Vec::<(String, ModelRequestPart)>::new();
397 for tool_return in self.list_run_tool_returns(session_id, run_id)? {
398 if !latest_tool_call_ids.contains(&tool_return.tool_call_id)
399 || tool_return_control_flow(&tool_return).is_some()
400 || existing_returns.contains(&tool_return.tool_call_id)
401 {
402 continue;
403 }
404 existing_returns.insert(tool_return.tool_call_id.clone());
405 resolved.push((
406 tool_return.tool_call_id.clone(),
407 ModelRequestPart::ToolReturn(tool_return),
408 ));
409 }
410 for approval in approvals {
411 if existing_returns.contains(&approval.action_id) {
412 continue;
413 }
414 if let Some(tool_return) = approval_tool_return(&approval)? {
415 existing_returns.insert(approval.action_id.clone());
416 resolved.push((
417 approval.action_id.clone(),
418 ModelRequestPart::ToolReturn(tool_return),
419 ));
420 }
421 }
422 for deferred in deferred_tools {
423 if existing_returns.contains(&deferred.tool_call_id) {
424 continue;
425 }
426 if let Some(tool_return) = deferred_tool_return(&deferred) {
427 existing_returns.insert(deferred.tool_call_id.clone());
428 resolved.push((
429 deferred.tool_call_id.clone(),
430 ModelRequestPart::ToolReturn(tool_return),
431 ));
432 }
433 }
434 if resolved.is_empty() {
435 return Ok(());
436 }
437 resolved.sort_by_key(|(tool_call_id, _)| {
438 tool_call_order
439 .iter()
440 .position(|known| known == tool_call_id)
441 .unwrap_or(usize::MAX)
442 });
443 let mut metadata = serde_json::Map::new();
444 metadata.insert(
445 "starweaver.resume.hitl_results".to_string(),
446 serde_json::json!(true),
447 );
448 metadata.insert(
449 "starweaver.resume.source_run_id".to_string(),
450 serde_json::json!(run_id),
451 );
452 state
453 .message_history
454 .push(ModelMessage::Request(ModelRequest {
455 parts: resolved.into_iter().map(|(_, part)| part).collect(),
456 timestamp: Some(Utc::now()),
457 instructions: None,
458 run_id: Some(RunId::from_string(run_id)),
459 conversation_id: state.conversation_id.clone(),
460 metadata,
461 }));
462 Ok(())
463 }
464
465 fn list_run_tool_returns(
466 &self,
467 session_id: &str,
468 run_id: &str,
469 ) -> CliResult<Vec<ToolReturnPart>> {
470 let records = self
471 .storage
472 .load_stream_records(
473 &SessionId::from_string(session_id),
474 &RunId::from_string(run_id),
475 )
476 .map_err(storage_error)?;
477 Ok(records
478 .into_iter()
479 .filter_map(|record| match record.event {
480 AgentStreamEvent::ToolReturn { tool_return, .. } => Some(tool_return),
481 _ => None,
482 })
483 .collect())
484 }
485
486 pub fn list_sessions(&self, limit: usize) -> CliResult<Vec<SessionSummary>> {
488 self.storage
489 .list_sessions()
490 .map_err(storage_error)?
491 .into_iter()
492 .take(limit)
493 .map(|session| {
494 let runs = self
495 .storage
496 .list_runs(&session.session_id)
497 .map_err(storage_error)?;
498 Ok(SessionSummary {
499 session_id: session.session_id.as_str().to_string(),
500 title: session.title,
501 profile: session.profile,
502 status: session_status_name(session.status).to_string(),
503 head_run_id: session.head_run_id.map(|id| id.as_str().to_string()),
504 head_success_run_id: session
505 .head_success_run_id
506 .map(|id| id.as_str().to_string()),
507 active_run_id: session.active_run_id.map(|id| id.as_str().to_string()),
508 run_count: runs.len(),
509 last_output_preview: runs.last().and_then(|run| run.output_preview.clone()),
510 created_at: session.created_at.to_rfc3339(),
511 updated_at: session.updated_at.to_rfc3339(),
512 })
513 })
514 .collect()
515 }
516
517 pub fn search_sessions(&self, query: SessionSearchQuery) -> CliResult<SessionSearchPage> {
519 let provider = LocalSessionSearchProvider::new(
520 Arc::new(self.storage.session_store()),
521 &self.search_scope,
522 )
523 .with_display_root(self.file_store_path.clone());
524 let runtime = tokio::runtime::Builder::new_current_thread()
525 .enable_all()
526 .build()
527 .map_err(|error| CliError::Run(error.to_string()))?;
528 runtime
529 .block_on(provider.search(&self.search_scope, query))
530 .map_err(search_error)
531 }
532
533 pub fn list_run_records(&self, session_id: &str) -> CliResult<Vec<RunRecord>> {
535 self.storage
536 .list_runs(&SessionId::from_string(session_id))
537 .map_err(storage_error)
538 }
539
540 pub fn list_runs(&self, session_id: &str, limit: usize) -> CliResult<Vec<RunSummary>> {
542 let mut runs = self
543 .storage
544 .list_runs(&SessionId::from_string(session_id))
545 .map_err(storage_error)?;
546 if runs.len() > limit {
547 runs.drain(..runs.len() - limit);
548 }
549 Ok(runs.into_iter().map(run_summary).collect())
550 }
551
552 pub fn replay_display(
554 &self,
555 session_id: &str,
556 run_id: Option<&str>,
557 after: Option<usize>,
558 ) -> CliResult<Vec<DisplayMessage>> {
559 let session_id = SessionId::from_string(session_id);
560 let run_id = run_id.map(RunId::from_string);
561 self.storage
562 .load_display_messages(&session_id, run_id.as_ref(), after)
563 .map_err(storage_error)
564 }
565
566 pub fn trim(
568 &mut self,
569 sessions: Vec<String>,
570 keep_runs: usize,
571 dry_run: bool,
572 ) -> CliResult<TrimReport> {
573 self.trim_with_age(sessions, keep_runs, None, dry_run)
574 }
575
576 pub fn trim_with_age(
578 &mut self,
579 sessions: Vec<String>,
580 keep_runs: usize,
581 older_than: Option<chrono::Duration>,
582 dry_run: bool,
583 ) -> CliResult<TrimReport> {
584 let mut report = TrimReport {
585 sessions_scanned: sessions.len(),
586 dry_run,
587 ..TrimReport::default()
588 };
589 let cutoff = older_than.map(|duration| Utc::now() - duration);
590 for session_id in sessions {
591 let session_id = SessionId::from_string(session_id);
592 let session = self
593 .storage
594 .load_session(&session_id)
595 .map_err(storage_error)?;
596 let runs = self.storage.list_runs(&session_id).map_err(storage_error)?;
597 let keep_from = runs.len().saturating_sub(keep_runs);
598 let candidates = runs
599 .into_iter()
600 .take(keep_from)
601 .filter(|run| session.active_run_id.as_ref() != Some(&run.run_id))
602 .filter(|run| cutoff.is_none_or(|cutoff| run.updated_at < cutoff))
603 .collect::<Vec<_>>();
604 report.runs_to_trim += candidates.len();
605 for run in &candidates {
606 report.bytes_reclaimed = report
607 .bytes_reclaimed
608 .saturating_add(self.run_file_bytes(session_id.as_str(), run.run_id.as_str())?);
609 }
610 if !dry_run && !candidates.is_empty() {
611 let run_ids = candidates
612 .iter()
613 .map(|run| run.run_id.clone())
614 .collect::<Vec<_>>();
615 report.runs_trimmed += self
616 .storage
617 .prune_runs(&session_id, &run_ids)
618 .map_err(storage_error)?;
619 for run_id in run_ids {
620 self.remove_run_files(session_id.as_str(), run_id.as_str())?;
621 }
622 }
623 }
624 Ok(report)
625 }
626
627 pub fn all_session_ids(&self) -> CliResult<Vec<String>> {
629 Ok(self
630 .storage
631 .list_sessions()
632 .map_err(storage_error)?
633 .into_iter()
634 .map(|session| session.session_id.as_str().to_string())
635 .collect())
636 }
637
638 fn write_run_blob<T: Serialize>(
639 &self,
640 run: &RunRecord,
641 name: &str,
642 value: &T,
643 ) -> CliResult<()> {
644 let path = self
645 .file_store_path
646 .join("sessions")
647 .join(run.session_id.as_str())
648 .join("runs")
649 .join(run.run_id.as_str())
650 .join(name);
651 atomic_write_json(&path, value)
652 }
653
654 fn run_file_bytes(&self, session_id: &str, run_id: &str) -> CliResult<u64> {
655 directory_bytes(
656 &self
657 .file_store_path
658 .join("sessions")
659 .join(session_id)
660 .join("runs")
661 .join(run_id),
662 )
663 }
664
665 fn remove_run_files(&self, session_id: &str, run_id: &str) -> CliResult<()> {
666 let path = self
667 .file_store_path
668 .join("sessions")
669 .join(session_id)
670 .join("runs")
671 .join(run_id);
672 if path.exists() {
673 fs::remove_dir_all(&path).map_err(|error| io_error(&path, error))?;
674 }
675 Ok(())
676 }
677
678 pub fn list_approvals(
680 &self,
681 session_id: Option<&str>,
682 run_id: Option<&str>,
683 ) -> CliResult<Vec<ApprovalRecord>> {
684 let session_id = session_id.map(SessionId::from_string);
685 let run_id = run_id.map(RunId::from_string);
686 self.storage
687 .list_approvals(session_id.as_ref(), run_id.as_ref())
688 .map_err(storage_error)
689 }
690
691 pub fn load_approval(&self, approval_id: &str) -> CliResult<ApprovalRecord> {
693 self.storage
694 .load_approval(approval_id)
695 .map_err(storage_error)
696 }
697
698 pub fn decide_approval(
700 &mut self,
701 approval_id: &str,
702 status: ApprovalStatus,
703 reason: Option<String>,
704 ) -> CliResult<ApprovalRecord> {
705 self.storage
706 .decide_approval(
707 approval_id,
708 status,
709 Some("starweaver-cli".to_string()),
710 reason,
711 )
712 .map_err(storage_error)
713 }
714
715 pub fn list_deferred_tools(
717 &self,
718 session_id: Option<&str>,
719 run_id: Option<&str>,
720 ) -> CliResult<Vec<DeferredToolRecord>> {
721 let session_id = session_id.map(SessionId::from_string);
722 let run_id = run_id.map(RunId::from_string);
723 self.storage
724 .list_deferred_tools(session_id.as_ref(), run_id.as_ref())
725 .map_err(storage_error)
726 }
727
728 pub fn load_deferred_tool(&self, deferred_id: &str) -> CliResult<DeferredToolRecord> {
730 self.storage
731 .load_deferred_tool(deferred_id)
732 .map_err(storage_error)
733 }
734
735 pub fn complete_deferred_tool(
737 &mut self,
738 deferred_id: &str,
739 response: Value,
740 ) -> CliResult<DeferredToolRecord> {
741 self.storage
742 .resolve_deferred_tool(deferred_id, ExecutionStatus::Completed, response)
743 .map_err(storage_error)
744 }
745
746 pub fn fail_deferred_tool(
748 &mut self,
749 deferred_id: &str,
750 error: &str,
751 ) -> CliResult<DeferredToolRecord> {
752 self.storage
753 .resolve_deferred_tool(
754 deferred_id,
755 ExecutionStatus::Failed,
756 serde_json::json!({"error": error}),
757 )
758 .map_err(storage_error)
759 }
760}
761
762fn run_summary(run: RunRecord) -> RunSummary {
763 RunSummary {
764 run_id: run.run_id.as_str().to_string(),
765 sequence_no: run.sequence_no,
766 status: run_status_name(run.status).to_string(),
767 restore_from_run_id: run
768 .restore_from_run_id
769 .map(|run_id| run_id.as_str().to_string()),
770 output_preview: run.output_preview,
771 created_at: run.created_at.to_rfc3339(),
772 updated_at: run.updated_at.to_rfc3339(),
773 }
774}
775
776const fn run_status_name(status: RunStatus) -> &'static str {
777 status.as_str()
778}
779
780const fn session_status_name(status: SessionStatus) -> &'static str {
781 match status {
782 SessionStatus::Active => "active",
783 SessionStatus::Archived => "archived",
784 SessionStatus::Failed => "failed",
785 SessionStatus::Deleted => "deleted",
786 }
787}
788
789fn attach_hitl_resume_update(
790 run: &RunRecord,
791 commit: &mut RunEvidenceCommit,
792 source_status: RunStatus,
793) -> CliResult<()> {
794 let claim_id = run
795 .metadata
796 .get(HITL_RESUME_CLAIM_ID_METADATA_KEY)
797 .and_then(Value::as_str);
798 let source_run_id = run
799 .metadata
800 .get(HITL_RESUME_SOURCE_RUN_ID_METADATA_KEY)
801 .and_then(Value::as_str);
802 match (claim_id, source_run_id) {
803 (None, None) => Ok(()),
804 (Some(claim_id), Some(source_run_id)) => {
805 let mut update = RelatedRunUpdate::new(
806 RunId::from_string(source_run_id),
807 RunStatus::Waiting,
808 source_status,
809 );
810 update.resume_claim_id = Some(claim_id.to_string());
811 commit.related_run_updates.push(update);
812 Ok(())
813 }
814 _ => Err(CliError::Storage(
815 "incomplete HITL resume claim metadata on continuation run".to_string(),
816 )),
817 }
818}
819
820fn storage_error(error: SessionStoreError) -> CliError {
821 match error {
822 SessionStoreError::NotFound(value) => CliError::NotFound(value),
823 other => CliError::Storage(other.to_string()),
824 }
825}
826
827fn search_error(error: SessionSearchError) -> CliError {
828 match error {
829 SessionSearchError::InvalidQuery(message) | SessionSearchError::InvalidCursor(message) => {
830 CliError::Usage(message)
831 }
832 SessionSearchError::Unsupported(message) => CliError::Unsupported(message),
833 SessionSearchError::Unavailable(message) | SessionSearchError::Failed(message) => {
834 CliError::Storage(message)
835 }
836 SessionSearchError::PermissionDenied => {
837 CliError::Storage("session search permission denied".to_string())
838 }
839 }
840}
841
842fn atomic_write_json<T: Serialize>(path: &Path, value: &T) -> CliResult<()> {
843 let parent = path
844 .parent()
845 .ok_or_else(|| CliError::Storage("missing parent path".to_string()))?;
846 fs::create_dir_all(parent).map_err(|error| io_error(parent, error))?;
847 let temp = path.with_extension(format!("{}.tmp", Uuid::new_v4()));
848 fs::write(&temp, serde_json::to_vec_pretty(value)?).map_err(|error| io_error(&temp, error))?;
849 fs::rename(&temp, path).map_err(|error| io_error(path, error))
850}
851
852fn directory_bytes(path: &Path) -> CliResult<u64> {
853 if !path.exists() {
854 return Ok(0);
855 }
856 let mut total = 0_u64;
857 for entry in fs::read_dir(path).map_err(|error| io_error(path, error))? {
858 let entry = entry.map_err(|error| io_error(path, error))?;
859 let entry_path = entry.path();
860 let metadata = entry
861 .metadata()
862 .map_err(|error| io_error(&entry_path, error))?;
863 if metadata.is_dir() {
864 total = total.saturating_add(directory_bytes(&entry_path)?);
865 } else {
866 total = total.saturating_add(metadata.len());
867 }
868 }
869 Ok(total)
870}