1use std::pin::Pin;
39use std::sync::Arc;
40use std::sync::atomic::{AtomicU8, Ordering};
41
42use serde_json::json;
43use tokio::sync::Mutex;
44
45use oxicode_agent::tools::{MemoryBackend, MemoryItem, ToolError};
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55pub enum BrainHealth {
56 Connected,
58 Degraded,
60 Unavailable,
62}
63
64impl BrainHealth {
65 pub fn info(self) -> &'static str {
66 match self {
67 BrainHealth::Connected => "ok: oxibrain daemon connected",
68 BrainHealth::Degraded => "degraded: oxibrain daemon unreachable",
69 BrainHealth::Unavailable => "degraded: oxibrain daemon unreachable",
70 }
71 }
72}
73
74const HEALTH_CONNECTED: u8 = 0;
75const HEALTH_DEGRADED: u8 = 1;
76const HEALTH_UNAVAILABLE: u8 = 2;
77fn encode_health(h: BrainHealth) -> u8 {
78 match h {
79 BrainHealth::Connected => HEALTH_CONNECTED,
80 BrainHealth::Degraded => HEALTH_DEGRADED,
81 BrainHealth::Unavailable => HEALTH_UNAVAILABLE,
82 }
83}
84fn decode_health(b: u8) -> BrainHealth {
85 match b {
86 HEALTH_CONNECTED => BrainHealth::Connected,
87 HEALTH_DEGRADED => BrainHealth::Degraded,
88 _ => BrainHealth::Unavailable,
89 }
90}
91
92#[derive(Debug, Clone)]
96pub enum MigrationError {
97 BackendOffline,
99 Backend(String),
101 Runtime(String),
103 Checkpoint(String),
105}
106
107impl std::fmt::Display for MigrationError {
108 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
109 match self {
110 MigrationError::BackendOffline => f.write_str("brain daemon offline"),
111 MigrationError::Backend(e) => write!(f, "brain write failed: {e}"),
112 MigrationError::Runtime(e) => write!(f, "migration runtime: {e}"),
113 MigrationError::Checkpoint(e) => write!(f, "checkpoint write failed: {e}"),
114 }
115 }
116}
117
118impl std::error::Error for MigrationError {}
119pub const DEFAULT_BRAIN_SCOPE: &str = "default";
128
129pub struct BrainMemoryBackend {
133 socket_path: std::path::PathBuf,
134 client: Arc<Mutex<Option<oxibrain_client::BrainClient>>>,
135 health: Arc<AtomicU8>,
136 scope: String,
137}
138
139impl std::fmt::Debug for BrainMemoryBackend {
140 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
141 f.debug_struct("BrainMemoryBackend")
142 .field("socket_path", &self.socket_path)
143 .field("scope", &self.scope)
144 .field(
145 "health",
146 &decode_health(self.health.load(Ordering::SeqCst)).info(),
147 )
148 .finish()
149 }
150}
151
152impl BrainMemoryBackend {
153 pub fn new(socket_path: impl Into<std::path::PathBuf>) -> Self {
156 Self {
157 socket_path: socket_path.into(),
158 client: Arc::new(Mutex::new(None)),
159 health: Arc::new(AtomicU8::new(HEALTH_UNAVAILABLE)),
160 scope: DEFAULT_BRAIN_SCOPE.to_string(),
161 }
162 }
163
164 pub fn with_scope(mut self, scope: impl Into<String>) -> Self {
167 self.scope = scope.into();
168 self
169 }
170
171 pub fn health(&self) -> BrainHealth {
173 decode_health(self.health.load(Ordering::SeqCst))
174 }
175
176 pub async fn connected(&self) -> bool {
179 self.client.lock().await.is_some()
180 }
181
182 pub fn put_sync(&self, content: &str, kind: &str, subject: &str) -> Result<String, ToolError> {
187 let rt = tokio::runtime::Builder::new_current_thread()
188 .enable_io()
189 .build()
190 .map_err(|e| format!("backend unavailable: tokio runtime: {e}"))?;
191 let future = <Self as MemoryBackend>::put(self, content, kind, subject);
192 rt.block_on(future)
193 }
194
195 async fn ensure_connected(&self) -> Result<(), ToolError> {
196 let mut guard = self.client.lock().await;
197 if guard.is_some() {
198 return Ok(());
199 }
200 match oxibrain_client::BrainClient::connect(&self.socket_path).await {
201 Ok(client) => {
202 *guard = Some(client);
203 self.health.store(HEALTH_CONNECTED, Ordering::SeqCst);
204 Ok(())
205 }
206 Err(e) => {
207 self.health.store(HEALTH_UNAVAILABLE, Ordering::SeqCst);
208 Err(format!(
209 "backend unavailable: oxibrain daemon unreachable at {}: {e}",
210 self.socket_path.display()
211 ))
212 }
213 }
214 }
215
216 async fn with_client<R>(
220 &self,
221 f: impl FnOnce(
222 &mut oxibrain_client::BrainClient,
223 ) -> futures::future::BoxFuture<'_, anyhow::Result<R>>,
224 ) -> Result<R, ToolError> {
225 self.ensure_connected().await?;
226 let mut guard = self.client.lock().await;
227 let client = guard.as_mut().ok_or_else(|| {
228 "backend unavailable: oxibrain client missing after handshake".to_string()
229 })?;
230 match f(client).await {
231 Ok(v) => {
232 self.health.store(HEALTH_CONNECTED, Ordering::SeqCst);
233 Ok(v)
234 }
235 Err(e) => {
236 self.health.store(HEALTH_DEGRADED, Ordering::SeqCst);
237 *guard = None;
238 Err(format!("backend unavailable: oxibrain call failed: {e}"))
239 }
240 }
241 }
242}
243
244impl MemoryBackend for BrainMemoryBackend {
245 fn put<'a>(
246 &'a self,
247 content: &'a str,
248 kind: &'a str,
249 subject: &'a str,
250 ) -> Pin<Box<dyn Future<Output = Result<String, ToolError>> + Send + 'a>> {
251 Box::pin(async move {
252 let content = content.to_string();
253 let kind = kind.to_string();
254 let subject = subject.to_string();
255 let scope = self.scope.clone();
256 let args = json!({
257 "content": content,
258 "kind": kind,
259 "subject": subject,
260 "scope": scope,
261 });
262 let raw = self
263 .with_client(|c| Box::pin(async move { c.call_tool("memory.put", args).await }))
264 .await?;
265 let id = serde_json::from_str::<serde_json::Value>(&raw)
268 .ok()
269 .and_then(|v| {
270 v.get("id")
271 .and_then(|i| i.as_str().map(|s| s.to_string()))
272 .or_else(|| v.get("id").and_then(|i| i.as_u64().map(|n| n.to_string())))
273 })
274 .unwrap_or_else(|| raw.trim().to_string());
275 Ok(id)
276 })
277 }
278
279 fn search<'a>(
280 &'a self,
281 query: &'a str,
282 k: usize,
283 ) -> Pin<Box<dyn Future<Output = Result<Vec<MemoryItem>, ToolError>> + Send + 'a>> {
284 Box::pin(async move {
285 let query = query.to_string();
286 let scope = self.scope.clone();
287 let args = json!({
288 "query": query,
289 "k": k,
290 "scope": scope,
291 });
292 let raw = self
293 .with_client(|c| Box::pin(async move { c.call_tool("memory.search", args).await }))
294 .await?;
295 parse_memory_items(&raw)
296 })
297 }
298
299 fn list<'a>(
300 &'a self,
301 subject: &'a str,
302 ) -> Pin<Box<dyn Future<Output = Result<Vec<MemoryItem>, ToolError>> + Send + 'a>> {
303 Box::pin(async move {
304 let subject = subject.to_string();
305 let scope = self.scope.clone();
306 let args = json!({
307 "subject": subject,
308 "scope": scope,
309 });
310 let raw = self
311 .with_client(|c| Box::pin(async move { c.call_tool("memory.list", args).await }))
312 .await?;
313 parse_memory_items(&raw)
314 })
315 }
316
317 fn delete<'a>(
318 &'a self,
319 id: &'a str,
320 ) -> Pin<Box<dyn Future<Output = Result<(), ToolError>> + Send + 'a>> {
321 Box::pin(async move {
322 let id = id.to_string();
323 let args = json!({ "id": id });
324 let _ = self
325 .with_client(|c| Box::pin(async move { c.call_tool("memory.delete", args).await }))
326 .await?;
327 Ok(())
328 })
329 }
330
331 fn memory_info(&self) -> Option<String> {
332 Some(self.health().info().to_string())
333 }
334}
335
336fn parse_memory_items(raw: &str) -> Result<Vec<MemoryItem>, ToolError> {
344 let value: serde_json::Value = serde_json::from_str(raw)
345 .map_err(|e| format!("backend unavailable: malformed memory response: {e}"))?;
346 let items = value
347 .get("items")
348 .and_then(|i| i.as_array())
349 .or_else(|| value.as_array())
350 .ok_or_else(|| "backend unavailable: memory response missing 'items' array".to_string())?;
351 let mut out = Vec::with_capacity(items.len());
352 for item in items {
353 let id = match item.get("id") {
354 Some(serde_json::Value::String(s)) => s.clone(),
355 Some(serde_json::Value::Number(n)) => n.to_string(),
356 _ => String::new(),
357 };
358 let kind = item
359 .get("kind")
360 .and_then(|v| v.as_str())
361 .unwrap_or("fact")
362 .to_string();
363 let content = item
364 .get("content")
365 .and_then(|v| v.as_str())
366 .unwrap_or("")
367 .to_string();
368 let subject = item
369 .get("subject")
370 .and_then(|v| v.as_str())
371 .unwrap_or("")
372 .to_string();
373 out.push(MemoryItem {
374 id,
375 kind,
376 content,
377 subject,
378 });
379 }
380 Ok(out)
381}
382
383pub fn default_socket_path() -> std::path::PathBuf {
391 if let Ok(p) = std::env::var("OXIBRAIN_SOCKET") {
392 return std::path::PathBuf::from(p);
393 }
394 if let Some(runtime) = std::env::var_os("XDG_RUNTIME_DIR") {
395 return std::path::PathBuf::from(runtime).join("oxibrain.sock");
396 }
397 if let Some(home) = dirs::home_dir() {
398 return home.join(".oxi").join("run").join("oxibrain.sock");
399 }
400 std::path::PathBuf::from("/tmp/oxibrain.sock")
401}
402
403#[cfg(test)]
404mod tests {
405 use super::*;
406
407 #[test]
408 fn health_info_strings_match_spec() {
409 assert_eq!(
410 BrainHealth::Connected.info(),
411 "ok: oxibrain daemon connected"
412 );
413 assert_eq!(
414 BrainHealth::Degraded.info(),
415 "degraded: oxibrain daemon unreachable"
416 );
417 assert_eq!(
418 BrainHealth::Unavailable.info(),
419 "degraded: oxibrain daemon unreachable"
420 );
421 }
422
423 #[test]
424 fn health_round_trip() {
425 for h in [
426 BrainHealth::Connected,
427 BrainHealth::Degraded,
428 BrainHealth::Unavailable,
429 ] {
430 assert_eq!(decode_health(encode_health(h)), h);
431 }
432 }
433
434 #[test]
435 fn parse_memory_items_accepts_bare_array() {
436 let raw = r#"[{"id":"a","kind":"fact","content":"hello","subject":"proj"}]"#;
437 let items = parse_memory_items(raw).unwrap();
438 assert_eq!(items.len(), 1);
439 assert_eq!(items[0].id, "a");
440 assert_eq!(items[0].kind, "fact");
441 assert_eq!(items[0].content, "hello");
442 assert_eq!(items[0].subject, "proj");
443 }
444
445 #[test]
446 fn parse_memory_items_accepts_wrapped_array() {
447 let raw = r#"{"items":[{"id":"a","kind":"fact","content":"hello","subject":"proj"}]}"#;
448 let items = parse_memory_items(raw).unwrap();
449 assert_eq!(items.len(), 1);
450 assert_eq!(items[0].id, "a");
451 }
452
453 #[test]
454 fn parse_memory_items_rejects_missing_items() {
455 let raw = r#"{"unexpected":"shape"}"#;
456 let err = parse_memory_items(raw).unwrap_err();
457 assert!(err.starts_with("backend unavailable"));
458 }
459
460 #[test]
461 fn backend_starts_unavailable() {
462 let backend = BrainMemoryBackend::new("/tmp/does-not-exist.sock");
463 assert_eq!(backend.health(), BrainHealth::Unavailable);
464 assert_eq!(
465 backend.memory_info().as_deref(),
466 Some("degraded: oxibrain daemon unreachable")
467 );
468 }
469
470 #[test]
471 fn backend_with_scope_keeps_scope() {
472 let backend = BrainMemoryBackend::new("/tmp/x.sock").with_scope("oxicode/main");
473 assert_eq!(backend.scope, "oxicode/main");
474 }
475}