1pub mod file;
16pub mod http;
17pub mod mapping;
18pub mod mcp;
19pub mod memory;
20
21use serde::{Deserialize, Serialize};
22use serde_json::Value;
23use std::sync::Arc;
24use std::time::Duration;
25
26pub const ENVELOPE_VERSION: u32 = 2;
28
29#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
32pub struct Envelope {
33 pub v: u32,
34 pub kind: String,
35 pub id: String,
36 pub seq: u64,
37 pub ts: u64,
39 pub instance: String,
40 #[serde(default, skip_serializing_if = "Option::is_none")]
41 pub hash: Option<String>,
42 pub state: Value,
43}
44
45impl Envelope {
46 pub fn new(
47 kind: &str,
48 id: &str,
49 seq: u64,
50 instance: &str,
51 hash: Option<String>,
52 state: Value,
53 ) -> Envelope {
54 Envelope {
55 v: ENVELOPE_VERSION,
56 kind: kind.to_string(),
57 id: id.to_string(),
58 seq,
59 ts: now_ms(),
60 instance: instance.to_string(),
61 hash,
62 state,
63 }
64 }
65
66 pub fn is_tombstone(&self) -> bool {
67 self.state.is_null()
68 }
69
70 pub fn to_value(&self) -> Value {
71 serde_json::to_value(self).unwrap_or(Value::Null)
72 }
73
74 pub fn from_value(v: Value) -> Result<Envelope, StoreError> {
76 let env: Envelope = serde_json::from_value(v)
77 .map_err(|e| StoreError::Corrupt(format!("envelope does not parse: {e}")))?;
78 if env.v != ENVELOPE_VERSION {
79 return Err(StoreError::Corrupt(format!(
80 "envelope version {} is not supported (this build writes {})",
81 env.v, ENVELOPE_VERSION
82 )));
83 }
84 Ok(env)
85 }
86}
87
88pub fn key(prefix: &str, instance: &str, kind: &str, id: &str) -> String {
90 format!("{prefix}/{instance}/{kind}/{id}")
91}
92
93pub fn parse_key<'a>(prefix: &str, instance: &str, k: &'a str) -> Option<(&'a str, &'a str)> {
96 let rest = k.strip_prefix(&format!("{prefix}/{instance}/"))?;
97 let (kind, id) = rest.split_once('/')?;
98 Some((kind, id))
99}
100
101#[derive(Debug, Clone, PartialEq, Eq)]
103pub enum PutOutcome {
104 Ok,
105 Conflict {
107 latest_seq: Option<u64>,
108 },
109}
110
111#[derive(Debug, Clone, PartialEq, Eq)]
113pub struct KeySeq {
114 pub key: String,
115 pub seq: Option<u64>,
116}
117
118#[derive(Debug, Clone, PartialEq, Eq)]
119pub enum StoreError {
120 Io(String),
122 Unsupported(&'static str),
124 Mapping(String),
126 Corrupt(String),
128 Conflict(String),
130}
131
132impl std::fmt::Display for StoreError {
133 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
134 match self {
135 StoreError::Io(m) => write!(f, "store i/o: {m}"),
136 StoreError::Unsupported(op) => {
137 write!(f, "store: {op} is not supported by this adapter")
138 }
139 StoreError::Mapping(m) => write!(f, "store mapping: {m}"),
140 StoreError::Corrupt(m) => write!(f, "store record: {m}"),
141 StoreError::Conflict(m) => write!(f, "store conflict: {m}"),
142 }
143 }
144}
145
146impl std::error::Error for StoreError {}
147
148pub trait Store: Send + Sync {
152 fn put(&self, key: &str, seq: u64, envelope: &Value) -> Result<PutOutcome, StoreError>;
154 fn get(&self, key: &str, seq: Option<u64>) -> Result<Option<Value>, StoreError>;
156 fn list(&self, prefix: &str) -> Result<Vec<KeySeq>, StoreError>;
158 fn delete(&self, key: &str) -> Result<(), StoreError>;
160 fn kind(&self) -> &'static str;
163}
164
165pub type SharedStore = Arc<dyn Store>;
167
168pub fn default_timeout() -> Duration {
171 crate::obs::health::management_timeout()
172}
173
174pub fn with_retry<T>(
176 mut op: impl FnMut() -> Result<T, StoreError>,
177 attempts: u32,
178) -> Result<T, StoreError> {
179 let mut last = None;
180 for n in 0..attempts.max(1) {
181 match op() {
182 Err(StoreError::Io(m)) => {
183 last = Some(StoreError::Io(m));
184 if n + 1 < attempts {
185 std::thread::sleep(Duration::from_millis(50 * (n as u64 + 1)));
186 }
187 }
188 other => return other,
189 }
190 }
191 Err(last.unwrap_or(StoreError::Io("no attempts".into())))
192}
193
194pub(crate) fn now_ms() -> u64 {
195 std::time::SystemTime::now()
196 .duration_since(std::time::UNIX_EPOCH)
197 .map(|d| d.as_millis() as u64)
198 .unwrap_or(0)
199}
200
201pub fn open(
205 settings: &crate::config::v2::Store,
206 servers: &dyn Fn(&str) -> Option<Arc<dyn mcp::McpCall>>,
207) -> Result<Option<SharedStore>, StoreError> {
208 use crate::config::v2::StoreKind;
209 let timeout = settings
210 .timeout
211 .map(|d| d.0)
212 .unwrap_or_else(default_timeout);
213 match settings.kind {
214 StoreKind::None => Ok(None),
215 StoreKind::Memory => Ok(Some(Arc::new(memory::MemoryStore::new()))),
216 StoreKind::Mcp => {
217 let cfg = settings.mcp.as_ref().ok_or_else(|| {
218 StoreError::Mapping("store.kind is mcp but store.mcp is not set".into())
219 })?;
220 let client = servers(&cfg.server).ok_or_else(|| {
221 StoreError::Mapping(format!(
222 "store.mcp.server '{}' is not a connected MCP server",
223 cfg.server
224 ))
225 })?;
226 Ok(Some(Arc::new(mcp::McpStore::new(
227 client,
228 cfg.clone(),
229 timeout,
230 ))))
231 }
232 StoreKind::File => {
233 let root = crate::config::v2::file_store_root(settings);
238 let store = file::FileStore::open(&root).map_err(|e| match e {
243 StoreError::Io(m) => StoreError::Io(format!("store.file: {m}")),
244 other => other,
245 })?;
246 Ok(Some(Arc::new(store)))
247 }
248 StoreKind::Http => {
249 let cfg = settings.http.as_ref().ok_or_else(|| {
250 StoreError::Mapping("store.kind is http but store.http is not set".into())
251 })?;
252 Ok(Some(Arc::new(http::HttpStore::new(cfg.clone(), timeout)?)))
253 }
254 }
255}
256
257#[cfg(test)]
258mod tests {
259 use super::*;
260 use serde_json::json;
261
262 #[test]
263 fn envelope_round_trips_and_refuses_unknown_major() {
264 let e = Envelope::new("run", "01J", 3, "inst", Some("abc".into()), json!({"a": 1}));
265 let v = e.to_value();
266 assert_eq!(v["v"], json!(2));
267 assert_eq!(v["seq"], json!(3));
268 let back = Envelope::from_value(v).unwrap();
269 assert_eq!(back, e);
270 let mut bad = e.to_value();
271 bad["v"] = json!(9);
272 assert!(matches!(
273 Envelope::from_value(bad),
274 Err(StoreError::Corrupt(_))
275 ));
276 assert!(!e.is_tombstone());
277 assert!(Envelope::new("run", "x", 1, "i", None, Value::Null).is_tombstone());
278 }
279
280 #[test]
281 fn keys_compose_and_parse() {
282 let k = key("agentd", "inst-0", "run", "01J");
283 assert_eq!(k, "agentd/inst-0/run/01J");
284 assert_eq!(parse_key("agentd", "inst-0", &k), Some(("run", "01J")));
285 assert_eq!(parse_key("agentd", "other", &k), None);
286 assert_eq!(
288 parse_key("agentd", "i", "agentd/i/task/a/b"),
289 Some(("task", "a/b"))
290 );
291 }
292
293 #[test]
294 fn retry_only_on_io() {
295 let mut n = 0;
296 let r: Result<(), StoreError> = with_retry(
297 || {
298 n += 1;
299 Err(StoreError::Io("down".into()))
300 },
301 3,
302 );
303 assert!(matches!(r, Err(StoreError::Io(_))));
304 assert_eq!(n, 3);
305 let mut m = 0;
306 let r: Result<(), StoreError> = with_retry(
307 || {
308 m += 1;
309 Err(StoreError::Mapping("bad".into()))
310 },
311 3,
312 );
313 assert!(matches!(r, Err(StoreError::Mapping(_))));
314 assert_eq!(m, 1);
315 }
316}