1use crate::{Result, RuntimeError};
5use agent_core::config::{MemoryBackendConfig, MemoryBackendKind};
6use agent_core::memory::forum::Author;
7use agent_core::memory::store::{
8 self, MemoryDescriptor, MemoryRecord, MemorySensitivity, NewMemoryRecord, ProjectMemoryQuery,
9 ProjectScope,
10};
11use serde_json::{json, Value};
12use std::path::{Path, PathBuf};
13use std::sync::Arc;
14mod competing;
15pub mod forum;
16mod history;
17pub mod migration;
18mod process;
19mod provisioning;
20pub mod repository_migration;
21
22pub fn competing_note_tool(name: &str) -> bool {
23 matches!(
24 name,
25 "memory_store"
26 | "memory_search"
27 | "memory_fetch"
28 | "memory_forget"
29 | "memory_capture"
30 | "memory_recall"
31 )
32}
33pub use competing::competing_memory_tool;
34
35pub const CONTRACT: &str = "synaps-axel/2";
36pub const USER_SCOPE: &str = "p0000000000000000";
37pub const AXEL_REVISION: &str = "edbdea401d66feedb87fcad28c879ece54e3ccd2";
38
39#[derive(Clone)]
40pub struct MemoryBinding(Arc<Binding>);
41impl std::fmt::Debug for MemoryBinding {
42 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
43 f.debug_struct("MemoryBinding")
44 .field("exclusive", &self.exclusive())
45 .finish_non_exhaustive()
46 }
47}
48#[derive(Clone)]
49struct Binding {
50 base: PathBuf,
51 scope: std::result::Result<ProjectScope, String>,
52 author: Author,
53 operation_lock: Arc<tokio::sync::Mutex<()>>,
54 repository: Option<agent_core::memory::repository::RepositoryIdentity>,
55 user_scope_enabled: bool,
56 managed_parent: bool,
57 backend: Backend,
58}
59#[derive(Clone)]
60enum Backend {
61 Legacy,
62 Axel { executable: PathBuf, brain: PathBuf },
63 Unavailable,
64}
65fn error(message: impl Into<String>) -> RuntimeError {
66 RuntimeError::Tool(format!("memory: {}", message.into()))
67}
68
69impl MemoryBinding {
70 pub fn configured_current() -> Self {
74 Self::from_config(&agent_core::config::load_config().memory_backend)
75 }
76 pub fn legacy_current() -> Self {
77 Self::from_config(&MemoryBackendConfig::default())
78 }
79 pub fn from_config(config: &MemoryBackendConfig) -> Self {
80 Self::from_config_with_cwd(config, std::env::current_dir().ok())
81 }
82 pub fn from_config_with_cwd(
86 config: &MemoryBackendConfig,
87 cwd: Option<std::path::PathBuf>,
88 ) -> Self {
89 let base = agent_core::config::base_dir();
90 let repository = if config.kind == MemoryBackendKind::Axel {
91 cwd.as_ref()
92 .map(|cwd| ProjectScope::discover_repository(cwd))
93 } else {
94 None
95 };
96 let scope = match &repository {
97 Some(Ok(identity)) => Ok(identity.scope.clone()),
98 Some(Err(_)) => {
99 Err("repository memory identity unavailable; no path-scope fallback".into())
100 }
101 None => cwd
102 .ok_or_else(|| "workspace unavailable".to_owned())
103 .and_then(|cwd| {
104 ProjectScope::discover(&cwd).map_err(|_| "project scope unavailable".to_owned())
105 }),
106 };
107 let mut binding = Self::new(base, scope, config);
108 Arc::get_mut(&mut binding.0)
109 .expect("new binding")
110 .repository = repository.and_then(std::result::Result::ok);
111 binding
112 }
113 fn new(
114 base: PathBuf,
115 scope: std::result::Result<ProjectScope, String>,
116 config: &MemoryBackendConfig,
117 ) -> Self {
118 let backend = match config.kind {
119 MemoryBackendKind::Legacy => Backend::Legacy,
120 MemoryBackendKind::Axel => {
121 let executable = config.executable.clone().or_else(|| {
122 std::env::current_exe().ok().and_then(|path| {
123 path.parent()
124 .map(|parent| parent.join("synaps-axel-memory-service"))
125 })
126 });
127 let brain = config
128 .brain
129 .clone()
130 .unwrap_or_else(|| base.join("memory/axel/brain.r8"));
131 match executable {
132 Some(executable) if executable.is_absolute() && brain.is_absolute() => {
133 Backend::Axel { executable, brain }
134 }
135 _ => Backend::Unavailable,
136 }
137 }
138 MemoryBackendKind::Unavailable => Backend::Unavailable,
139 };
140 Self(Arc::new(Binding {
141 base,
142 scope,
143 author: Author::fresh(),
144 operation_lock: Arc::new(tokio::sync::Mutex::new(())),
145 repository: None,
146 user_scope_enabled: config.user_scope,
147 managed_parent: config.brain.is_none(),
148 backend,
149 }))
150 }
151 pub fn repository_identity(
153 &self,
154 ) -> Result<agent_core::memory::repository::RepositoryIdentity> {
155 self.0
156 .repository
157 .clone()
158 .ok_or_else(|| error("repository identity unavailable"))
159 }
160 pub fn forum_author(&self) -> &Author {
163 &self.0.author
164 }
165 pub fn fork_for_worker(&self) -> Self {
169 Self(Arc::new(Binding {
170 author: self.0.author.child(),
171 ..(*self.0).clone()
172 }))
173 }
174 pub fn with_scope(&self, scope: ProjectScope) -> Self {
177 Self(Arc::new(Binding {
178 scope: Ok(scope),
179 ..(*self.0).clone()
180 }))
181 }
182 pub fn for_user_notes(&self) -> Result<Self> {
185 if !self.is_axel() || !self.0.user_scope_enabled {
186 return Err(error("user-wide notes require Axel and explicit memory.user_scope = true; no automatic sharing"));
187 }
188 let scope =
189 ProjectScope::user_scope(&self.0.base).map_err(|_| error("user scope unavailable"))?;
190 Ok(self.with_scope(scope))
191 }
192 pub fn brain_path(&self) -> Option<&Path> {
193 match &self.0.backend {
194 Backend::Axel { brain, .. } => Some(brain),
195 _ => None,
196 }
197 }
198 pub fn is_axel(&self) -> bool {
199 matches!(self.0.backend, Backend::Axel { .. }) && self.0.scope.is_ok()
200 }
201 pub fn validate_axel_config(&self) -> Result<()> {
204 match &self.0.backend {
205 Backend::Axel { executable, brain } if self.0.scope.is_ok() => {
206 process::validate_configuration(executable, brain)
207 }
208 _ => Err(error("Axel backend is not configured; no fallback")),
209 }
210 }
211 pub fn exclusive(&self) -> bool {
212 !matches!(self.0.backend, Backend::Legacy)
213 }
214 pub fn scope(&self) -> Result<&ProjectScope> {
215 self.0
216 .scope
217 .as_ref()
218 .map_err(|_| error("host project scope unavailable"))
219 }
220 pub fn base(&self) -> &Path {
221 &self.0.base
222 }
223 pub async fn rpc(&self, operation: &str, payload: Value) -> Result<Value> {
226 self.call(operation, payload).await
227 }
228 pub async fn migration_apply(&self, payload: Value) -> Result<Value> {
231 self.rpc("migration_apply", payload).await
232 }
233 pub async fn capabilities(&self) -> Result<Value> {
235 self.rpc("capabilities", json!({})).await
236 }
237 async fn call(&self, operation: &str, payload: Value) -> Result<Value> {
238 let _guard = self.0.operation_lock.lock().await;
239 let scope = self.scope()?;
240 if scope.key() == USER_SCOPE
241 && !matches!(
242 operation,
243 "store" | "search" | "fetch" | "forget" | "scope_info" | "capabilities"
244 )
245 {
246 return Err(error("user-wide scope supports explicit notes only"));
247 }
248 match &self.0.backend {
249 Backend::Axel { executable, brain } => {
250 process::validate_configuration(executable, brain)?;
252 if self.0.managed_parent
253 && !matches!(
254 operation,
255 "capabilities" | "legacy_export" | "native_export" | "upgrade_preview"
256 )
257 {
258 let base = self.0.base.clone();
259 let brain = brain.clone();
260 tokio::task::spawn_blocking(move || {
261 provisioning::ensure_default_parent(&base, &brain)
262 })
263 .await
264 .map_err(|_| error("Axel parent provisioning worker failed"))??;
265 }
266 process::call(executable, brain, scope.key(), operation, payload).await
267 }
268 _ => Err(error("selected backend unavailable; no legacy fallback")),
269 }
270 }
271 async fn authorized_projects(&self, projects: &[&str]) -> Result<Vec<String>> {
274 let current = self.scope()?.key();
275 if projects.iter().all(|project| *project == current) {
276 return Ok(vec![current.to_owned()]);
277 }
278 let info = self.call("scope_info", json!({})).await?;
279 let members: Vec<String> = serde_json::from_value(info["members"].clone())
280 .map_err(|_| error("invalid backend scope membership"))?;
281 let canonical = info["canonical_project"]
282 .as_str()
283 .ok_or_else(|| error("missing backend canonical scope"))?;
284 let valid = |key: &str| {
285 key.len() == 17
286 && key.starts_with('p')
287 && key[1..]
288 .bytes()
289 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
290 };
291 if members.is_empty()
292 || members.len() > 4096
293 || !valid(canonical)
294 || !members.iter().all(|key| valid(key))
295 || !members.iter().any(|key| key == current)
296 || !members.iter().any(|key| key == canonical)
297 || !projects
298 .iter()
299 .all(|project| members.iter().any(|member| member == project))
300 || (members.iter().any(|key| key == USER_SCOPE)
301 && (members.len() != 1 || current != USER_SCOPE))
302 {
303 return Err(error("backend scope membership violates isolation"));
304 }
305 Ok(members)
306 }
307 pub async fn search(&self, q: ProjectMemoryQuery) -> Result<Vec<MemoryDescriptor>> {
308 let scope = self.scope()?.clone();
309 if matches!(self.0.backend, Backend::Legacy) {
310 let base = self.0.base.clone();
311 return tokio::task::spawn_blocking(move || {
312 store::search_project_in(&base, &scope, &q)
313 })
314 .await
315 .map_err(|_| error("legacy search worker failed"))?
316 .map_err(|e| error(e.to_string()));
317 }
318 let limit = q
319 .limit
320 .unwrap_or(store::DEFAULT_SEARCH_LIMIT)
321 .min(store::MAX_SEARCH_LIMIT);
322 let snippet_bytes = q
323 .snippet_bytes
324 .unwrap_or(store::DEFAULT_SNIPPET_BYTES)
325 .min(store::MAX_SNIPPET_BYTES);
326 let value = self.call("search", json!({"content_contains":q.content_contains,"tag_prefix":q.tag_prefix,"since_ms":q.since_ms,"until_ms":q.until_ms,"limit":limit,"snippet_bytes":snippet_bytes})).await?;
327 let rows = value
328 .as_array()
329 .ok_or_else(|| error("invalid backend search response"))?;
330 if rows.len() > limit {
331 return Err(error("backend exceeded search bound"));
332 }
333 let source_projects = rows
334 .iter()
335 .map(|row| row["project"].as_str().unwrap_or(""))
336 .collect::<Vec<_>>();
337 let members = self.authorized_projects(&source_projects).await?;
338 rows.iter()
339 .map(|row| {
340 let d: Descriptor = serde_json::from_value(row.clone())
341 .map_err(|_| error("invalid backend descriptor"))?;
342 if !members.contains(&d.project)
343 || d.id.is_empty()
344 || d.id.len() > 128
345 || d.snippet.len() > snippet_bytes
346 || (d.sensitivity == MemorySensitivity::Secret && !d.snippet.is_empty())
347 {
348 return Err(error("backend descriptor violates scope/disclosure/bounds"));
349 }
350 Ok(MemoryDescriptor {
351 id: d.id,
352 project: d.project,
353 timestamp_ms: d.timestamp_ms,
354 tags: d.tags,
355 snippet: d.snippet,
356 truncated: d.truncated,
357 content_bytes: d.content_bytes,
358 sensitivity: d.sensitivity,
359 retention: d.retention,
360 })
361 })
362 .collect()
363 }
364 pub async fn fetch(&self, ids: &[&str]) -> Result<Vec<MemoryRecord>> {
365 let scope = self.scope()?.clone();
366 if ids.is_empty() || ids.len() > 25 || ids.iter().any(|id| id.is_empty() || id.len() > 128)
367 {
368 return Err(error("fetch requires 1..=25 bounded exact IDs"));
369 }
370 if matches!(self.0.backend, Backend::Legacy) {
371 let base = self.0.base.clone();
372 let ids = ids.iter().map(|s| s.to_string()).collect::<Vec<_>>();
373 return tokio::task::spawn_blocking(move || {
374 store::fetch_exact_in(
375 &base,
376 &scope,
377 &ids.iter().map(String::as_str).collect::<Vec<_>>(),
378 )
379 })
380 .await
381 .map_err(|_| error("legacy fetch worker failed"))?
382 .map_err(|e| error(e.to_string()));
383 }
384 let records: Vec<MemoryRecord> =
385 serde_json::from_value(self.call("fetch", json!({"ids":ids})).await?)
386 .map_err(|_| error("invalid backend fetch response"))?;
387 if records.len() != ids.len() {
388 return Err(error("backend fetch is incomplete"));
389 }
390 let source_projects = records
391 .iter()
392 .map(|record| record.project.as_deref().unwrap_or(""))
393 .collect::<Vec<_>>();
394 let members = self.authorized_projects(&source_projects).await?;
395 for (record, id) in records.iter().zip(ids) {
396 validate_record_members(record, &members, id)?;
397 }
398 Ok(records)
399 }
400 pub async fn store(&self, new: NewMemoryRecord) -> Result<MemoryRecord> {
401 let scope = self.scope()?.clone();
402 if new.content.len() > store::MAX_CONTENT_BYTES {
403 return Err(error("content exceeds 16384 bytes"));
404 }
405 if matches!(self.0.backend, Backend::Legacy) {
406 let base = self.0.base.clone();
407 return tokio::task::spawn_blocking(move || store::store_record_in(&base, &scope, new))
408 .await
409 .map_err(|_| error("legacy store worker failed"))?
410 .map_err(|e| error(e.to_string()));
411 }
412 let record = MemoryRecord {
413 namespace: scope.namespace(),
414 timestamp_ms: store::now_ms(),
415 content: new.content,
416 tags: new.tags,
417 meta: self
418 .0
419 .repository
420 .as_ref()
421 .filter(|_| scope.key() != USER_SCOPE)
422 .map(|identity| {
423 json!({
424 "_synaps_repository": {
425 "repository_project": identity.scope.key(),
426 "source_worktree_project": identity.legacy_scope.key(),
427 "source_worktree_root": identity.worktree_root,
428 }
429 })
430 }),
431 id: Some(format!("mem-{}", uuid::Uuid::new_v4().simple())),
432 project: Some(scope.key().into()),
433 provenance: Some(new.provenance),
434 sensitivity: Some(new.sensitivity),
435 retention: Some(new.retention),
436 };
437 let saved: MemoryRecord = serde_json::from_value(
438 self.call(
439 "store",
440 serde_json::to_value(&record).map_err(|_| error("record serialization failed"))?,
441 )
442 .await.map_err(|cause| error(format!("store outcome unconfirmed for {}: {cause}; fetch this exact ID to reconcile, never automatically create a replacement", record.id.as_deref().unwrap())))?,
443 )
444 .map_err(|_| {
445 error(format!(
446 "invalid backend store acknowledgement; commit unknown for {}; do not automatically retry", record.id.as_deref().unwrap()
447 ))
448 })?;
449 validate_record(&saved, &scope, record.id.as_deref().unwrap())?;
450 let mut expected = record;
451 if expected.sensitivity == Some(MemorySensitivity::Secret) {
452 expected.content.clear();
453 }
454 if saved != expected {
455 return Err(error("backend store acknowledgement mismatch; commit unknown, do not automatically retry"));
456 }
457 Ok(saved)
458 }
459 pub async fn forget(&self, id: &str) -> Result<()> {
460 let scope = self.scope()?.clone();
461 if id.is_empty() || id.len() > 128 {
462 return Err(error("invalid exact ID"));
463 }
464 if matches!(self.0.backend, Backend::Legacy) {
465 let base = self.0.base.clone();
466 let id = id.to_owned();
467 return tokio::task::spawn_blocking(move || store::forget_in(&base, &scope, &id))
468 .await
469 .map_err(|_| error("legacy forget worker failed"))?
470 .map_err(|e| error(e.to_string()));
471 }
472 match self.call("forget", json!({"id":id})).await? {
473 Value::Bool(true) => Ok(()),
474 Value::Bool(false) => Err(error("record not found in this project scope")),
475 _ => Err(error("invalid forget acknowledgement; commit unknown")),
476 }
477 }
478}
479fn validate_record(record: &MemoryRecord, scope: &ProjectScope, id: &str) -> Result<()> {
480 validate_record_members(record, &[scope.key().to_owned()], id)
481}
482fn validate_record_members(record: &MemoryRecord, members: &[String], id: &str) -> Result<()> {
483 if record.id.as_deref() != Some(id)
484 || !record
485 .project
486 .as_ref()
487 .is_some_and(|project| members.contains(project))
488 || (record.namespace != format!("project-{}", record.project.as_deref().unwrap_or(""))
489 && !(record.namespace == "captures"
490 && id.starts_with("mem-cap-")
491 && record
492 .provenance
493 .as_ref()
494 .is_some_and(|p| p.source == "capture")))
495 || record.content.len() > store::MAX_CONTENT_BYTES
496 || record.sensitivity.is_none()
497 || record.retention.is_none()
498 || (record.sensitivity == Some(MemorySensitivity::Secret) && !record.content.is_empty())
499 {
500 return Err(error("backend record violates scope/disclosure/bounds"));
501 }
502 Ok(())
503}
504#[derive(serde::Deserialize)]
505#[serde(deny_unknown_fields)]
506struct Descriptor {
507 id: String,
508 project: String,
509 timestamp_ms: u64,
510 tags: Vec<String>,
511 snippet: String,
512 truncated: bool,
513 content_bytes: usize,
514 sensitivity: MemorySensitivity,
515 retention: store::MemoryRetention,
516}
517
518#[cfg(test)]
519mod tests {
520 use super::*;
521
522 fn assert_same_storage(parent: &MemoryBinding, child: &MemoryBinding) {
523 assert_eq!(child.0.base, parent.0.base);
524 assert_eq!(child.0.scope, parent.0.scope);
525 assert_eq!(child.0.repository, parent.0.repository);
526 assert_eq!(child.0.user_scope_enabled, parent.0.user_scope_enabled);
527 assert_eq!(child.0.managed_parent, parent.0.managed_parent);
528 assert_eq!(child.exclusive(), parent.exclusive());
529 assert_eq!(child.is_axel(), parent.is_axel());
530 assert!(Arc::ptr_eq(
531 &child.0.operation_lock,
532 &parent.0.operation_lock
533 ));
534 match (&parent.0.backend, &child.0.backend) {
535 (Backend::Legacy, Backend::Legacy) | (Backend::Unavailable, Backend::Unavailable) => {}
536 (
537 Backend::Axel {
538 executable: a,
539 brain: b,
540 },
541 Backend::Axel {
542 executable: c,
543 brain: d,
544 },
545 ) => {
546 assert_eq!(a, c);
547 assert_eq!(b, d);
548 }
549 _ => panic!("worker changed the selected backend"),
550 }
551 }
552
553 #[test]
554 fn forum_author_is_fresh_but_clones_and_scope_rebindings_keep_identity() {
555 let dir = tempfile::tempdir().unwrap();
556 let scope = ProjectScope::for_root(dir.path()).unwrap();
557 let parent = MemoryBinding::new(dir.path().into(), Ok(scope.clone()), &Default::default());
558 let fresh = MemoryBinding::new(dir.path().into(), Ok(scope), &Default::default());
559 parent.forum_author().validate().unwrap();
560 assert!(parent.forum_author().parent.is_none());
561 assert_ne!(parent.forum_author().actor, fresh.forum_author().actor);
562 assert_ne!(parent.forum_author().group, fresh.forum_author().group);
563 let clone = parent.clone();
564 assert!(Arc::ptr_eq(&parent.0, &clone.0));
565 assert_eq!(clone.forum_author(), parent.forum_author());
566
567 let other = tempfile::tempdir().unwrap();
568 let other_scope = ProjectScope::for_root(other.path()).unwrap();
569 let worker = parent.fork_for_worker();
570 for binding in [&parent, &worker] {
571 let scoped = binding.with_scope(other_scope.clone());
572 assert_eq!(scoped.scope().unwrap(), &other_scope);
573 assert_eq!(scoped.forum_author(), binding.forum_author());
574 assert_same_storage(
576 binding,
577 &scoped.with_scope(binding.scope().unwrap().clone()),
578 );
579 }
580 }
581
582 #[test]
583 fn forum_worker_forks_only_author_even_with_bad_scope_or_unavailable_backend() {
584 let dir = tempfile::tempdir().unwrap();
585 let root = dir.path().canonicalize().unwrap();
586 let scope = ProjectScope::for_root(&root).unwrap();
587 let repository = agent_core::memory::repository::RepositoryIdentity {
588 scope: scope.clone(),
589 legacy_scope: scope.clone(),
590 aliases: vec![scope.clone()],
591 repository_root: root.clone(),
592 worktree_root: root.clone(),
593 common_dir: Some(root.join("synthetic-common")),
594 };
595 for kind in [
596 MemoryBackendKind::Legacy,
597 MemoryBackendKind::Axel,
598 MemoryBackendKind::Unavailable,
599 ] {
600 for bad_scope in [false, true] {
601 for user_scope in [false, true] {
602 for managed_parent in [false, true] {
603 let config = MemoryBackendConfig {
604 kind,
605 executable: Some(root.join("unused-service")),
606 brain: (!managed_parent).then(|| root.join("custom-brain.r8")),
607 user_scope,
608 };
609 let selected_scope = if bad_scope {
610 Err("captured repository discovery failure".into())
611 } else {
612 Ok(scope.clone())
613 };
614 let mut parent = MemoryBinding::new(root.clone(), selected_scope, &config);
615 Arc::get_mut(&mut parent.0).unwrap().repository = Some(repository.clone());
616 let first = parent.fork_for_worker();
617 let second = parent.fork_for_worker();
618 for worker in [&first, &second] {
619 assert_same_storage(&parent, worker);
620 assert!(!Arc::ptr_eq(&parent.0, &worker.0));
621 worker.forum_author().validate().unwrap();
622 assert_ne!(worker.forum_author().actor, parent.forum_author().actor);
623 assert_eq!(worker.forum_author().group, parent.forum_author().group);
624 assert_eq!(
625 worker.forum_author().parent.as_deref(),
626 Some(parent.forum_author().actor.as_str())
627 );
628 assert_eq!(worker.clone().forum_author(), worker.forum_author());
629 }
630 assert_ne!(first.forum_author().actor, second.forum_author().actor);
631 let grandchild = first.fork_for_worker();
632 assert_same_storage(&first, &grandchild);
633 assert_eq!(grandchild.forum_author().group, parent.forum_author().group);
634 assert_eq!(
635 grandchild.forum_author().parent.as_deref(),
636 Some(first.forum_author().actor.as_str())
637 );
638 assert!(parent.forum_author().parent.is_none());
639 }
640 }
641 }
642 }
643 assert!(!root.join("memory").exists());
644 assert!(!root.join("custom-brain.r8").exists());
645 }
646
647 #[test]
648 fn forum_worker_preserves_user_note_opt_in_and_user_scope_without_widening() {
649 let dir = tempfile::tempdir().unwrap();
650 let root = dir.path().canonicalize().unwrap();
651 let scope = ProjectScope::for_root(&root).unwrap();
652 for user_scope in [false, true] {
653 let parent = MemoryBinding::new(
654 root.clone(),
655 Ok(scope.clone()),
656 &MemoryBackendConfig {
657 kind: MemoryBackendKind::Axel,
658 executable: Some(root.join("unused-service")),
659 brain: Some(root.join("brain.r8")),
660 user_scope,
661 },
662 );
663 let worker = parent.fork_for_worker();
664 assert_eq!(worker.for_user_notes().is_ok(), user_scope);
665 if user_scope {
666 let user = parent.for_user_notes().unwrap();
667 assert_eq!(user.forum_author(), parent.forum_author());
668 let user_worker = user.fork_for_worker();
669 assert_eq!(user_worker.scope().unwrap().key(), USER_SCOPE);
670 assert_same_storage(&user, &user_worker);
671 }
672 }
673 }
674
675 #[test]
676 fn forum_workers_and_scope_rebindings_share_the_operation_lock() {
677 let dir = tempfile::tempdir().unwrap();
678 let scope = ProjectScope::for_root(dir.path()).unwrap();
679 let parent = MemoryBinding::new(dir.path().into(), Ok(scope.clone()), &Default::default());
680 let worker = parent.fork_for_worker();
681 let sibling = parent.fork_for_worker();
682 let scoped = worker.with_scope(scope);
683 let guard = parent.0.operation_lock.try_lock().unwrap();
684 for binding in [&worker, &sibling, &scoped] {
685 assert!(Arc::ptr_eq(
686 &parent.0.operation_lock,
687 &binding.0.operation_lock
688 ));
689 assert!(binding.0.operation_lock.try_lock().is_err());
690 }
691 drop(guard);
692 let worker_guard = worker.0.operation_lock.try_lock().unwrap();
693 assert!(parent.0.operation_lock.try_lock().is_err());
694 assert!(sibling.0.operation_lock.try_lock().is_err());
695 drop(worker_guard);
696 assert!(scoped.0.operation_lock.try_lock().is_ok());
697 }
698
699 #[tokio::test]
702 #[ignore = "requires separately built pinned Axel service; synthetic temp storage only"]
703 async fn real_axel_service_roundtrip_without_legacy_store() {
704 let exe =
705 PathBuf::from(std::env::var_os("SYNAPS_AXEL_TEST_BIN").expect("explicit test binary"));
706 let tmp = tempfile::tempdir().unwrap();
707 let root = tmp.path().canonicalize().unwrap();
708 #[cfg(unix)]
709 {
710 use std::os::unix::fs::PermissionsExt;
711 std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o700)).unwrap();
712 }
713 let scope = ProjectScope::for_root(&root).unwrap();
714 let binding = MemoryBinding::new(
715 root.clone(),
716 Ok(scope.clone()),
717 &MemoryBackendConfig {
718 kind: MemoryBackendKind::Axel,
719 executable: Some(exe.clone()),
720 brain: Some(root.join("brain.r8")),
721 user_scope: false,
722 },
723 );
724 for sensitivity in [
725 MemorySensitivity::Normal,
726 MemorySensitivity::Sensitive,
727 MemorySensitivity::Secret,
728 ] {
729 let saved = binding
730 .store(NewMemoryRecord {
731 content: "Ünicode cobalt_27".into(),
732 tags: vec!["tag_%literal".into()],
733 provenance: store::MemoryProvenance {
734 source: "model".into(),
735 session: Some(format!("synthetic-{sensitivity:?}")),
736 },
737 sensitivity,
738 retention: store::MemoryRetention::MaxAgeDays(365),
739 })
740 .await
741 .unwrap();
742 let id = saved.id.unwrap();
743 let rows = binding
744 .search(ProjectMemoryQuery {
745 content_contains: Some("ÜNICODE".into()),
746 tag_prefix: Some("tag_%".into()),
747 ..Default::default()
748 })
749 .await
750 .unwrap();
751 assert_eq!(
752 rows.iter().any(|row| row.id == id),
753 sensitivity != MemorySensitivity::Secret
754 );
755 let fetched = binding.fetch(&[&id]).await.unwrap();
756 assert_eq!(
757 fetched[0].content,
758 if sensitivity == MemorySensitivity::Secret {
759 ""
760 } else {
761 "Ünicode cobalt_27"
762 }
763 );
764 assert_eq!(fetched[0].sensitivity, Some(sensitivity));
765 binding.forget(&id).await.unwrap();
766 assert!(binding.fetch(&[&id]).await.is_err());
767 }
768 use crate::tools::Tool;
770 let context = || {
771 let mut ctx = crate::tools::test_helpers::create_tool_context();
772 ctx.capabilities.memory_backend = Some(binding.clone());
773 ctx
774 };
775 let stored = crate::tools::memory::MemoryStoreTool
776 .execute(
777 json!({"content":"SHORT_TOOL_AXEL_CANARY","sensitivity":"normal"}),
778 context(),
779 )
780 .await
781 .unwrap();
782 let tool_id = stored.split_whitespace().nth(2).unwrap();
783 let searched = crate::tools::memory::MemorySearchTool
784 .execute(json!({"query":"SHORT_TOOL_AXEL_CANARY"}), context())
785 .await
786 .unwrap();
787 assert!(searched.contains(tool_id));
788 let fetched = crate::tools::memory::MemoryFetchTool
789 .execute(json!({"ids":[tool_id]}), context())
790 .await
791 .unwrap();
792 assert!(fetched.contains("SHORT_TOOL_AXEL_CANARY"));
793 crate::tools::memory::MemoryForgetTool
794 .execute(json!({"id":tool_id}), context())
795 .await
796 .unwrap();
797 assert!(!root.join("memory").exists());
798 assert!(binding.history_search("", 8).await.is_ok());
799 assert!(!root.join("context-archives").exists());
800 let other = tempfile::tempdir().unwrap();
801 let foreign = MemoryBinding::new(
802 root.clone(),
803 Ok(ProjectScope::for_root(other.path()).unwrap()),
804 &MemoryBackendConfig {
805 kind: MemoryBackendKind::Axel,
806 executable: Some(exe),
807 brain: Some(root.join("brain.r8")),
808 user_scope: false,
809 },
810 );
811 assert!(foreign.search(Default::default()).await.unwrap().is_empty());
812 }
813
814 #[tokio::test]
815 async fn unavailable_axel_never_opens_legacy_store() {
816 let dir = tempfile::tempdir().unwrap();
817 let root = dir.path().canonicalize().unwrap();
818 let scope = ProjectScope::for_root(&root).unwrap();
819 let cfg = MemoryBackendConfig {
820 kind: MemoryBackendKind::Axel,
821 executable: None,
822 brain: None,
823 user_scope: false,
824 };
825 let binding = MemoryBinding::new(root.clone(), Ok(scope), &cfg);
826 assert!(binding.exclusive());
827 assert!(binding.search(Default::default()).await.is_err());
828 assert!(binding.fetch(&["mem-unknown"]).await.is_err());
829 assert!(binding.forget("mem-unknown").await.is_err());
830 assert!(binding
831 .store(NewMemoryRecord {
832 content: "note".into(),
833 tags: vec![],
834 provenance: store::MemoryProvenance {
835 source: "model".into(),
836 session: None
837 },
838 sensitivity: MemorySensitivity::Normal,
839 retention: store::MemoryRetention::Standard
840 })
841 .await
842 .is_err());
843 assert!(!root.join("memory").exists());
844 }
845}
846
847#[cfg(test)]
848mod shared_tests;