//! Graph store — persistence layer for SkillGraph definitions //! //! Provides save/load/delete operations for orchestration graphs, //! enabling graph_id references in pipeline actions. use async_trait::async_trait; use std::collections::HashMap; use std::path::PathBuf; use tokio::sync::RwLock; use crate::orchestration::SkillGraph; /// Trait for graph persistence backends #[async_trait] pub trait GraphStore: Send + Sync { /// Save a graph definition async fn save(&self, graph: &SkillGraph) -> Result<(), String>; /// Load a graph by ID async fn load(&self, id: &str) -> Option; /// Delete a graph by ID async fn delete(&self, id: &str) -> bool; /// List all stored graph IDs async fn list_ids(&self) -> Vec; } /// In-memory graph store with optional file persistence pub struct MemoryGraphStore { graphs: RwLock>, persist_dir: Option, } impl MemoryGraphStore { /// Create an in-memory-only store pub fn new() -> Self { Self { graphs: RwLock::new(HashMap::new()), persist_dir: None, } } /// Create with file persistence to the given directory pub fn with_persist_dir(dir: PathBuf) -> Self { let store = Self { graphs: RwLock::new(HashMap::new()), persist_dir: Some(dir), }; // We'll load from disk lazily on first access store } /// Load all graphs from the persist directory pub async fn load_from_disk(&self) -> Result { let dir = match &self.persist_dir { Some(d) => d.clone(), None => return Ok(0), }; if !dir.exists() { return Ok(0); } let mut count = 0; let mut entries = tokio::fs::read_dir(&dir) .await .map_err(|e| format!("Failed to read graph dir: {}", e))?; while let Some(entry) = entries.next_entry().await .map_err(|e| format!("Failed to read entry: {}", e))? { let path = entry.path(); if path.extension().map(|e| e == "json").unwrap_or(false) { let content = tokio::fs::read_to_string(&path) .await .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?; if let Ok(graph) = serde_json::from_str::(&content) { let id = graph.id.clone(); self.graphs.write().await.insert(id, graph); count += 1; } } } tracing::info!("[GraphStore] Loaded {} graphs from {}", count, dir.display()); Ok(count) } async fn persist_graph(&self, graph: &SkillGraph) { if let Some(ref dir) = self.persist_dir { let path = dir.join(format!("{}.json", graph.id)); if let Ok(content) = serde_json::to_string_pretty(graph) { if let Err(e) = tokio::fs::write(&path, &content).await { tracing::warn!("[GraphStore] Failed to persist {}: {}", graph.id, e); } } } } async fn remove_persist(&self, id: &str) { if let Some(ref dir) = self.persist_dir { let path = dir.join(format!("{}.json", id)); let _ = tokio::fs::remove_file(&path).await; } } } #[async_trait] impl GraphStore for MemoryGraphStore { async fn save(&self, graph: &SkillGraph) -> Result<(), String> { self.persist_graph(graph).await; self.graphs.write().await.insert(graph.id.clone(), graph.clone()); tracing::debug!("[GraphStore] Saved graph: {}", graph.id); Ok(()) } async fn load(&self, id: &str) -> Option { self.graphs.read().await.get(id).cloned() } async fn delete(&self, id: &str) -> bool { self.remove_persist(id).await; self.graphs.write().await.remove(id).is_some() } async fn list_ids(&self) -> Vec { self.graphs.read().await.keys().cloned().collect() } } impl Default for MemoryGraphStore { fn default() -> Self { Self::new() } }