1use 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}