use std::{ collections::HashMap, sync::Arc, time::{Duration, Instant}, }; use crank_core::{CacheScope, CoordinationStateStore, CoordinationStateValue}; use crank_registry::{PostgresRegistry, PublishedAgentTool, RegistryError}; use serde::{Deserialize, Serialize}; use tokio::sync::RwLock; use tracing::info; #[derive(Clone)] pub struct PublishedToolCatalog { registry: PostgresRegistry, refresh_interval: Duration, coordination_store: Arc, cached: Arc>>, } #[derive(Clone, Debug, PartialEq, Eq, Hash)] struct CatalogKey { workspace_slug: String, agent_slug: String, } #[derive(Default)] struct CachedCatalog { loaded_at: Option, tools: Vec, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] struct CatalogSnapshot { tools: Vec, } impl PublishedToolCatalog { pub fn new( registry: PostgresRegistry, refresh_interval: Duration, coordination_store: Arc, ) -> Self { Self { registry, refresh_interval, coordination_store, cached: Arc::new(RwLock::new(HashMap::new())), } } pub async fn list_tools( &self, workspace_slug: &str, agent_slug: &str, ) -> Result, RegistryError> { self.refresh_if_stale(workspace_slug, agent_slug).await?; let guard = self.cached.read().await; Ok(guard .get(&CatalogKey::new(workspace_slug, agent_slug)) .map(|entry| entry.tools.clone()) .unwrap_or_default()) } async fn refresh_if_stale( &self, workspace_slug: &str, agent_slug: &str, ) -> Result<(), RegistryError> { let key = CatalogKey::new(workspace_slug, agent_slug); let should_refresh = { let guard = self.cached.read().await; match guard.get(&key).and_then(|entry| entry.loaded_at) { Some(loaded_at) => loaded_at.elapsed() >= self.refresh_interval, None => true, } }; if !should_refresh { return Ok(()); } if let Some(tools) = self.load_shared_snapshot(workspace_slug, agent_slug).await { let mut guard = self.cached.write().await; guard.insert( key, CachedCatalog { loaded_at: Some(Instant::now()), tools, }, ); return Ok(()); } let tools = match self .registry .get_published_agent_tools_by_slug(workspace_slug, agent_slug) .await { Ok(tools) => tools, Err(RegistryError::PublishedAgentNotFound { .. }) => Vec::new(), Err(error) => return Err(error), }; self.store_shared_snapshot(workspace_slug, agent_slug, &tools) .await; let mut guard = self.cached.write().await; let previous_count = guard .get(&key) .map(|entry| entry.tools.len()) .unwrap_or_default(); guard.insert( key, CachedCatalog { loaded_at: Some(Instant::now()), tools, }, ); info!( workspace_slug, agent_slug, published_tool_count = guard .get(&CatalogKey::new(workspace_slug, agent_slug)) .map(|entry| entry.tools.len()) .unwrap_or_default(), previous_published_tool_count = previous_count, "published agent catalog refreshed" ); Ok(()) } async fn load_shared_snapshot( &self, workspace_slug: &str, agent_slug: &str, ) -> Option> { if self.refresh_interval.is_zero() { return None; } let key = catalog_snapshot_key(workspace_slug, agent_slug); let value = match self .coordination_store .get_value(CacheScope::Coordination, &key) .await { Ok(value) => value?, Err(_) => return None, }; serde_json::from_value::(value.payload) .ok() .map(|snapshot| snapshot.tools) } async fn store_shared_snapshot( &self, workspace_slug: &str, agent_slug: &str, tools: &[PublishedAgentTool], ) { let Some(ttl) = catalog_snapshot_ttl(self.refresh_interval) else { return; }; let payload = match serde_json::to_value(CatalogSnapshot { tools: tools.to_vec(), }) { Ok(payload) => payload, Err(_) => return, }; let key = catalog_snapshot_key(workspace_slug, agent_slug); let _ = self .coordination_store .put_value( CacheScope::Coordination, &key, CoordinationStateValue { payload }, ttl, ) .await; } } impl CatalogKey { fn new(workspace_slug: &str, agent_slug: &str) -> Self { Self { workspace_slug: workspace_slug.to_owned(), agent_slug: agent_slug.to_owned(), } } } fn catalog_snapshot_key(workspace_slug: &str, agent_slug: &str) -> String { format!("published_catalog:{workspace_slug}:{agent_slug}") } fn catalog_snapshot_ttl(refresh_interval: Duration) -> Option { if refresh_interval.is_zero() { return None; } refresh_interval.checked_mul(2).or(Some(refresh_interval)) }