pub mod file;
pub mod http;
pub mod mapping;
pub mod mcp;
pub mod memory;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::sync::Arc;
use std::time::Duration;
pub const ENVELOPE_VERSION: u32 = 2;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Envelope {
pub v: u32,
pub kind: String,
pub id: String,
pub seq: u64,
pub ts: u64,
pub instance: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub hash: Option<String>,
pub state: Value,
}
impl Envelope {
pub fn new(
kind: &str,
id: &str,
seq: u64,
instance: &str,
hash: Option<String>,
state: Value,
) -> Envelope {
Envelope {
v: ENVELOPE_VERSION,
kind: kind.to_string(),
id: id.to_string(),
seq,
ts: now_ms(),
instance: instance.to_string(),
hash,
state,
}
}
pub fn is_tombstone(&self) -> bool {
self.state.is_null()
}
pub fn to_value(&self) -> Value {
serde_json::to_value(self).unwrap_or(Value::Null)
}
pub fn from_value(v: Value) -> Result<Envelope, StoreError> {
let env: Envelope = serde_json::from_value(v)
.map_err(|e| StoreError::Corrupt(format!("envelope does not parse: {e}")))?;
if env.v != ENVELOPE_VERSION {
return Err(StoreError::Corrupt(format!(
"envelope version {} is not supported (this build writes {})",
env.v, ENVELOPE_VERSION
)));
}
Ok(env)
}
}
pub fn key(prefix: &str, instance: &str, kind: &str, id: &str) -> String {
format!("{prefix}/{instance}/{kind}/{id}")
}
pub fn parse_key<'a>(prefix: &str, instance: &str, k: &'a str) -> Option<(&'a str, &'a str)> {
let rest = k.strip_prefix(&format!("{prefix}/{instance}/"))?;
let (kind, id) = rest.split_once('/')?;
Some((kind, id))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PutOutcome {
Ok,
Conflict {
latest_seq: Option<u64>,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct KeySeq {
pub key: String,
pub seq: Option<u64>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum StoreError {
Io(String),
Unsupported(&'static str),
Mapping(String),
Corrupt(String),
Conflict(String),
}
impl std::fmt::Display for StoreError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
StoreError::Io(m) => write!(f, "store i/o: {m}"),
StoreError::Unsupported(op) => {
write!(f, "store: {op} is not supported by this adapter")
}
StoreError::Mapping(m) => write!(f, "store mapping: {m}"),
StoreError::Corrupt(m) => write!(f, "store record: {m}"),
StoreError::Conflict(m) => write!(f, "store conflict: {m}"),
}
}
}
impl std::error::Error for StoreError {}
pub trait Store: Send + Sync {
fn put(&self, key: &str, seq: u64, envelope: &Value) -> Result<PutOutcome, StoreError>;
fn get(&self, key: &str, seq: Option<u64>) -> Result<Option<Value>, StoreError>;
fn list(&self, prefix: &str) -> Result<Vec<KeySeq>, StoreError>;
fn delete(&self, key: &str) -> Result<(), StoreError>;
fn kind(&self) -> &'static str;
}
pub type SharedStore = Arc<dyn Store>;
pub fn default_timeout() -> Duration {
crate::obs::health::management_timeout()
}
pub fn with_retry<T>(
mut op: impl FnMut() -> Result<T, StoreError>,
attempts: u32,
) -> Result<T, StoreError> {
let mut last = None;
for n in 0..attempts.max(1) {
match op() {
Err(StoreError::Io(m)) => {
last = Some(StoreError::Io(m));
if n + 1 < attempts {
std::thread::sleep(Duration::from_millis(50 * (n as u64 + 1)));
}
}
other => return other,
}
}
Err(last.unwrap_or(StoreError::Io("no attempts".into())))
}
pub(crate) fn now_ms() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0)
}
pub fn open(
settings: &crate::config::v2::Store,
servers: &dyn Fn(&str) -> Option<Arc<dyn mcp::McpCall>>,
) -> Result<Option<SharedStore>, StoreError> {
use crate::config::v2::StoreKind;
let timeout = settings
.timeout
.map(|d| d.0)
.unwrap_or_else(default_timeout);
match settings.kind {
StoreKind::None => Ok(None),
StoreKind::Memory => Ok(Some(Arc::new(memory::MemoryStore::new()))),
StoreKind::Mcp => {
let cfg = settings.mcp.as_ref().ok_or_else(|| {
StoreError::Mapping("store.kind is mcp but store.mcp is not set".into())
})?;
let client = servers(&cfg.server).ok_or_else(|| {
StoreError::Mapping(format!(
"store.mcp.server '{}' is not a connected MCP server",
cfg.server
))
})?;
Ok(Some(Arc::new(mcp::McpStore::new(
client,
cfg.clone(),
timeout,
))))
}
StoreKind::File => {
let root = crate::config::v2::file_store_root(settings);
let store = file::FileStore::open(&root).map_err(|e| match e {
StoreError::Io(m) => StoreError::Io(format!("store.file: {m}")),
other => other,
})?;
Ok(Some(Arc::new(store)))
}
StoreKind::Http => {
let cfg = settings.http.as_ref().ok_or_else(|| {
StoreError::Mapping("store.kind is http but store.http is not set".into())
})?;
Ok(Some(Arc::new(http::HttpStore::new(cfg.clone(), timeout)?)))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn envelope_round_trips_and_refuses_unknown_major() {
let e = Envelope::new("run", "01J", 3, "inst", Some("abc".into()), json!({"a": 1}));
let v = e.to_value();
assert_eq!(v["v"], json!(2));
assert_eq!(v["seq"], json!(3));
let back = Envelope::from_value(v).unwrap();
assert_eq!(back, e);
let mut bad = e.to_value();
bad["v"] = json!(9);
assert!(matches!(
Envelope::from_value(bad),
Err(StoreError::Corrupt(_))
));
assert!(!e.is_tombstone());
assert!(Envelope::new("run", "x", 1, "i", None, Value::Null).is_tombstone());
}
#[test]
fn keys_compose_and_parse() {
let k = key("agentd", "inst-0", "run", "01J");
assert_eq!(k, "agentd/inst-0/run/01J");
assert_eq!(parse_key("agentd", "inst-0", &k), Some(("run", "01J")));
assert_eq!(parse_key("agentd", "other", &k), None);
assert_eq!(
parse_key("agentd", "i", "agentd/i/task/a/b"),
Some(("task", "a/b"))
);
}
#[test]
fn retry_only_on_io() {
let mut n = 0;
let r: Result<(), StoreError> = with_retry(
|| {
n += 1;
Err(StoreError::Io("down".into()))
},
3,
);
assert!(matches!(r, Err(StoreError::Io(_))));
assert_eq!(n, 3);
let mut m = 0;
let r: Result<(), StoreError> = with_retry(
|| {
m += 1;
Err(StoreError::Mapping("bad".into()))
},
3,
);
assert!(matches!(r, Err(StoreError::Mapping(_))));
assert_eq!(m, 1);
}
}