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