Skip to main content

meerkat_mobkit/runtime/
memory.rs

1//! Memory subsystem — local-JSON ledger store adapter and query execution.
2
3use super::*;
4
5impl LocalJsonMemoryStoreAdapter {
6    pub(super) fn from_config(
7        config: &LocalJsonMemoryBackendConfig,
8    ) -> Result<Self, LocalJsonMemoryStoreError> {
9        let health_check_endpoint = match config.health_check_endpoint.as_deref() {
10            None => None,
11            Some(raw) => {
12                let endpoint = raw.trim();
13                if endpoint.is_empty() {
14                    return Err(LocalJsonMemoryStoreError::InvalidConfig(
15                        "memory backend health_check_endpoint must not be empty when provided"
16                            .to_string(),
17                    ));
18                }
19                Some(endpoint.to_string())
20            }
21        };
22        let state_path = config.state_path.trim();
23        if state_path.is_empty() {
24            return Err(LocalJsonMemoryStoreError::InvalidConfig(
25                "memory backend state_path must not be empty".to_string(),
26            ));
27        }
28        Ok(Self {
29            health_check_endpoint,
30            state_path: PathBuf::from(state_path),
31        })
32    }
33
34    fn ensure_remote_health(&self) -> Result<(), LocalJsonMemoryStoreError> {
35        let Some(endpoint) = self.health_check_endpoint.as_deref() else {
36            return Ok(());
37        };
38        let health_url = format!("{}/v1/health", endpoint.trim_end_matches('/'));
39        let parsed = parse_http_url(&health_url)?;
40        let authority = format!("{}:{}", parsed.host, parsed.port);
41        let mut addrs = authority.to_socket_addrs().map_err(|err| {
42            LocalJsonMemoryStoreError::ExternalCallFailed(format!(
43                "healthcheck resolve failed for '{health_url}': {err}"
44            ))
45        })?;
46        let addr = addrs.next().ok_or_else(|| {
47            LocalJsonMemoryStoreError::ExternalCallFailed(format!(
48                "healthcheck resolve failed for '{health_url}': no socket addresses"
49            ))
50        })?;
51        let mut stream = TcpStream::connect_timeout(&addr, MEMORY_LEDGER_HEALTHCHECK_TIMEOUT)
52            .map_err(|err| {
53                LocalJsonMemoryStoreError::ExternalCallFailed(format!(
54                    "healthcheck connect failed for '{health_url}': {err}"
55                ))
56            })?;
57        stream
58            .set_read_timeout(Some(MEMORY_LEDGER_HEALTHCHECK_TIMEOUT))
59            .map_err(|err| {
60                LocalJsonMemoryStoreError::ExternalCallFailed(format!(
61                    "healthcheck timeout setup failed for '{health_url}': {err}"
62                ))
63            })?;
64        stream
65            .set_write_timeout(Some(MEMORY_LEDGER_HEALTHCHECK_TIMEOUT))
66            .map_err(|err| {
67                LocalJsonMemoryStoreError::ExternalCallFailed(format!(
68                    "healthcheck timeout setup failed for '{health_url}': {err}"
69                ))
70            })?;
71        let request = format!(
72            "GET {} HTTP/1.1\r\nHost: {}\r\nConnection: close\r\n\r\n",
73            parsed.path, parsed.host
74        );
75        stream.write_all(request.as_bytes()).map_err(|err| {
76            LocalJsonMemoryStoreError::ExternalCallFailed(format!(
77                "healthcheck write failed for '{health_url}': {err}"
78            ))
79        })?;
80        let mut reader = BufReader::new(stream);
81        let mut status_line = String::new();
82        let bytes_read = reader.read_line(&mut status_line).map_err(|err| {
83            LocalJsonMemoryStoreError::ExternalCallFailed(format!(
84                "healthcheck read failed for '{health_url}': {err}"
85            ))
86        })?;
87        if bytes_read == 0 {
88            return Err(LocalJsonMemoryStoreError::ExternalCallFailed(format!(
89                "healthcheck read failed for '{health_url}': empty response"
90            )));
91        }
92        let status_code = status_line
93            .split_whitespace()
94            .nth(1)
95            .and_then(|value| value.parse::<u16>().ok())
96            .ok_or_else(|| {
97                LocalJsonMemoryStoreError::ExternalCallFailed(format!(
98                    "healthcheck parse failed for '{health_url}': invalid status line '{}'",
99                    status_line.trim()
100                ))
101            })?;
102        if (200..300).contains(&status_code) {
103            Ok(())
104        } else {
105            Err(LocalJsonMemoryStoreError::ExternalCallFailed(format!(
106                "healthcheck status failed for '{health_url}': HTTP {status_code}"
107            )))
108        }
109    }
110
111    pub(super) fn read_state(&self) -> Result<PersistedMemoryState, LocalJsonMemoryStoreError> {
112        self.ensure_remote_health()?;
113        if !self.state_path.exists() {
114            return Ok(PersistedMemoryState::default());
115        }
116        let bytes = fs::read(&self.state_path)
117            .map_err(|err| LocalJsonMemoryStoreError::Io(err.to_string()))?;
118        serde_json::from_slice::<PersistedMemoryState>(&bytes)
119            .map_err(|err| LocalJsonMemoryStoreError::InvalidStoreData(err.to_string()))
120    }
121
122    fn write_state(&self, state: &PersistedMemoryState) -> Result<(), LocalJsonMemoryStoreError> {
123        self.ensure_remote_health()?;
124        if let Some(parent) = self.state_path.parent() {
125            fs::create_dir_all(parent)
126                .map_err(|err| LocalJsonMemoryStoreError::Io(err.to_string()))?;
127        }
128        let tmp_path = self.state_path.with_extension("tmp");
129        let json = serde_json::to_vec_pretty(state)
130            .map_err(|err| LocalJsonMemoryStoreError::Serialize(err.to_string()))?;
131        fs::write(&tmp_path, json).map_err(|err| LocalJsonMemoryStoreError::Io(err.to_string()))?;
132        fs::rename(&tmp_path, &self.state_path)
133            .map_err(|err| LocalJsonMemoryStoreError::Io(err.to_string()))?;
134        Ok(())
135    }
136}
137
138#[derive(Debug, Clone, PartialEq, Eq)]
139struct ParsedHttpUrl {
140    host: String,
141    port: u16,
142    path: String,
143}
144
145fn parse_http_url(url: &str) -> Result<ParsedHttpUrl, LocalJsonMemoryStoreError> {
146    let trimmed = url.trim();
147    let without_scheme = trimmed.strip_prefix("http://").ok_or_else(|| {
148        LocalJsonMemoryStoreError::InvalidConfig(format!(
149            "memory backend endpoint must start with http:// (got '{trimmed}')"
150        ))
151    })?;
152    if without_scheme.is_empty() {
153        return Err(LocalJsonMemoryStoreError::InvalidConfig(
154            "memory backend endpoint host must not be empty".to_string(),
155        ));
156    }
157    let (authority, path_suffix) = without_scheme
158        .split_once('/')
159        .map(|(left, right)| (left, format!("/{right}")))
160        .unwrap_or((without_scheme, "/".to_string()));
161    if authority.is_empty() {
162        return Err(LocalJsonMemoryStoreError::InvalidConfig(
163            "memory backend endpoint host must not be empty".to_string(),
164        ));
165    }
166    let (host, port) = match authority.rsplit_once(':') {
167        Some((host, raw_port))
168            if !host.is_empty() && raw_port.chars().all(|c| c.is_ascii_digit()) =>
169        {
170            let parsed = raw_port.parse::<u16>().map_err(|_| {
171                LocalJsonMemoryStoreError::InvalidConfig(format!(
172                    "memory backend endpoint port is invalid in '{trimmed}'"
173                ))
174            })?;
175            (host.to_string(), parsed)
176        }
177        _ => (authority.to_string(), 80_u16),
178    };
179    if host.is_empty() {
180        return Err(LocalJsonMemoryStoreError::InvalidConfig(
181            "memory backend endpoint host must not be empty".to_string(),
182        ));
183    }
184    Ok(ParsedHttpUrl {
185        host,
186        port,
187        path: path_suffix,
188    })
189}
190
191impl MobkitRuntimeHandle {
192    fn next_memory_sequence(&mut self) -> u64 {
193        Self::next_sequence(&mut self.memory_sequence)
194    }
195
196    pub(super) fn canonical_memory_token(raw: &str) -> Option<String> {
197        let token = raw.trim().to_ascii_lowercase();
198        if token.is_empty() { None } else { Some(token) }
199    }
200
201    pub(super) fn canonical_memory_store(raw: &str) -> Option<String> {
202        let store = Self::canonical_memory_token(raw)?;
203        if MEMORY_SUPPORTED_STORES.contains(&store.as_str()) {
204            Some(store)
205        } else {
206            None
207        }
208    }
209
210    fn default_memory_store() -> String {
211        "knowledge_graph".to_string()
212    }
213
214    pub(super) fn memory_conflict_for_reference(
215        &self,
216        entity: Option<&str>,
217        topic: Option<&str>,
218    ) -> Option<MemoryConflictSignal> {
219        let canonical_entity = entity.and_then(Self::canonical_memory_token);
220        let canonical_topic = topic.and_then(Self::canonical_memory_token);
221        match (canonical_entity, canonical_topic) {
222            (Some(entity), Some(topic)) => self
223                .memory_conflicts
224                .values()
225                .find(|signal| signal.entity == entity && signal.topic == topic)
226                .cloned(),
227            (Some(entity), None) => self
228                .memory_conflicts
229                .values()
230                .find(|signal| signal.entity == entity)
231                .cloned(),
232            (None, Some(topic)) => self
233                .memory_conflicts
234                .values()
235                .find(|signal| signal.topic == topic)
236                .cloned(),
237            (None, None) => None,
238        }
239    }
240    pub fn memory_stores(&self) -> Vec<MemoryStoreInfo> {
241        MEMORY_SUPPORTED_STORES
242            .iter()
243            .map(|store| MemoryStoreInfo {
244                store: (*store).to_string(),
245                record_count: self
246                    .memory_assertions
247                    .iter()
248                    .filter(|assertion| assertion.store == *store)
249                    .count()
250                    + self
251                        .memory_conflicts
252                        .values()
253                        .filter(|signal| signal.store == *store)
254                        .count(),
255            })
256            .collect()
257    }
258
259    fn persist_memory_state(&self) -> Result<(), MemoryIndexError> {
260        let Some(backend) = self.memory_backend.as_ref() else {
261            return Ok(());
262        };
263        let state = PersistedMemoryState {
264            assertions: self.memory_assertions.clone(),
265            conflicts: self.memory_conflicts.values().cloned().collect::<Vec<_>>(),
266        };
267        backend
268            .write_state(&state)
269            .map_err(MemoryIndexError::BackendPersistFailed)
270    }
271
272    pub fn memory_index(
273        &mut self,
274        request: MemoryIndexRequest,
275    ) -> Result<MemoryIndexResult, MemoryIndexError> {
276        let entity = Self::canonical_memory_token(&request.entity)
277            .ok_or(MemoryIndexError::EntityRequired)?;
278        let topic =
279            Self::canonical_memory_token(&request.topic).ok_or(MemoryIndexError::TopicRequired)?;
280        let store = match request.store.as_deref() {
281            None => Self::default_memory_store(),
282            Some(raw_store) => Self::canonical_memory_store(raw_store)
283                .ok_or_else(|| MemoryIndexError::UnsupportedStore(raw_store.trim().to_string()))?,
284        };
285        let fact = request
286            .fact
287            .as_deref()
288            .map(str::trim)
289            .filter(|value| !value.is_empty())
290            .map(ToString::to_string);
291        let conflict = request.conflict.unwrap_or(false);
292        if fact.is_none() && !conflict {
293            return Err(MemoryIndexError::FactRequiredWhenConflictUnset);
294        }
295
296        let previous_memory_assertions = self.memory_assertions.clone();
297        let previous_memory_conflicts = self.memory_conflicts.clone();
298        let previous_memory_sequence = self.memory_sequence;
299
300        let mut assertion_id = None;
301        if let Some(fact) = fact {
302            let assertion_sequence = self.next_memory_sequence();
303            let assertion = MemoryAssertion {
304                assertion_id: format!("memory-assert-{assertion_sequence:06}"),
305                entity: entity.clone(),
306                topic: topic.clone(),
307                store: store.clone(),
308                fact,
309                metadata: request.metadata.clone(),
310                indexed_at_ms: current_time_ms(),
311            };
312            assertion_id = Some(assertion.assertion_id.clone());
313            self.memory_assertions.push(assertion);
314            while self.memory_assertions.len() > MEMORY_ASSERTIONS_MAX_RETAINED {
315                self.memory_assertions.remove(0);
316            }
317        }
318
319        if conflict {
320            let conflict_key = MemoryConflictKey {
321                entity: entity.clone(),
322                topic: topic.clone(),
323                store: store.clone(),
324            };
325            self.memory_conflicts.insert(
326                conflict_key,
327                MemoryConflictSignal {
328                    entity: entity.clone(),
329                    topic: topic.clone(),
330                    store: store.clone(),
331                    reason: request
332                        .conflict_reason
333                        .as_deref()
334                        .map(str::trim)
335                        .filter(|value| !value.is_empty())
336                        .map(ToString::to_string),
337                    updated_at_ms: current_time_ms(),
338                },
339            );
340        }
341        if let Err(error) = self.persist_memory_state() {
342            self.memory_assertions = previous_memory_assertions;
343            self.memory_conflicts = previous_memory_conflicts;
344            self.memory_sequence = previous_memory_sequence;
345            return Err(error);
346        }
347
348        let conflict_active = self
349            .memory_conflict_for_reference(Some(entity.as_str()), Some(topic.as_str()))
350            .is_some();
351
352        Ok(MemoryIndexResult {
353            entity,
354            topic,
355            store,
356            assertion_id,
357            conflict_active,
358        })
359    }
360    pub fn memory_query(&self, request: MemoryQueryRequest) -> MemoryQueryResult {
361        let entity = request
362            .entity
363            .as_deref()
364            .and_then(Self::canonical_memory_token);
365        let topic = request
366            .topic
367            .as_deref()
368            .and_then(Self::canonical_memory_token);
369        let store = request
370            .store
371            .as_deref()
372            .and_then(Self::canonical_memory_store);
373        let query = request
374            .query
375            .as_deref()
376            .and_then(Self::canonical_memory_token);
377        let assertion_matches_query = |assertion: &MemoryAssertion| {
378            query.as_ref().is_none_or(|needle| {
379                assertion.entity.contains(needle.as_str())
380                    || assertion.topic.contains(needle.as_str())
381                    || assertion
382                        .fact
383                        .to_ascii_lowercase()
384                        .contains(needle.as_str())
385            })
386        };
387        let conflict_matches_query = |signal: &MemoryConflictSignal| {
388            query.as_ref().is_none_or(|needle| {
389                signal.entity.contains(needle.as_str())
390                    || signal.topic.contains(needle.as_str())
391                    || signal
392                        .reason
393                        .as_deref()
394                        .is_some_and(|reason| reason.to_ascii_lowercase().contains(needle.as_str()))
395            })
396        };
397        let assertions = self
398            .memory_assertions
399            .iter()
400            .filter(|assertion| {
401                entity
402                    .as_ref()
403                    .is_none_or(|value| assertion.entity.as_str() == value.as_str())
404            })
405            .filter(|assertion| {
406                topic
407                    .as_ref()
408                    .is_none_or(|value| assertion.topic.as_str() == value.as_str())
409            })
410            .filter(|assertion| {
411                store
412                    .as_ref()
413                    .is_none_or(|value| assertion.store.as_str() == value.as_str())
414            })
415            .filter(|assertion| assertion_matches_query(assertion))
416            .cloned()
417            .collect::<Vec<_>>();
418        let conflicts = self
419            .memory_conflicts
420            .values()
421            .filter(|signal| {
422                entity
423                    .as_ref()
424                    .is_none_or(|value| signal.entity.as_str() == value.as_str())
425            })
426            .filter(|signal| {
427                topic
428                    .as_ref()
429                    .is_none_or(|value| signal.topic.as_str() == value.as_str())
430            })
431            .filter(|signal| {
432                store
433                    .as_ref()
434                    .is_none_or(|value| signal.store.as_str() == value.as_str())
435            })
436            .filter(|signal| conflict_matches_query(signal))
437            .cloned()
438            .collect::<Vec<_>>();
439        MemoryQueryResult {
440            assertions,
441            conflicts,
442        }
443    }
444}
445
446#[cfg(test)]
447#[allow(clippy::expect_used)]
448mod tests {
449    use super::*;
450
451    #[test]
452    fn local_json_adapter_roundtrips_state_without_health_endpoint() {
453        let temp = tempfile::tempdir().expect("temp dir");
454        let state_path = temp.path().join("memory-ledger-state.json");
455        let adapter = LocalJsonMemoryStoreAdapter::from_config(&LocalJsonMemoryBackendConfig {
456            state_path: state_path.to_string_lossy().to_string(),
457            health_check_endpoint: None,
458        })
459        .expect("adapter from config");
460
461        let state = PersistedMemoryState {
462            assertions: vec![MemoryAssertion {
463                assertion_id: "memory-assert-000001".to_string(),
464                entity: "delivery".to_string(),
465                topic: "email_send".to_string(),
466                store: "todo".to_string(),
467                fact: "double-check recipient consent".to_string(),
468                metadata: None,
469                indexed_at_ms: 1,
470            }],
471            conflicts: vec![],
472        };
473        adapter.write_state(&state).expect("write state");
474        let reloaded = adapter.read_state().expect("read state");
475        assert_eq!(reloaded, state);
476    }
477
478    #[test]
479    fn local_json_adapter_rejects_empty_health_endpoint() {
480        let error = LocalJsonMemoryStoreAdapter::from_config(&LocalJsonMemoryBackendConfig {
481            state_path: "/tmp/state.json".to_string(),
482            health_check_endpoint: Some("   ".to_string()),
483        })
484        .expect_err("empty health endpoint must be rejected");
485        assert!(matches!(error, LocalJsonMemoryStoreError::InvalidConfig(_)));
486    }
487
488    #[test]
489    fn legacy_elephant_config_maps_endpoint_to_health_check() {
490        let legacy = ElephantMemoryBackendConfig {
491            endpoint: "http://localhost:3000".to_string(),
492            state_path: "/tmp/state.json".to_string(),
493        };
494        let config = LocalJsonMemoryBackendConfig::from(legacy);
495        assert_eq!(
496            config.health_check_endpoint.as_deref(),
497            Some("http://localhost:3000")
498        );
499        assert_eq!(config.state_path, "/tmp/state.json");
500    }
501
502    #[test]
503    fn memory_backend_config_accepts_legacy_elephant_wire_shape() {
504        let parsed: MemoryBackendConfig = serde_json::from_value(serde_json::json!({
505            "kind": "elephant",
506            "endpoint": "http://localhost:3000",
507            "state_path": "/tmp/state.json"
508        }))
509        .expect("legacy wire shape deserializes");
510        assert_eq!(
511            parsed,
512            MemoryBackendConfig::Elephant(ElephantMemoryBackendConfig {
513                endpoint: "http://localhost:3000".to_string(),
514                state_path: "/tmp/state.json".to_string(),
515            })
516        );
517
518        let parsed: MemoryBackendConfig = serde_json::from_value(serde_json::json!({
519            "kind": "local_json",
520            "state_path": "/tmp/state.json"
521        }))
522        .expect("local_json wire shape deserializes");
523        assert_eq!(
524            parsed,
525            MemoryBackendConfig::LocalJson(LocalJsonMemoryBackendConfig {
526                state_path: "/tmp/state.json".to_string(),
527                health_check_endpoint: None,
528            })
529        );
530    }
531}