harness_loop/orchestrator/
store.rs1use crate::orchestrator::run::Run;
10use async_trait::async_trait;
11use std::collections::HashMap;
12use std::path::PathBuf;
13use std::sync::Mutex;
14
15#[derive(Debug, thiserror::Error)]
16#[non_exhaustive]
17pub enum StoreError {
18 #[error("run store io: {0}")]
19 Io(String),
20 #[error("run store serde: {0}")]
21 Serde(String),
22}
23
24#[async_trait]
25pub trait RunStore: Send + Sync {
26 async fn save(&self, run: &Run) -> Result<(), StoreError>;
27 async fn load(&self, run_id: &str) -> Result<Option<Run>, StoreError>;
28}
29
30#[derive(Default)]
32pub struct InMemoryRunStore {
33 runs: Mutex<HashMap<String, Run>>,
34}
35
36impl InMemoryRunStore {
37 pub fn new() -> Self {
38 Self::default()
39 }
40}
41
42#[async_trait]
43impl RunStore for InMemoryRunStore {
44 async fn save(&self, run: &Run) -> Result<(), StoreError> {
45 self.runs
46 .lock()
47 .unwrap()
48 .insert(run.id.clone(), run.clone());
49 Ok(())
50 }
51 async fn load(&self, run_id: &str) -> Result<Option<Run>, StoreError> {
52 Ok(self.runs.lock().unwrap().get(run_id).cloned())
53 }
54}
55
56pub struct FileRunStore {
58 dir: PathBuf,
59}
60
61impl FileRunStore {
62 pub fn open(dir: impl Into<PathBuf>) -> Result<Self, StoreError> {
63 let dir = dir.into();
64 std::fs::create_dir_all(&dir).map_err(|e| StoreError::Io(e.to_string()))?;
65 Ok(Self { dir })
66 }
67 fn path(&self, run_id: &str) -> PathBuf {
68 let safe: String = run_id
70 .chars()
71 .map(|c| {
72 if c.is_alphanumeric() || c == '-' || c == '_' {
73 c
74 } else {
75 '_'
76 }
77 })
78 .collect();
79 self.dir.join(format!("{safe}.json"))
80 }
81}
82
83#[async_trait]
84impl RunStore for FileRunStore {
85 async fn save(&self, run: &Run) -> Result<(), StoreError> {
86 let json = serde_json::to_vec_pretty(run).map_err(|e| StoreError::Serde(e.to_string()))?;
87 std::fs::write(self.path(&run.id), json).map_err(|e| StoreError::Io(e.to_string()))
88 }
89 async fn load(&self, run_id: &str) -> Result<Option<Run>, StoreError> {
90 let p = self.path(run_id);
91 if !p.exists() {
92 return Ok(None);
93 }
94 let bytes = std::fs::read(&p).map_err(|e| StoreError::Io(e.to_string()))?;
95 let run = serde_json::from_slice(&bytes).map_err(|e| StoreError::Serde(e.to_string()))?;
96 Ok(Some(run))
97 }
98}
99
100#[cfg(test)]
101mod tests {
102 use super::*;
103 use crate::orchestrator::dag::Dag;
104 use crate::orchestrator::job::Job;
105
106 #[tokio::test]
107 async fn file_store_roundtrips() {
108 let dir = std::env::temp_dir().join(format!("harness-orch-{}", std::process::id()));
109 let store = FileRunStore::open(&dir).unwrap();
110 let run = Run::new("run-1", "goal", Dag::from_jobs([Job::new("a", "x")]));
111 store.save(&run).await.unwrap();
112 let back = store.load("run-1").await.unwrap().unwrap();
113 assert_eq!(back.id, "run-1");
114 assert_eq!(back.dag.len(), 1);
115 assert!(store.load("missing").await.unwrap().is_none());
116 let _ = std::fs::remove_dir_all(&dir);
117 }
118}