Skip to main content

aida_core/
node.rs

1// trace:ARCH-distributed-node | ai:claude
2//! Node identity and workspace configuration for distributed AIDA.
3//!
4//! A **node** is a single clone/installation of AIDA. Each node gets a unique
5//! sequential integer ID via the git CAS push loop at `aida init`. After
6//! registration, the node can generate globally unique object IDs offline
7//! indefinitely.
8//!
9//! A **workspace** groups multiple code repos that share a single AIDA database.
10//! The workspace config is discovered by walking up the directory tree.
11
12use chrono::{DateTime, Utc};
13use serde::{Deserialize, Serialize};
14use std::path::{Path, PathBuf};
15
16use crate::dispenser::IdMode;
17
18// ---------------------------------------------------------------------------
19// Node Identity
20// ---------------------------------------------------------------------------
21
22/// Information about a registered node (persisted locally, gitignored).
23#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct NodeConfig {
25    /// The assigned node ID (unique within the workspace)
26    pub node_id: u32,
27    /// The user ID who owns this node
28    pub user_id: u32,
29    /// Hostname at registration time (informational)
30    pub hostname: String,
31    /// When this node was registered
32    pub registered_at: DateTime<Utc>,
33}
34
35impl NodeConfig {
36    /// Load node config from a TOML file.
37    #[cfg(feature = "native")]
38    pub fn load(path: &Path) -> anyhow::Result<Self> {
39        let content = std::fs::read_to_string(path)?;
40        let config: NodeConfig = toml::from_str(&content)?;
41        Ok(config)
42    }
43
44    /// Save node config to a TOML file.
45    #[cfg(feature = "native")]
46    pub fn save(&self, path: &Path) -> anyhow::Result<()> {
47        if let Some(parent) = path.parent() {
48            std::fs::create_dir_all(parent)?;
49        }
50        let content = toml::to_string_pretty(self)?;
51        std::fs::write(path, content)?;
52        Ok(())
53    }
54
55    /// Get the IdMode for this node.
56    pub fn id_mode(&self) -> IdMode {
57        IdMode::Distributed {
58            node_id: self.node_id,
59        }
60    }
61}
62
63/// A node registration entry in the shared registry (committed to git).
64#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct NodeRegistryEntry {
66    /// The assigned node ID
67    pub id: u32,
68    /// The user ID who owns this node
69    pub user_id: u32,
70    /// Hostname at registration time
71    pub hostname: String,
72    /// Registration timestamp
73    pub registered: DateTime<Utc>,
74}
75
76/// The shared node registry (committed to git, append-only).
77#[derive(Debug, Clone, Serialize, Deserialize, Default)]
78pub struct NodeRegistry {
79    /// All registered nodes
80    #[serde(default)]
81    pub nodes: Vec<NodeRegistryEntry>,
82}
83
84impl NodeRegistry {
85    /// Get the next available node ID.
86    pub fn next_node_id(&self) -> u32 {
87        self.nodes.iter().map(|n| n.id).max().unwrap_or(0) + 1
88    }
89
90    /// Check if a node ID is already registered.
91    pub fn is_registered(&self, node_id: u32) -> bool {
92        self.nodes.iter().any(|n| n.id == node_id)
93    }
94
95    /// Get a node entry by ID.
96    pub fn get(&self, node_id: u32) -> Option<&NodeRegistryEntry> {
97        self.nodes.iter().find(|n| n.id == node_id)
98    }
99
100    /// Register a new node. Returns the assigned node ID.
101    pub fn register(&mut self, user_id: u32, hostname: String) -> u32 {
102        let id = self.next_node_id();
103        self.nodes.push(NodeRegistryEntry {
104            id,
105            user_id,
106            hostname,
107            registered: Utc::now(),
108        });
109        id
110    }
111
112    /// Load from a TOML file.
113    #[cfg(feature = "native")]
114    pub fn load(path: &Path) -> anyhow::Result<Self> {
115        if !path.exists() {
116            return Ok(Self::default());
117        }
118        let content = std::fs::read_to_string(path)?;
119        let registry: NodeRegistry = toml::from_str(&content)?;
120        Ok(registry)
121    }
122
123    /// Save to a TOML file.
124    #[cfg(feature = "native")]
125    pub fn save(&self, path: &Path) -> anyhow::Result<()> {
126        if let Some(parent) = path.parent() {
127            std::fs::create_dir_all(parent)?;
128        }
129        let content = toml::to_string_pretty(self)?;
130        std::fs::write(path, content)?;
131        Ok(())
132    }
133}
134
135// ---------------------------------------------------------------------------
136// User Identity
137// ---------------------------------------------------------------------------
138
139/// A user registration entry in the shared registry.
140#[derive(Debug, Clone, Serialize, Deserialize)]
141pub struct UserRegistryEntry {
142    /// The assigned user ID
143    pub id: u32,
144    /// Display name
145    pub name: String,
146    /// Email address (optional)
147    #[serde(default, skip_serializing_if = "Option::is_none")]
148    pub email: Option<String>,
149    /// Registration timestamp
150    pub registered: DateTime<Utc>,
151}
152
153/// The shared user registry (committed to git, append-only).
154#[derive(Debug, Clone, Serialize, Deserialize, Default)]
155pub struct UserRegistry {
156    #[serde(default)]
157    pub users: Vec<UserRegistryEntry>,
158}
159
160impl UserRegistry {
161    /// Get the next available user ID.
162    pub fn next_user_id(&self) -> u32 {
163        self.users.iter().map(|u| u.id).max().unwrap_or(0) + 1
164    }
165
166    /// Register a new user. Returns the assigned user ID.
167    pub fn register(&mut self, name: String, email: Option<String>) -> u32 {
168        let id = self.next_user_id();
169        self.users.push(UserRegistryEntry {
170            id,
171            name,
172            email,
173            registered: Utc::now(),
174        });
175        id
176    }
177
178    /// Find a user by name.
179    pub fn find_by_name(&self, name: &str) -> Option<&UserRegistryEntry> {
180        self.users
181            .iter()
182            .find(|u| u.name.eq_ignore_ascii_case(name))
183    }
184}
185
186// ---------------------------------------------------------------------------
187// Agreed ID Counters
188// ---------------------------------------------------------------------------
189
190/// Per-type counters for agreed IDs assigned at merge-to-trunk.
191#[derive(Debug, Clone, Serialize, Deserialize, Default)]
192pub struct AgreedCounters {
193    /// Maps type prefix to the last assigned agreed sequence number.
194    /// e.g., {"FR": 422, "FEAT": 89}
195    #[serde(flatten)]
196    pub counters: std::collections::HashMap<String, u32>,
197}
198
199impl AgreedCounters {
200    /// Get the next agreed ID for a type and increment the counter.
201    pub fn next(&mut self, type_prefix: &str) -> u32 {
202        let counter = self
203            .counters
204            .entry(type_prefix.to_uppercase())
205            .or_insert(0);
206        *counter += 1;
207        *counter
208    }
209
210    /// Peek at the next agreed ID without incrementing.
211    pub fn peek(&self, type_prefix: &str) -> u32 {
212        self.counters
213            .get(&type_prefix.to_uppercase())
214            .copied()
215            .unwrap_or(0)
216            + 1
217    }
218
219    /// Format an agreed ID: `FR-423`
220    pub fn format_agreed_id(type_prefix: &str, seq: u32) -> String {
221        format!("{}-{}", type_prefix.to_uppercase(), seq)
222    }
223}
224
225// ---------------------------------------------------------------------------
226// Workspace Configuration
227// ---------------------------------------------------------------------------
228
229/// Workspace configuration — discovered by walking up the directory tree.
230///
231/// A workspace groups multiple code repos that share a single AIDA database
232/// (the `aida_path` repo). All nodes within a workspace share the same
233/// node registry and agreed ID counters.
234#[derive(Debug, Clone, Serialize, Deserialize)]
235pub struct WorkspaceConfig {
236    /// Workspace name (e.g., "gdms-disruptive")
237    pub workspace: String,
238    /// Path to the aida repo (relative to workspace root)
239    #[serde(default = "default_aida_path")]
240    pub aida_path: String,
241    /// Code repos in this workspace
242    #[serde(default)]
243    pub repos: Vec<String>,
244}
245
246fn default_aida_path() -> String {
247    "./aida".to_string()
248}
249
250impl WorkspaceConfig {
251    /// Discover a workspace config by walking up the directory tree
252    /// looking for `.aida-workspace`.
253    #[cfg(feature = "native")]
254    pub fn discover(from: &Path) -> Option<(PathBuf, Self)> {
255        let mut current = from.to_path_buf();
256        loop {
257            let candidate = current.join(".aida-workspace");
258            if candidate.exists() {
259                if let Ok(content) = std::fs::read_to_string(&candidate) {
260                    if let Ok(config) = toml::from_str::<WorkspaceConfig>(&content) {
261                        return Some((current, config));
262                    }
263                }
264            }
265            if !current.pop() {
266                return None;
267            }
268        }
269    }
270}
271
272// ---------------------------------------------------------------------------
273// Deployment Mode
274// ---------------------------------------------------------------------------
275
276/// The deployment mode for an AIDA instance.
277///
278/// This is the top-level configuration that determines how IDs are generated,
279/// how storage works, and whether distributed features are enabled.
280#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
281#[serde(tag = "mode")]
282pub enum DeploymentMode {
283    /// Centralized: single PostgreSQL or SQLite database, simple sequential IDs.
284    /// This is the default for teams with always-available connectivity.
285    /// IDs: `FR-001`, `FEAT-042`
286    #[serde(rename = "centralized")]
287    Centralized,
288
289    /// Distributed: git-based event log, node-namespaced IDs, offline-capable.
290    /// IDs: `FR-7-001`, `FEAT-3-042` (with optional agreed IDs on trunk)
291    #[serde(rename = "distributed")]
292    Distributed {
293        /// Path to the shared aida git repo (absolute or relative to workspace)
294        #[serde(default)]
295        aida_repo_path: Option<String>,
296    },
297}
298
299impl Default for DeploymentMode {
300    fn default() -> Self {
301        Self::Centralized
302    }
303}
304
305impl DeploymentMode {
306    /// Whether this mode uses node-namespaced IDs.
307    pub fn is_distributed(&self) -> bool {
308        matches!(self, Self::Distributed { .. })
309    }
310
311    /// Get the IdMode for the dispenser.
312    /// In centralized mode, returns `IdMode::Centralized`.
313    /// In distributed mode, requires a node_id (from NodeConfig).
314    pub fn id_mode(&self, node_id: Option<u32>) -> IdMode {
315        match self {
316            Self::Centralized => IdMode::Centralized,
317            Self::Distributed { .. } => IdMode::Distributed {
318                node_id: node_id.expect("distributed mode requires a node_id"),
319            },
320        }
321    }
322}
323
324// ---------------------------------------------------------------------------
325// Tests
326// ---------------------------------------------------------------------------
327
328#[cfg(test)]
329mod tests {
330    use super::*;
331
332    #[test]
333    fn test_node_registry_sequential_ids() {
334        let mut registry = NodeRegistry::default();
335        let id1 = registry.register(1, "laptop".into());
336        let id2 = registry.register(1, "workstation".into());
337        let id3 = registry.register(2, "alice-dev".into());
338        assert_eq!(id1, 1);
339        assert_eq!(id2, 2);
340        assert_eq!(id3, 3);
341        assert!(registry.is_registered(1));
342        assert!(!registry.is_registered(99));
343    }
344
345    #[test]
346    fn test_user_registry() {
347        let mut registry = UserRegistry::default();
348        let id1 = registry.register("Joe".into(), Some("joe@example.com".into()));
349        let id2 = registry.register("Alice".into(), None);
350        assert_eq!(id1, 1);
351        assert_eq!(id2, 2);
352        assert!(registry.find_by_name("joe").is_some());
353        assert!(registry.find_by_name("JOE").is_some()); // case-insensitive
354        assert!(registry.find_by_name("bob").is_none());
355    }
356
357    #[test]
358    fn test_agreed_counters() {
359        let mut counters = AgreedCounters::default();
360        assert_eq!(counters.peek("FR"), 1);
361        assert_eq!(counters.next("FR"), 1);
362        assert_eq!(counters.next("FR"), 2);
363        assert_eq!(counters.next("FEAT"), 1);
364        assert_eq!(counters.peek("FR"), 3);
365
366        assert_eq!(
367            AgreedCounters::format_agreed_id("FR", 423),
368            "FR-423"
369        );
370    }
371
372    #[test]
373    fn test_deployment_mode_centralized() {
374        let mode = DeploymentMode::Centralized;
375        assert!(!mode.is_distributed());
376        assert_eq!(mode.id_mode(None), IdMode::Centralized);
377    }
378
379    #[test]
380    fn test_deployment_mode_distributed() {
381        let mode = DeploymentMode::Distributed {
382            aida_repo_path: Some("./aida".into()),
383        };
384        assert!(mode.is_distributed());
385        assert_eq!(
386            mode.id_mode(Some(7)),
387            IdMode::Distributed { node_id: 7 }
388        );
389    }
390
391    #[test]
392    fn test_deployment_mode_serde_roundtrip() {
393        let centralized = DeploymentMode::Centralized;
394        let json = serde_json::to_string(&centralized).unwrap();
395        assert_eq!(json, r#"{"mode":"centralized"}"#);
396
397        let distributed = DeploymentMode::Distributed {
398            aida_repo_path: Some("./aida".into()),
399        };
400        let json = serde_json::to_string(&distributed).unwrap();
401        let back: DeploymentMode = serde_json::from_str(&json).unwrap();
402        assert_eq!(back, distributed);
403    }
404
405    #[cfg(feature = "native")]
406    #[test]
407    fn test_node_config_persistence() {
408        let dir = tempfile::tempdir().unwrap();
409        let path = dir.path().join("node.toml");
410
411        let config = NodeConfig {
412            node_id: 7,
413            user_id: 102,
414            hostname: "joe-laptop".into(),
415            registered_at: Utc::now(),
416        };
417        config.save(&path).unwrap();
418
419        let loaded = NodeConfig::load(&path).unwrap();
420        assert_eq!(loaded.node_id, 7);
421        assert_eq!(loaded.user_id, 102);
422        assert_eq!(loaded.hostname, "joe-laptop");
423    }
424
425    #[cfg(feature = "native")]
426    #[test]
427    fn test_node_registry_persistence() {
428        let dir = tempfile::tempdir().unwrap();
429        let path = dir.path().join("nodes.toml");
430
431        let mut registry = NodeRegistry::default();
432        registry.register(102, "joe-laptop".into());
433        registry.register(102, "joe-workstation".into());
434        registry.save(&path).unwrap();
435
436        let loaded = NodeRegistry::load(&path).unwrap();
437        assert_eq!(loaded.nodes.len(), 2);
438        assert_eq!(loaded.nodes[0].hostname, "joe-laptop");
439        assert_eq!(loaded.nodes[1].hostname, "joe-workstation");
440    }
441
442    #[cfg(feature = "native")]
443    #[test]
444    fn test_workspace_config_serde() {
445        let config = WorkspaceConfig {
446            workspace: "gdms-disruptive".into(),
447            aida_path: "./aida".into(),
448            repos: vec!["pacgate".into(), "pacinet".into()],
449        };
450        let toml_str = toml::to_string_pretty(&config).unwrap();
451        let back: WorkspaceConfig = toml::from_str(&toml_str).unwrap();
452        assert_eq!(back.workspace, "gdms-disruptive");
453        assert_eq!(back.repos.len(), 2);
454    }
455}