Skip to main content

wm_tools/expansion/
transaction.rs

1//! Transaction tools — begin, commit, rollback for multi-tool sequences.
2//!
3//! `transaction.begin` snapshots all memory galaxies into Journals and
4//! stores the backup ID in a shared transaction state. `transaction.rollback`
5//! restores all galaxies from the snapshot. `transaction.commit` clears the
6//! transaction state, keeping the changes.
7//!
8//! Exactness contract (release gate):
9//! - Snapshots serialize complete `Memory` records — IDs, timestamps, hashes,
10//!   coordinates, privacy flags, provenance, and versions are preserved.
11//! - Snapshots are not truncated: every memory in every memory galaxy is
12//!   captured, so rollback cannot silently drop data.
13//! - Rollback validates and restores before clearing the active transaction,
14//!   so a failed restore can be retried.
15//! - Commit and successful rollback remove the journal snapshot, so
16//!   transactions do not accumulate permanent recovery data.
17
18#![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
30/// Shared transaction state — holds the active backup ID (if any).
31pub type TransactionState = Arc<Mutex<Option<String>>>;
32
33/// Locate the transaction snapshot memory in Journals by backup ID.
34fn 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
44/// Parse a galaxy snapshot entry back into exact `Memory` records.
45///
46/// Prefers the current full-record format. Falls back to the legacy
47/// field-level format so snapshots taken by earlier builds still restore.
48fn 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    // Full-record format: every element deserializes as a complete Memory.
56    if let Ok(memories) = serde_json::from_value::<Vec<Memory>>(Value::Array(arr.clone())) {
57        return memories;
58    }
59    // Legacy field-level format (pre-exact-rollback snapshots).
60    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
78/// `transaction.begin` — snapshot all galaxies, store backup ID for rollback.
79pub 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        // Full scan with exact record serialization: an exact rollback cannot
133        // be truncated at an arbitrary limit, and field-picked snapshots
134        // silently dropped IDs, timestamps, hashes, privacy flags, and
135        // provenance on restore.
136        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
178/// `transaction.commit` — clear transaction state, keeping all changes.
179pub 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                // Commit deletes the rollback snapshot from Journals.
200                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        // Remove the rollback snapshot so committed transactions leave no
239        // permanent recovery data behind. A missing snapshot is fine
240        // (idempotent commit); a failed delete keeps the transaction active
241        // so the caller can retry.
242        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
260/// `transaction.rollback` — restore all galaxies from the transaction snapshot.
261pub 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                // Rollback clears and rewrites every memory galaxy from the
282                // Journals snapshot. The snapshot holds the exact prior
283                // records of every galaxy; restoring is a read of that
284                // evidence before the write.
285                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        // Locate and parse the snapshot BEFORE touching any galaxy. On any
334        // failure the transaction state is left intact so rollback can be
335        // retried — a partial restore with no retry state was the old
336        // failure mode.
337        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            // Exact records from the snapshot (with legacy fallback).
364            let memories = parse_snapshot_memories(galaxy_entry, galaxy);
365
366            // Clear existing memories in this galaxy (single transaction) and
367            // de-index them so full-text search doesn't return stale hits.
368            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            // Restore from the snapshot (single transaction via batch_put,
375            // which preserves each memory's original UUID and index entries).
376            total_restored += self.store.batch_put(galaxy, &memories)?;
377            for mem in &memories {
378                super::common::index_memory(&self.store, self.search.as_deref(), mem);
379            }
380        }
381
382        // Success — the snapshot restored, so the transaction is complete.
383        *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        // Create a memory
415        let mem = Memory::new(Galaxy::Codex, "test content".into());
416        store.put(Galaxy::Codex, &mem).unwrap();
417
418        // Begin transaction
419        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        // Commit
427        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        // Create a memory
441        let mem = Memory::new(Galaxy::Codex, "original content".into());
442        store.put(Galaxy::Codex, &mem).unwrap();
443
444        // Begin transaction
445        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        // Modify: delete the memory
452        store.delete(Galaxy::Codex, mem.metadata.id).unwrap();
453        assert_eq!(store.count(Galaxy::Codex).unwrap(), 0);
454
455        // Rollback
456        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        // Verify memory was restored
463        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        // Mutate the memory, then roll back.
530        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        // Exact record: same UUID, timestamps, hashes, flags, coordinates.
543        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        // 10,001 memories — the old snapshot path capped scans at 10,000 and
558        // silently dropped the rest, so rollback deleted real data.
559        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        // Wipe the galaxy, then roll back.
575        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        // Corrupt the snapshot: replace it with a same-tagged journal
632        // memory whose content does not parse as a snapshot.
633        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        // The transaction stays active so rollback can be retried.
648        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}