Skip to main content

oxidized_state/
schema.rs

1//! Schema definitions for AIVCS SurrealDB tables
2//!
3//! Tables:
4//! - commits: Version control commits (graph nodes)
5//! - branches: Branch pointers to commit IDs
6//! - agents: Registered agent metadata
7//! - memories: Agent memory/context snapshots
8
9use chrono::{DateTime, Utc};
10
11/// Module for serializing chrono DateTime to SurrealDB datetime format
12mod surreal_datetime {
13    use chrono::{DateTime, Utc};
14    use serde::{self, Deserialize, Deserializer, Serializer};
15    use surrealdb::sql::Datetime as SurrealDatetime;
16
17    pub fn serialize<S>(date: &DateTime<Utc>, serializer: S) -> Result<S::Ok, S::Error>
18    where
19        S: Serializer,
20    {
21        let sd = SurrealDatetime::from(*date);
22        serde::Serialize::serialize(&sd, serializer)
23    }
24
25    pub fn deserialize<'de, D>(deserializer: D) -> Result<DateTime<Utc>, D::Error>
26    where
27        D: Deserializer<'de>,
28    {
29        let sd = SurrealDatetime::deserialize(deserializer)?;
30        Ok(DateTime::from(sd))
31    }
32}
33
34/// Module for serializing optional chrono DateTime to SurrealDB datetime format
35mod surreal_datetime_opt {
36    use chrono::{DateTime, Utc};
37    use serde::{self, Deserialize, Deserializer, Serializer};
38    use surrealdb::sql::Datetime as SurrealDatetime;
39
40    pub fn serialize<S>(date: &Option<DateTime<Utc>>, serializer: S) -> Result<S::Ok, S::Error>
41    where
42        S: Serializer,
43    {
44        match date {
45            Some(d) => {
46                let sd = SurrealDatetime::from(*d);
47                serde::Serialize::serialize(&Some(sd), serializer)
48            }
49            None => serde::Serialize::serialize(&None::<SurrealDatetime>, serializer),
50        }
51    }
52
53    pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<DateTime<Utc>>, D::Error>
54    where
55        D: Deserializer<'de>,
56    {
57        let sd = Option::<SurrealDatetime>::deserialize(deserializer)?;
58        Ok(sd.map(DateTime::from))
59    }
60}
61use serde::{Deserialize, Serialize};
62use sha2::{Digest, Sha256};
63use uuid::Uuid;
64
65/// Composite Commit ID - hash of (Logic + State + Environment)
66///
67/// A commit in AIVCS is a tuple of:
68/// 1. Logic: The Rust binaries/scripts hash
69/// 2. State: The agent state snapshot hash
70/// 3. Environment: The Nix Flake hash
71#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
72pub struct CommitId {
73    /// The composite hash
74    pub hash: String,
75    /// Logic hash component
76    pub logic_hash: Option<String>,
77    /// State hash component
78    pub state_hash: String,
79    /// Environment (Nix) hash component
80    pub env_hash: Option<String>,
81}
82
83impl CommitId {
84    /// Create a new CommitId from state only (Phase 1 MVP)
85    pub fn from_state(state: &[u8]) -> Self {
86        let mut hasher = Sha256::new();
87        hasher.update(state);
88        let state_hash = hex::encode(hasher.finalize());
89
90        // For MVP, composite hash is just the state hash
91        CommitId {
92            hash: state_hash.clone(),
93            logic_hash: None,
94            state_hash,
95            env_hash: None,
96        }
97    }
98
99    /// Create a full composite CommitId (Phase 2+)
100    pub fn new(logic_hash: Option<&str>, state_hash: &str, env_hash: Option<&str>) -> Self {
101        let mut hasher = Sha256::new();
102        if let Some(lh) = logic_hash {
103            hasher.update(lh.as_bytes());
104        }
105        hasher.update(state_hash.as_bytes());
106        if let Some(eh) = env_hash {
107            hasher.update(eh.as_bytes());
108        }
109        let composite = hex::encode(hasher.finalize());
110
111        CommitId {
112            hash: composite,
113            logic_hash: logic_hash.map(String::from),
114            state_hash: state_hash.to_string(),
115            env_hash: env_hash.map(String::from),
116        }
117    }
118
119    /// Get short hash (first 8 characters)
120    pub fn short(&self) -> &str {
121        &self.hash[..8.min(self.hash.len())]
122    }
123}
124
125impl std::fmt::Display for CommitId {
126    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
127        write!(f, "{}", self.hash)
128    }
129}
130
131/// Commit record stored in SurrealDB
132#[derive(Debug, Clone, Serialize, Deserialize)]
133pub struct CommitRecord {
134    /// SurrealDB record ID
135    pub id: Option<surrealdb::sql::Thing>,
136    /// The commit ID (composite hash)
137    pub commit_id: CommitId,
138    /// Parent commit IDs (empty for root commits)
139    pub parent_ids: Vec<String>,
140    /// Commit message
141    pub message: String,
142    /// Author/agent that created the commit
143    pub author: String,
144    /// Timestamp of commit creation
145    #[serde(with = "surreal_datetime")]
146    pub created_at: DateTime<Utc>,
147    /// Branch name (if this is a branch head)
148    pub branch: Option<String>,
149}
150
151impl CommitRecord {
152    /// Create a new commit record
153    pub fn new(commit_id: CommitId, parent_ids: Vec<String>, message: &str, author: &str) -> Self {
154        CommitRecord {
155            id: None,
156            commit_id,
157            parent_ids,
158            message: message.to_string(),
159            author: author.to_string(),
160            created_at: Utc::now(),
161            branch: None,
162        }
163    }
164}
165
166/// Snapshot record - the actual agent state data
167#[derive(Debug, Clone, Serialize, Deserialize)]
168pub struct SnapshotRecord {
169    /// SurrealDB record ID
170    pub id: Option<surrealdb::sql::Thing>,
171    /// The commit ID this snapshot belongs to
172    pub commit_id: String,
173    /// Serialized agent state (JSON)
174    pub state: serde_json::Value,
175    /// Size in bytes
176    pub size_bytes: u64,
177    /// Timestamp
178    #[serde(with = "surreal_datetime")]
179    pub created_at: DateTime<Utc>,
180}
181
182impl SnapshotRecord {
183    /// Create a new snapshot record
184    pub fn new(commit_id: &str, state: serde_json::Value) -> Self {
185        let size = serde_json::to_string(&state)
186            .map(|s| s.len() as u64)
187            .unwrap_or(0);
188
189        SnapshotRecord {
190            id: None,
191            commit_id: commit_id.to_string(),
192            state,
193            size_bytes: size,
194            created_at: Utc::now(),
195        }
196    }
197}
198
199/// Branch record - pointer to a commit
200#[derive(Debug, Clone, Serialize, Deserialize)]
201pub struct BranchRecord {
202    /// SurrealDB record ID
203    pub id: Option<surrealdb::sql::Thing>,
204    /// Branch name (e.g., "main", "feature/experiment-1")
205    pub name: String,
206    /// Current head commit ID
207    pub head_commit_id: String,
208    /// Is this the default branch?
209    pub is_default: bool,
210    /// Created timestamp
211    #[serde(with = "surreal_datetime")]
212    pub created_at: DateTime<Utc>,
213    /// Last updated timestamp
214    #[serde(with = "surreal_datetime")]
215    pub updated_at: DateTime<Utc>,
216}
217
218impl BranchRecord {
219    /// Create a new branch record
220    pub fn new(name: &str, head_commit_id: &str, is_default: bool) -> Self {
221        let now = Utc::now();
222        BranchRecord {
223            id: None,
224            name: name.to_string(),
225            head_commit_id: head_commit_id.to_string(),
226            is_default,
227            created_at: now,
228            updated_at: now,
229        }
230    }
231}
232
233/// Agent record - registered agent metadata
234#[derive(Debug, Clone, Serialize, Deserialize)]
235pub struct AgentRecord {
236    /// SurrealDB record ID
237    pub id: Option<surrealdb::sql::Thing>,
238    /// Agent UUID
239    pub agent_id: Uuid,
240    /// Agent name
241    pub name: String,
242    /// Agent type/kind
243    pub agent_type: String,
244    /// Configuration (JSON)
245    pub config: serde_json::Value,
246    /// Created timestamp
247    #[serde(with = "surreal_datetime")]
248    pub created_at: DateTime<Utc>,
249}
250
251impl AgentRecord {
252    /// Create a new agent record
253    pub fn new(name: &str, agent_type: &str, config: serde_json::Value) -> Self {
254        AgentRecord {
255            id: None,
256            agent_id: Uuid::new_v4(),
257            name: name.to_string(),
258            agent_type: agent_type.to_string(),
259            config,
260            created_at: Utc::now(),
261        }
262    }
263}
264
265/// Memory record - agent memory/context for RAG
266#[derive(Debug, Clone, Serialize, Deserialize)]
267pub struct MemoryRecord {
268    /// SurrealDB record ID
269    pub id: Option<surrealdb::sql::Thing>,
270    /// The commit ID this memory belongs to
271    pub commit_id: String,
272    /// Memory key/namespace
273    pub key: String,
274    /// Memory content (text for embedding)
275    pub content: String,
276    /// Optional embedding vector (for semantic search)
277    pub embedding: Option<Vec<f32>>,
278    /// Metadata
279    pub metadata: serde_json::Value,
280    /// Created timestamp
281    #[serde(with = "surreal_datetime")]
282    pub created_at: DateTime<Utc>,
283}
284
285impl MemoryRecord {
286    /// Create a new memory record
287    pub fn new(commit_id: &str, key: &str, content: &str) -> Self {
288        MemoryRecord {
289            id: None,
290            commit_id: commit_id.to_string(),
291            key: key.to_string(),
292            content: content.to_string(),
293            embedding: None,
294            metadata: serde_json::json!({}),
295            created_at: Utc::now(),
296        }
297    }
298
299    /// Set embedding vector
300    pub fn with_embedding(mut self, embedding: Vec<f32>) -> Self {
301        self.embedding = Some(embedding);
302        self
303    }
304
305    /// Set metadata
306    pub fn with_metadata(mut self, metadata: serde_json::Value) -> Self {
307        self.metadata = metadata;
308        self
309    }
310}
311
312/// Graph edge - represents commit relationships (parent -> child)
313#[derive(Debug, Clone, Serialize, Deserialize)]
314pub struct GraphEdge {
315    /// Child commit ID
316    pub child_id: String,
317    /// Parent commit ID
318    pub parent_id: String,
319    /// Edge type (normal, merge, fork)
320    pub edge_type: EdgeType,
321    /// Created timestamp
322    #[serde(with = "surreal_datetime")]
323    pub created_at: DateTime<Utc>,
324}
325
326/// Type of graph edge
327#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
328#[serde(rename_all = "lowercase")]
329pub enum EdgeType {
330    /// Normal parent-child relationship
331    Normal,
332    /// Merge commit (multiple parents)
333    Merge,
334    /// Fork/branch point
335    Fork,
336}
337
338impl GraphEdge {
339    /// Create a new normal graph edge
340    pub fn new(child_id: &str, parent_id: &str) -> Self {
341        GraphEdge {
342            child_id: child_id.to_string(),
343            parent_id: parent_id.to_string(),
344            edge_type: EdgeType::Normal,
345            created_at: Utc::now(),
346        }
347    }
348
349    /// Create a merge edge
350    pub fn merge(child_id: &str, parent_id: &str) -> Self {
351        GraphEdge {
352            child_id: child_id.to_string(),
353            parent_id: parent_id.to_string(),
354            edge_type: EdgeType::Merge,
355            created_at: Utc::now(),
356        }
357    }
358}
359
360// ---------------------------------------------------------------------------
361// RunLedger Records — Execution Run Persistence
362// ---------------------------------------------------------------------------
363
364/// Run record - execution run metadata and state
365#[derive(Debug, Clone, Serialize, Deserialize)]
366pub struct RunRecord {
367    /// SurrealDB record ID
368    pub id: Option<surrealdb::sql::Thing>,
369    /// Unique run ID (UUID string)
370    pub run_id: String,
371    /// Agent spec digest (SHA256)
372    pub spec_digest: String,
373    /// Git SHA at time of run (optional)
374    pub git_sha: Option<String>,
375    /// Agent name
376    pub agent_name: String,
377    /// Arbitrary tags (JSON)
378    pub tags: serde_json::Value,
379    /// Run status: "running" | "completed" | "failed"
380    pub status: String,
381    /// Total events recorded
382    pub total_events: u64,
383    /// Final state digest (if completed)
384    pub final_state_digest: Option<String>,
385    /// Duration in milliseconds
386    pub duration_ms: u64,
387    /// Whether run succeeded
388    pub success: bool,
389    /// Created timestamp
390    #[serde(with = "surreal_datetime")]
391    pub created_at: DateTime<Utc>,
392    /// Completed timestamp (if terminal)
393    #[serde(default, with = "surreal_datetime_opt")]
394    pub completed_at: Option<DateTime<Utc>>,
395}
396
397impl RunRecord {
398    /// Create a new run record in "running" state
399    pub fn new(
400        run_id: String,
401        spec_digest: String,
402        git_sha: Option<String>,
403        agent_name: String,
404        tags: serde_json::Value,
405    ) -> Self {
406        RunRecord {
407            id: None,
408            run_id,
409            spec_digest,
410            git_sha,
411            agent_name,
412            tags,
413            status: "running".to_string(),
414            total_events: 0,
415            final_state_digest: None,
416            duration_ms: 0,
417            success: false,
418            created_at: Utc::now(),
419            completed_at: None,
420        }
421    }
422
423    /// Mark run as completed
424    pub fn complete(
425        mut self,
426        total_events: u64,
427        final_state_digest: Option<String>,
428        duration_ms: u64,
429    ) -> Self {
430        self.status = "completed".to_string();
431        self.total_events = total_events;
432        self.final_state_digest = final_state_digest;
433        self.duration_ms = duration_ms;
434        self.success = true;
435        self.completed_at = Some(Utc::now());
436        self
437    }
438
439    /// Mark run as failed
440    pub fn fail(mut self, total_events: u64, duration_ms: u64) -> Self {
441        self.status = "failed".to_string();
442        self.total_events = total_events;
443        self.duration_ms = duration_ms;
444        self.success = false;
445        self.completed_at = Some(Utc::now());
446        self
447    }
448}
449
450/// Run event record - single event in execution
451#[derive(Debug, Clone, Serialize, Deserialize)]
452pub struct RunEventRecord {
453    /// SurrealDB record ID
454    pub id: Option<surrealdb::sql::Thing>,
455    /// Run ID this event belongs to
456    pub run_id: String,
457    /// Monotonic sequence number within run (1-indexed)
458    pub seq: u64,
459    /// Event kind (e.g. "GraphStarted", "NodeEntered", "ToolCalled")
460    pub kind: String,
461    /// Event payload (JSON)
462    pub payload: serde_json::Value,
463    /// Event timestamp
464    #[serde(with = "surreal_datetime")]
465    pub timestamp: DateTime<Utc>,
466}
467
468impl RunEventRecord {
469    /// Create a new run event record
470    pub fn new(run_id: String, seq: u64, kind: String, payload: serde_json::Value) -> Self {
471        RunEventRecord {
472            id: None,
473            run_id,
474            seq,
475            kind,
476            payload,
477            timestamp: Utc::now(),
478        }
479    }
480}
481
482/// Release record - agent release and version management
483#[derive(Debug, Clone, Serialize, Deserialize)]
484pub struct ReleaseRecordSchema {
485    /// SurrealDB record ID
486    pub id: Option<surrealdb::sql::Thing>,
487    /// Agent name
488    pub agent_name: String,
489    /// Spec digest being released
490    pub spec_digest: String,
491    /// Version label (e.g. "v1.2.3")
492    pub version_label: Option<String>,
493    /// Who or what promoted this release
494    pub promoted_by: String,
495    /// Release notes
496    pub notes: Option<String>,
497    /// Created timestamp
498    #[serde(with = "surreal_datetime")]
499    pub created_at: DateTime<Utc>,
500}
501
502impl ReleaseRecordSchema {
503    /// Create a new release record
504    pub fn new(
505        agent_name: String,
506        spec_digest: String,
507        version_label: Option<String>,
508        promoted_by: String,
509        notes: Option<String>,
510    ) -> Self {
511        ReleaseRecordSchema {
512            id: None,
513            agent_name,
514            spec_digest,
515            version_label,
516            promoted_by,
517            notes,
518            created_at: Utc::now(),
519        }
520    }
521}
522
523#[cfg(test)]
524mod tests {
525    use super::*;
526
527    #[test]
528    fn test_commit_id_from_state() {
529        let state = b"test state data";
530        let commit_id = CommitId::from_state(state);
531
532        assert!(!commit_id.hash.is_empty());
533        assert_eq!(commit_id.hash.len(), 64); // SHA256 hex = 64 chars
534        assert!(commit_id.logic_hash.is_none());
535        assert!(commit_id.env_hash.is_none());
536    }
537
538    #[test]
539    fn test_commit_id_deterministic() {
540        let state = b"same state";
541        let id1 = CommitId::from_state(state);
542        let id2 = CommitId::from_state(state);
543
544        assert_eq!(id1.hash, id2.hash);
545    }
546
547    #[test]
548    fn test_commit_id_different_states() {
549        let id1 = CommitId::from_state(b"state 1");
550        let id2 = CommitId::from_state(b"state 2");
551
552        assert_ne!(id1.hash, id2.hash);
553    }
554
555    #[test]
556    fn test_commit_id_short() {
557        let commit_id = CommitId::from_state(b"test");
558        assert_eq!(commit_id.short().len(), 8);
559    }
560
561    #[test]
562    fn test_composite_commit_id() {
563        let commit_id = CommitId::new(Some("logic-hash"), "state-hash", Some("env-hash"));
564
565        assert!(!commit_id.hash.is_empty());
566        assert_eq!(commit_id.logic_hash, Some("logic-hash".to_string()));
567        assert_eq!(commit_id.env_hash, Some("env-hash".to_string()));
568    }
569
570    #[test]
571    fn test_snapshot_record_size() {
572        let state = serde_json::json!({"key": "value", "nested": {"a": 1}});
573        let snapshot = SnapshotRecord::new("commit-123", state);
574
575        assert!(snapshot.size_bytes > 0);
576    }
577
578    #[test]
579    fn test_run_record_new() {
580        let run = RunRecord::new(
581            "run-123".to_string(),
582            "spec-digest-abc".to_string(),
583            Some("abc123".to_string()),
584            "test-agent".to_string(),
585            serde_json::json!({"env": "test"}),
586        );
587
588        assert_eq!(run.run_id, "run-123");
589        assert_eq!(run.status, "running");
590        assert_eq!(run.total_events, 0);
591        assert!(!run.success);
592    }
593
594    #[test]
595    fn test_run_record_complete() {
596        let run = RunRecord::new(
597            "run-123".to_string(),
598            "spec-digest-abc".to_string(),
599            Some("abc123".to_string()),
600            "test-agent".to_string(),
601            serde_json::json!({}),
602        )
603        .complete(5, Some("state-digest-xyz".to_string()), 1000);
604
605        assert_eq!(run.status, "completed");
606        assert_eq!(run.total_events, 5);
607        assert!(run.success);
608        assert!(run.completed_at.is_some());
609    }
610
611    #[test]
612    fn test_run_record_fail() {
613        let run = RunRecord::new(
614            "run-123".to_string(),
615            "spec-digest-abc".to_string(),
616            None,
617            "test-agent".to_string(),
618            serde_json::json!({}),
619        )
620        .fail(2, 500);
621
622        assert_eq!(run.status, "failed");
623        assert_eq!(run.total_events, 2);
624        assert!(!run.success);
625        assert!(run.completed_at.is_some());
626    }
627
628    #[test]
629    fn test_run_event_record() {
630        let event = RunEventRecord::new(
631            "run-123".to_string(),
632            1,
633            "GraphStarted".to_string(),
634            serde_json::json!({"graph_id": "g1"}),
635        );
636
637        assert_eq!(event.run_id, "run-123");
638        assert_eq!(event.seq, 1);
639        assert_eq!(event.kind, "GraphStarted");
640    }
641
642    #[test]
643    fn test_release_record() {
644        let release = ReleaseRecordSchema::new(
645            "my-agent".to_string(),
646            "spec-digest-abc".to_string(),
647            Some("v1.0.0".to_string()),
648            "alice".to_string(),
649            Some("Initial release".to_string()),
650        );
651
652        assert_eq!(release.agent_name, "my-agent");
653        assert_eq!(release.version_label, Some("v1.0.0".to_string()));
654    }
655}