Skip to main content

mur_core/team/
mod.rs

1//! CRDT-based team workflow synchronization.
2//!
3//! Provides conflict-free replicated data types for synchronizing
4//! workflow definitions across team members without a central server.
5
6pub mod crdt;
7pub mod peer;
8
9use chrono::{DateTime, Utc};
10use schemars::JsonSchema;
11use serde::{Deserialize, Serialize};
12use uuid::Uuid;
13
14/// Strategy for resolving conflicting edits to the same workflow.
15#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
16#[serde(rename_all = "snake_case")]
17pub enum ConflictResolution {
18    /// Last writer wins based on wall-clock timestamp.
19    #[default]
20    LastWriterWins,
21    /// Keep the version from the peer with the highest node ID.
22    HighestNodeId,
23    /// Fork into two separate workflows and let a human merge.
24    ForkAndMerge,
25}
26
27/// Current synchronization state for a workflow.
28#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
29#[serde(rename_all = "snake_case")]
30pub enum SyncState {
31    /// Fully synchronized with all known peers.
32    Synced,
33    /// Local changes not yet propagated.
34    LocalAhead { pending_changes: u64 },
35    /// Remote changes not yet merged.
36    RemoteAhead { pending_changes: u64 },
37    /// Both local and remote have diverged.
38    Diverged {
39        local_changes: u64,
40        remote_changes: u64,
41    },
42    /// Conflict detected, awaiting resolution.
43    Conflicted { workflow_id: String },
44}
45
46/// Summary of a sync operation.
47#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
48pub struct SyncResult {
49    pub workflows_synced: usize,
50    pub conflicts: Vec<SyncConflict>,
51    pub timestamp: DateTime<Utc>,
52}
53
54/// A detected conflict between two versions of a workflow.
55#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
56pub struct SyncConflict {
57    pub workflow_id: String,
58    pub local_version: u64,
59    pub remote_version: u64,
60    pub local_peer: String,
61    pub remote_peer: String,
62    pub detected_at: DateTime<Utc>,
63}
64
65/// Trait for team workflow synchronization backends.
66pub trait TeamSync: Send + Sync {
67    /// Push local changes to peers.
68    fn push(&self, workflow_id: &str, yaml: &str) -> anyhow::Result<SyncState>;
69
70    /// Pull and merge remote changes.
71    fn pull(&self, workflow_id: &str) -> anyhow::Result<(String, SyncState)>;
72
73    /// Get the current sync state for a workflow.
74    fn state(&self, workflow_id: &str) -> anyhow::Result<SyncState>;
75
76    /// Resolve a conflict using the configured strategy.
77    fn resolve_conflict(
78        &self,
79        workflow_id: &str,
80        resolution: &ConflictResolution,
81    ) -> anyhow::Result<String>;
82
83    /// Get this node's unique peer identifier.
84    fn local_peer_id(&self) -> Uuid;
85}
86
87#[cfg(test)]
88mod tests {
89    use super::*;
90
91    #[test]
92    fn test_conflict_resolution_default() {
93        let cr = ConflictResolution::default();
94        assert_eq!(cr, ConflictResolution::LastWriterWins);
95    }
96
97    #[test]
98    fn test_sync_state_serialization() {
99        let state = SyncState::Diverged {
100            local_changes: 3,
101            remote_changes: 5,
102        };
103        let json = serde_json::to_string(&state).unwrap();
104        let deserialized: SyncState = serde_json::from_str(&json).unwrap();
105        assert_eq!(state, deserialized);
106    }
107
108    #[test]
109    fn test_sync_state_variants() {
110        let synced = SyncState::Synced;
111        let json = serde_json::to_string(&synced).unwrap();
112        assert!(json.contains("synced"));
113
114        let conflicted = SyncState::Conflicted {
115            workflow_id: "wf-1".into(),
116        };
117        let json = serde_json::to_string(&conflicted).unwrap();
118        assert!(json.contains("wf-1"));
119    }
120
121    #[test]
122    fn test_conflict_resolution_serde_roundtrip() {
123        for variant in [
124            ConflictResolution::LastWriterWins,
125            ConflictResolution::HighestNodeId,
126            ConflictResolution::ForkAndMerge,
127        ] {
128            let json = serde_json::to_string(&variant).unwrap();
129            let back: ConflictResolution = serde_json::from_str(&json).unwrap();
130            assert_eq!(variant, back);
131        }
132    }
133}