use anyhow::Result;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorkspaceSnapshot {
pub session_id: Uuid,
pub repo_id: Uuid,
pub agent_id: String,
pub agent_name: String,
pub changeset_id: Uuid,
pub intent: String,
pub base_commit: String,
pub state: String,
pub mode: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum CachedOverlayEntry {
Modified { content: Vec<u8>, hash: String },
Added { content: Vec<u8>, hash: String },
Deleted,
}
#[async_trait]
pub trait WorkspaceCache: Send + Sync + 'static {
async fn cache_workspace(&self, id: &Uuid, snapshot: &WorkspaceSnapshot) -> Result<()>;
async fn get_workspace(&self, id: &Uuid) -> Result<Option<WorkspaceSnapshot>>;
async fn cache_file(
&self,
workspace_id: &Uuid,
path: &str,
entry: &CachedOverlayEntry,
) -> Result<()>;
async fn get_file(
&self,
workspace_id: &Uuid,
path: &str,
) -> Result<Option<CachedOverlayEntry>>;
async fn list_files(&self, workspace_id: &Uuid) -> Result<Vec<String>>;
async fn cache_graph(&self, workspace_id: &Uuid, graph_data: &[u8]) -> Result<()>;
async fn get_graph(&self, workspace_id: &Uuid) -> Result<Option<Vec<u8>>>;
async fn evict(&self, id: &Uuid) -> Result<()>;
async fn touch(&self, id: &Uuid) -> Result<()>;
}
#[derive(Debug, Clone, Default)]
pub struct NoOpCache;
#[async_trait]
impl WorkspaceCache for NoOpCache {
async fn cache_workspace(&self, _id: &Uuid, _snapshot: &WorkspaceSnapshot) -> Result<()> {
Ok(())
}
async fn get_workspace(&self, _id: &Uuid) -> Result<Option<WorkspaceSnapshot>> {
Ok(None)
}
async fn cache_file(
&self,
_workspace_id: &Uuid,
_path: &str,
_entry: &CachedOverlayEntry,
) -> Result<()> {
Ok(())
}
async fn get_file(
&self,
_workspace_id: &Uuid,
_path: &str,
) -> Result<Option<CachedOverlayEntry>> {
Ok(None)
}
async fn list_files(&self, _workspace_id: &Uuid) -> Result<Vec<String>> {
Ok(vec![])
}
async fn cache_graph(&self, _workspace_id: &Uuid, _graph_data: &[u8]) -> Result<()> {
Ok(())
}
async fn get_graph(&self, _workspace_id: &Uuid) -> Result<Option<Vec<u8>>> {
Ok(None)
}
async fn evict(&self, _id: &Uuid) -> Result<()> {
Ok(())
}
async fn touch(&self, _id: &Uuid) -> Result<()> {
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn workspace_snapshot_roundtrips_json() {
let snap = WorkspaceSnapshot {
session_id: Uuid::new_v4(),
repo_id: Uuid::new_v4(),
agent_id: "clerk|abc".to_string(),
agent_name: "agent-1".to_string(),
changeset_id: Uuid::new_v4(),
intent: "fix the bug".to_string(),
base_commit: "deadbeef".to_string(),
state: "active".to_string(),
mode: "ephemeral".to_string(),
};
let json = serde_json::to_string(&snap).expect("serialize");
let back: WorkspaceSnapshot = serde_json::from_str(&json).expect("deserialize");
assert_eq!(snap, back);
}
#[test]
fn cached_overlay_entry_roundtrips_json() {
let entries = [
CachedOverlayEntry::Modified {
content: b"fn foo() {}".to_vec(),
hash: "abc123".to_string(),
},
CachedOverlayEntry::Added {
content: b"new file".to_vec(),
hash: "def456".to_string(),
},
CachedOverlayEntry::Deleted,
];
for entry in &entries {
let json = serde_json::to_string(entry).expect("serialize");
let back: CachedOverlayEntry = serde_json::from_str(&json).expect("deserialize");
assert_eq!(entry, &back);
}
}
#[tokio::test]
async fn noop_get_workspace_returns_none() {
let cache = NoOpCache;
let id = Uuid::new_v4();
let result = cache.get_workspace(&id).await.expect("should not error");
assert!(result.is_none(), "NoOpCache must return None on get_workspace");
}
#[tokio::test]
async fn noop_cache_workspace_is_silent() {
let cache = NoOpCache;
let id = Uuid::new_v4();
let snap = WorkspaceSnapshot {
session_id: id,
repo_id: Uuid::new_v4(),
agent_id: "agent".to_string(),
agent_name: "agent-1".to_string(),
changeset_id: Uuid::new_v4(),
intent: "intent".to_string(),
base_commit: "abc".to_string(),
state: "active".to_string(),
mode: "ephemeral".to_string(),
};
cache.cache_workspace(&id, &snap).await.expect("should not error");
let result = cache.get_workspace(&id).await.expect("should not error");
assert!(result.is_none());
}
#[tokio::test]
async fn noop_get_file_returns_none() {
let cache = NoOpCache;
let id = Uuid::new_v4();
let result = cache.get_file(&id, "src/lib.rs").await.expect("should not error");
assert!(result.is_none());
}
#[tokio::test]
async fn noop_cache_file_is_silent() {
let cache = NoOpCache;
let id = Uuid::new_v4();
let entry = CachedOverlayEntry::Modified {
content: b"hello".to_vec(),
hash: "abc".to_string(),
};
cache.cache_file(&id, "src/lib.rs", &entry).await.expect("should not error");
let result = cache.get_file(&id, "src/lib.rs").await.expect("should not error");
assert!(result.is_none());
}
#[tokio::test]
async fn noop_list_files_returns_empty() {
let cache = NoOpCache;
let id = Uuid::new_v4();
let files = cache.list_files(&id).await.expect("should not error");
assert!(files.is_empty(), "NoOpCache must return empty vec from list_files");
}
#[tokio::test]
async fn noop_get_graph_returns_none() {
let cache = NoOpCache;
let id = Uuid::new_v4();
let result = cache.get_graph(&id).await.expect("should not error");
assert!(result.is_none());
}
#[tokio::test]
async fn noop_cache_graph_is_silent() {
let cache = NoOpCache;
let id = Uuid::new_v4();
let data = b"graph-bytes";
cache.cache_graph(&id, data).await.expect("should not error");
let result = cache.get_graph(&id).await.expect("should not error");
assert!(result.is_none());
}
#[tokio::test]
async fn noop_evict_is_noop() {
let cache = NoOpCache;
let id = Uuid::new_v4();
cache.evict(&id).await.expect("evict should not error");
}
#[tokio::test]
async fn noop_touch_is_noop() {
let cache = NoOpCache;
let id = Uuid::new_v4();
cache.touch(&id).await.expect("touch should not error");
}
#[tokio::test]
async fn noop_cache_is_send_sync() {
let cache: std::sync::Arc<dyn WorkspaceCache> = std::sync::Arc::new(NoOpCache);
let id = Uuid::new_v4();
let result = cache.get_workspace(&id).await.expect("should not error");
assert!(result.is_none());
}
}