1#![forbid(unsafe_code)]
19
20use async_trait::async_trait;
21
22use serde_json::{Value, json};
23use std::sync::{Arc, Mutex};
24use wm_core::{Context, EffectRow, Gana, Resource, Tool, ToolStats};
25use wm_memory::{Memory, MemoryStore, SearchEngine};
26
27use super::common::galaxy_name;
28use wm_core::Galaxy;
29
30pub type TransactionState = Arc<Mutex<Option<String>>>;
32
33fn find_snapshot(store: &MemoryStore, snapshot_id: &str) -> wm_core::Result<Option<Memory>> {
35 for mem in store.scan_all(Galaxy::Journals)? {
36 if mem.metadata.tags.iter().any(|t| t == "transaction") && mem.content.contains(snapshot_id)
37 {
38 return Ok(Some(mem));
39 }
40 }
41 Ok(None)
42}
43
44fn parse_snapshot_memories(galaxy_entry: &Value, galaxy: Galaxy) -> Vec<Memory> {
49 let Some(arr) = galaxy_entry.get("memories").and_then(Value::as_array) else {
50 return Vec::new();
51 };
52 if arr.is_empty() {
53 return Vec::new();
54 }
55 if let Ok(memories) = serde_json::from_value::<Vec<Memory>>(Value::Array(arr.clone())) {
57 return memories;
58 }
59 arr.iter()
61 .map(|mem_val| {
62 let content = mem_val.get("content").and_then(Value::as_str).unwrap_or("");
63 let mut mem = Memory::new(galaxy, content.to_string());
64 if let Some(tags) = mem_val.get("tags").and_then(Value::as_array) {
65 mem.metadata.tags = tags
66 .iter()
67 .filter_map(|t| t.as_str().map(String::from))
68 .collect();
69 }
70 if let Some(imp) = mem_val.get("importance").and_then(Value::as_f64) {
71 mem.metadata.importance = imp as f32;
72 }
73 mem
74 })
75 .collect()
76}
77
78pub struct TransactionBeginTool {
80 store: Arc<MemoryStore>,
81 state: TransactionState,
82 stats: ToolStats,
83 effects: EffectRow,
84}
85
86impl TransactionBeginTool {
87 pub fn new(store: Arc<MemoryStore>, state: TransactionState) -> Self {
88 Self {
89 store,
90 state,
91 stats: ToolStats::default(),
92 effects: EffectRow {
93 writes: vec![Resource::Galaxy("journals".into())],
94 reads: vec![],
95 ..Default::default()
96 },
97 }
98 }
99}
100
101#[async_trait]
102impl Tool for TransactionBeginTool {
103 fn name(&self) -> &str {
104 "transaction.begin"
105 }
106 fn gana(&self) -> Gana {
107 Gana::Void
108 }
109 fn effects(&self) -> &EffectRow {
110 &self.effects
111 }
112 fn input_schema(&self) -> Value {
113 super::common::schema(&json!({}), &[])
114 }
115 fn description(&self) -> &str {
116 "Begin a transaction — snapshots all memory galaxies (exact records) for potential rollback"
117 }
118 async fn call(&self, _ctx: &mut Context, _args: Value) -> wm_core::Result<Value> {
119 let mut guard = self.state.lock().map_err(|e| {
120 wm_core::CoreError::Governance(format!("transaction state lock error: {e}"))
121 })?;
122 if guard.is_some() {
123 return Err(wm_core::CoreError::Governance(
124 "transaction already in progress — commit or rollback first".into(),
125 ));
126 }
127
128 let backup_id = uuid::Uuid::new_v4();
129 let mut galaxy_data = serde_json::Map::new();
130 let mut total_backed_up = 0usize;
131
132 for galaxy in Galaxy::memory_galaxies() {
137 let memories = self.store.scan_all(galaxy)?;
138 let count = memories.len();
139 total_backed_up += count;
140 galaxy_data.insert(
141 galaxy_name(galaxy).to_string(),
142 json!({
143 "count": count,
144 "memories": memories,
145 }),
146 );
147 }
148
149 let backup_content = json!({
150 "type": "transaction_snapshot",
151 "backup_id": backup_id,
152 "timestamp": chrono::Utc::now().to_rfc3339(),
153 "total_memories": total_backed_up,
154 "galaxies": galaxy_data,
155 })
156 .to_string();
157
158 let mut backup_mem = Memory::new(Galaxy::Journals, backup_content);
159 backup_mem.metadata.tags = vec!["transaction".to_string()];
160 backup_mem.metadata.importance = 1.0;
161 self.store.put(Galaxy::Journals, &backup_mem)?;
162
163 let id_str = backup_id.to_string();
164 *guard = Some(id_str.clone());
165
166 Ok(json!({
167 "status": "success",
168 "transaction_id": id_str,
169 "total_memories_snapshotted": total_backed_up,
170 "galaxies_snapshotted": Galaxy::memory_galaxies().len(),
171 }))
172 }
173 fn stats(&self) -> &ToolStats {
174 &self.stats
175 }
176}
177
178pub struct TransactionCommitTool {
180 store: Arc<MemoryStore>,
181 state: TransactionState,
182 search: Option<Arc<SearchEngine>>,
183 stats: ToolStats,
184 effects: EffectRow,
185}
186
187impl TransactionCommitTool {
188 pub fn new(
189 store: Arc<MemoryStore>,
190 state: TransactionState,
191 search: Option<Arc<SearchEngine>>,
192 ) -> Self {
193 Self {
194 store,
195 state,
196 search,
197 stats: ToolStats::default(),
198 effects: EffectRow {
199 writes: vec![Resource::Galaxy("journals".into())],
201 reads: vec![Resource::Galaxy("journals".into())],
202 ..Default::default()
203 },
204 }
205 }
206}
207
208#[async_trait]
209impl Tool for TransactionCommitTool {
210 fn name(&self) -> &str {
211 "transaction.commit"
212 }
213 fn gana(&self) -> Gana {
214 Gana::Void
215 }
216 fn effects(&self) -> &EffectRow {
217 &self.effects
218 }
219 fn input_schema(&self) -> Value {
220 super::common::schema(&json!({}), &[])
221 }
222 fn description(&self) -> &str {
223 "Commit a transaction — keeps all changes, removes the rollback snapshot"
224 }
225 async fn call(&self, _ctx: &mut Context, _args: Value) -> wm_core::Result<Value> {
226 let mut guard = self.state.lock().map_err(|e| {
227 wm_core::CoreError::Governance(format!("transaction state lock error: {e}"))
228 })?;
229 let snapshot_id = match guard.as_ref() {
230 Some(id) => id.clone(),
231 None => {
232 return Err(wm_core::CoreError::Governance(
233 "no active transaction to commit".into(),
234 ));
235 }
236 };
237
238 if let Some(mem) = find_snapshot(&self.store, &snapshot_id)? {
243 let mem_id = mem.metadata.id.to_string();
244 self.store.delete(Galaxy::Journals, mem.metadata.id)?;
245 super::common::deindex(self.search.as_deref(), &mem_id);
246 }
247
248 *guard = None;
249 Ok(json!({
250 "status": "success",
251 "transaction_id": snapshot_id,
252 "message": "transaction committed — changes kept, snapshot removed",
253 }))
254 }
255 fn stats(&self) -> &ToolStats {
256 &self.stats
257 }
258}
259
260pub struct TransactionRollbackTool {
262 store: Arc<MemoryStore>,
263 state: TransactionState,
264 search: Option<Arc<SearchEngine>>,
265 stats: ToolStats,
266 effects: EffectRow,
267}
268
269impl TransactionRollbackTool {
270 pub fn new(
271 store: Arc<MemoryStore>,
272 state: TransactionState,
273 search: Option<Arc<SearchEngine>>,
274 ) -> Self {
275 Self {
276 store,
277 state,
278 search,
279 stats: ToolStats::default(),
280 effects: EffectRow {
281 writes: super::common::memory_galaxy_writes(),
286 reads: {
287 let mut r = vec![Resource::Galaxy("journals".into())];
288 r.extend(super::common::memory_galaxy_reads());
289 r
290 },
291 destructive: true,
292 ..Default::default()
293 },
294 }
295 }
296}
297
298#[async_trait]
299impl Tool for TransactionRollbackTool {
300 fn name(&self) -> &str {
301 "transaction.rollback"
302 }
303 fn gana(&self) -> Gana {
304 Gana::Void
305 }
306 fn effects(&self) -> &EffectRow {
307 &self.effects
308 }
309 fn input_schema(&self) -> Value {
310 super::common::schema(
311 &json!({
312 "confirm": super::common::bool_prop("Required — transaction.rollback is destructive"),
313 }),
314 &["confirm"],
315 )
316 }
317 fn description(&self) -> &str {
318 "Rollback a transaction — restores exact pre-transaction records"
319 }
320 async fn call(&self, _ctx: &mut Context, _args: Value) -> wm_core::Result<Value> {
321 let mut guard = self.state.lock().map_err(|e| {
322 wm_core::CoreError::Governance(format!("transaction state lock error: {e}"))
323 })?;
324 let snapshot_id = match guard.as_ref() {
325 Some(id) => id.clone(),
326 None => {
327 return Err(wm_core::CoreError::Governance(
328 "no active transaction to rollback".into(),
329 ));
330 }
331 };
332
333 let snapshot = find_snapshot(&self.store, &snapshot_id)?.ok_or_else(|| {
338 wm_core::CoreError::NotFound(format!(
339 "transaction snapshot {snapshot_id} not found in journals"
340 ))
341 })?;
342
343 let backup: Value = serde_json::from_str(&snapshot.content).map_err(|e| {
344 wm_core::CoreError::Memory(format!("failed to parse transaction snapshot: {e}"))
345 })?;
346
347 let galaxies = backup
348 .get("galaxies")
349 .and_then(|v| v.as_object())
350 .ok_or_else(|| {
351 wm_core::CoreError::Memory("snapshot has no 'galaxies' object".into())
352 })?;
353
354 let mut total_restored = 0usize;
355 let mut total_cleared = 0usize;
356
357 for galaxy in Galaxy::memory_galaxies() {
358 let gname = galaxy_name(galaxy);
359 let Some(galaxy_entry) = galaxies.get(gname) else {
360 continue;
361 };
362
363 let memories = parse_snapshot_memories(galaxy_entry, galaxy);
365
366 let existing = self.store.scan_all(galaxy)?;
369 for mem in &existing {
370 super::common::deindex(self.search.as_deref(), &mem.metadata.id.to_string());
371 }
372 total_cleared += self.store.clear_galaxy(galaxy)?;
373
374 total_restored += self.store.batch_put(galaxy, &memories)?;
377 for mem in &memories {
378 super::common::index_memory(self.search.as_deref(), mem);
379 }
380 }
381
382 *guard = None;
384
385 Ok(json!({
386 "status": "success",
387 "transaction_id": snapshot_id,
388 "galaxies_restored": Galaxy::memory_galaxies().len(),
389 "memories_cleared": total_cleared,
390 "memories_restored": total_restored,
391 }))
392 }
393 fn stats(&self) -> &ToolStats {
394 &self.stats
395 }
396}
397
398#[cfg(test)]
399mod tests {
400 use super::*;
401 use wm_core::BrainWave;
402
403 fn open_store() -> (tempfile::TempDir, Arc<MemoryStore>) {
404 let tmp = tempfile::tempdir().unwrap();
405 let store = Arc::new(MemoryStore::open_default(tmp.path()).unwrap());
406 (tmp, store)
407 }
408
409 #[tokio::test]
410 async fn transaction_begin_commit_workflow() {
411 let (_tmp, store) = open_store();
412 let state: TransactionState = Arc::new(Mutex::new(None));
413
414 let mem = Memory::new(Galaxy::Codex, "test content".into());
416 store.put(Galaxy::Codex, &mem).unwrap();
417
418 let begin = TransactionBeginTool::new(Arc::clone(&store), Arc::clone(&state));
420 let result = begin
421 .call(&mut Context::new(BrainWave::Gamma), json!({}))
422 .await;
423 assert!(result.is_ok());
424 assert!(state.lock().unwrap().is_some());
425
426 let commit = TransactionCommitTool::new(Arc::clone(&store), Arc::clone(&state), None);
428 let result = commit
429 .call(&mut Context::new(BrainWave::Gamma), json!({}))
430 .await;
431 assert!(result.is_ok());
432 assert!(state.lock().unwrap().is_none());
433 }
434
435 #[tokio::test]
436 async fn transaction_begin_rollback_restores_data() {
437 let (_tmp, store) = open_store();
438 let state: TransactionState = Arc::new(Mutex::new(None));
439
440 let mem = Memory::new(Galaxy::Codex, "original content".into());
442 store.put(Galaxy::Codex, &mem).unwrap();
443
444 let begin = TransactionBeginTool::new(store.clone(), state.clone());
446 let result = begin
447 .call(&mut Context::new(BrainWave::Gamma), json!({}))
448 .await;
449 assert!(result.is_ok());
450
451 store.delete(Galaxy::Codex, mem.metadata.id).unwrap();
453 assert_eq!(store.count(Galaxy::Codex).unwrap(), 0);
454
455 let rollback = TransactionRollbackTool::new(store.clone(), state, None);
457 let result = rollback
458 .call(&mut Context::new(BrainWave::Gamma), json!({}))
459 .await;
460 assert!(result.is_ok());
461
462 let memories = store.scan(Galaxy::Codex, 10_000).unwrap();
464 assert_eq!(memories.len(), 1);
465 assert_eq!(memories[0].content, "original content");
466 }
467
468 #[tokio::test]
469 async fn transaction_begin_twice_errors() {
470 let (_tmp, store) = open_store();
471 let state: TransactionState = Arc::new(Mutex::new(Some("existing-id".into())));
472
473 let begin = TransactionBeginTool::new(store, state);
474 let result = begin
475 .call(&mut Context::new(BrainWave::Gamma), json!({}))
476 .await;
477 assert!(result.is_err());
478 }
479
480 #[tokio::test]
481 async fn transaction_commit_without_begin_errors() {
482 let (_tmp, store) = open_store();
483 let state: TransactionState = Arc::new(Mutex::new(None));
484 let commit = TransactionCommitTool::new(store, state, None);
485 let result = commit
486 .call(&mut Context::new(BrainWave::Gamma), json!({}))
487 .await;
488 assert!(result.is_err());
489 }
490
491 #[tokio::test]
492 async fn transaction_rollback_without_begin_errors() {
493 let (_tmp, store) = open_store();
494 let state: TransactionState = Arc::new(Mutex::new(None));
495 let rollback = TransactionRollbackTool::new(store, state, None);
496 let result = rollback
497 .call(&mut Context::new(BrainWave::Gamma), json!({}))
498 .await;
499 assert!(result.is_err());
500 }
501
502 #[tokio::test]
503 async fn transaction_rollback_is_destructive() {
504 let (_tmp, store) = open_store();
505 let state: TransactionState = Arc::new(Mutex::new(None));
506 let rollback = TransactionRollbackTool::new(store, state, None);
507 assert!(rollback.effects().destructive);
508 }
509
510 #[tokio::test]
511 async fn rollback_restores_exact_records() {
512 let (_tmp, store) = open_store();
513 let state: TransactionState = Arc::new(Mutex::new(None));
514
515 let mut mem = Memory::new(Galaxy::Codex, "exact record content".into());
516 mem.metadata.tags = vec!["alpha".into(), "beta".into()];
517 mem.metadata.importance = 0.9;
518 mem.metadata.is_private = true;
519 mem.metadata.model_exclude = true;
520 store.put(Galaxy::Codex, &mem).unwrap();
521 let original_json = serde_json::to_value(&mem).unwrap();
522
523 let begin = TransactionBeginTool::new(store.clone(), state.clone());
524 begin
525 .call(&mut Context::new(BrainWave::Gamma), json!({}))
526 .await
527 .unwrap();
528
529 let mut mutated = store.get(Galaxy::Codex, mem.metadata.id).unwrap().unwrap();
531 mutated.metadata.tags = vec!["changed".into()];
532 mutated.metadata.importance = 0.1;
533 mutated.metadata.is_private = false;
534 store.put(Galaxy::Codex, &mutated).unwrap();
535
536 let rollback = TransactionRollbackTool::new(store.clone(), state.clone(), None);
537 rollback
538 .call(&mut Context::new(BrainWave::Gamma), json!({}))
539 .await
540 .unwrap();
541
542 let restored = store.get(Galaxy::Codex, mem.metadata.id).unwrap().unwrap();
544 assert_eq!(serde_json::to_value(&restored).unwrap(), original_json);
545 assert!(state.lock().unwrap().is_none());
546 }
547
548 #[tokio::test]
549 #[cfg_attr(
550 windows,
551 ignore = "Windows 256MiB LMDB default cannot hold the 10k+snapshot fixture; see WM_DEFAULT_MAP_SIZE"
552 )]
553 async fn rollback_is_not_truncated_at_ten_thousand() {
554 let (_tmp, store) = open_store();
555 let state: TransactionState = Arc::new(Mutex::new(None));
556
557 let memories: Vec<Memory> = (0..10_001)
560 .map(|i| Memory::new(Galaxy::Codex, format!("bulk memory {i}")))
561 .collect();
562 store.batch_put(Galaxy::Codex, &memories).unwrap();
563
564 let begin = TransactionBeginTool::new(store.clone(), state.clone());
565 let result = begin
566 .call(&mut Context::new(BrainWave::Gamma), json!({}))
567 .await
568 .unwrap();
569 assert_eq!(
570 result["total_memories_snapshotted"], 10_001,
571 "snapshot must not truncate"
572 );
573
574 store.clear_galaxy(Galaxy::Codex).unwrap();
576 assert_eq!(store.count(Galaxy::Codex).unwrap(), 0);
577
578 let rollback = TransactionRollbackTool::new(store.clone(), state.clone(), None);
579 let result = rollback
580 .call(&mut Context::new(BrainWave::Gamma), json!({}))
581 .await
582 .unwrap();
583 assert_eq!(result["memories_restored"], 10_001);
584 assert_eq!(store.count(Galaxy::Codex).unwrap(), 10_001);
585 }
586
587 #[tokio::test]
588 async fn commit_removes_snapshot() {
589 let (_tmp, store) = open_store();
590 let state: TransactionState = Arc::new(Mutex::new(None));
591
592 let mem = Memory::new(Galaxy::Codex, "commit cleanup".into());
593 store.put(Galaxy::Codex, &mem).unwrap();
594
595 let begin = TransactionBeginTool::new(store.clone(), state.clone());
596 begin
597 .call(&mut Context::new(BrainWave::Gamma), json!({}))
598 .await
599 .unwrap();
600 let snapshot_id = state.lock().unwrap().clone().unwrap();
601 assert!(find_snapshot(&store, &snapshot_id).unwrap().is_some());
602
603 let commit = TransactionCommitTool::new(store.clone(), state.clone(), None);
604 commit
605 .call(&mut Context::new(BrainWave::Gamma), json!({}))
606 .await
607 .unwrap();
608
609 assert!(state.lock().unwrap().is_none());
610 assert!(
611 find_snapshot(&store, &snapshot_id).unwrap().is_none(),
612 "commit must remove the journal snapshot"
613 );
614 }
615
616 #[tokio::test]
617 async fn failed_rollback_keeps_transaction_state() {
618 let (_tmp, store) = open_store();
619 let state: TransactionState = Arc::new(Mutex::new(None));
620
621 let mem = Memory::new(Galaxy::Codex, "retryable rollback".into());
622 store.put(Galaxy::Codex, &mem).unwrap();
623
624 let begin = TransactionBeginTool::new(store.clone(), state.clone());
625 begin
626 .call(&mut Context::new(BrainWave::Gamma), json!({}))
627 .await
628 .unwrap();
629 let snapshot_id = state.lock().unwrap().clone().unwrap();
630
631 let snapshot_mem = find_snapshot(&store, &snapshot_id).unwrap().unwrap();
634 store
635 .delete(Galaxy::Journals, snapshot_mem.metadata.id)
636 .unwrap();
637 let mut corrupt = Memory::new(Galaxy::Journals, format!("corrupted {snapshot_id}"));
638 corrupt.metadata.tags = vec!["transaction".into()];
639 store.put(Galaxy::Journals, &corrupt).unwrap();
640
641 let rollback = TransactionRollbackTool::new(store.clone(), state.clone(), None);
642 let result = rollback
643 .call(&mut Context::new(BrainWave::Gamma), json!({}))
644 .await;
645 assert!(result.is_err(), "corrupted snapshot must fail rollback");
646
647 assert_eq!(
649 state.lock().unwrap().as_deref(),
650 Some(snapshot_id.as_str()),
651 "failed rollback must keep the transaction state"
652 );
653 }
654}