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