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