Skip to main content

noether_engine/
trace.rs

1use noether_core::stage::StageId;
2use serde::{Deserialize, Serialize};
3use std::collections::HashMap;
4
5#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
6pub struct CompositionTrace {
7    pub composition_id: String,
8    pub started_at: String,
9    pub duration_ms: u64,
10    pub status: TraceStatus,
11    pub stages: Vec<StageTrace>,
12    /// Capability violations detected during pre-flight (informational; executions are blocked before this).
13    #[serde(default, skip_serializing_if = "Vec::is_empty")]
14    pub security_events: Vec<SecurityEvent>,
15    /// Effect warnings produced by the type checker.
16    #[serde(default, skip_serializing_if = "Vec::is_empty")]
17    pub warnings: Vec<String>,
18}
19
20/// A security event recorded when a capability policy blocks a stage.
21#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
22pub struct SecurityEvent {
23    pub stage_id: StageId,
24    pub capability: String,
25    pub message: String,
26}
27
28#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
29pub enum TraceStatus {
30    Ok,
31    Failed,
32}
33
34#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
35pub struct StageTrace {
36    pub stage_id: StageId,
37    pub step_index: usize,
38    pub status: StageStatus,
39    pub duration_ms: u64,
40    pub input_hash: Option<String>,
41    pub output_hash: Option<String>,
42}
43
44#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
45pub enum StageStatus {
46    Ok,
47    Failed { code: String, message: String },
48    Skipped { reason: String },
49}
50
51/// In-memory trace store.
52#[derive(Debug, Default)]
53pub struct MemoryTraceStore {
54    traces: HashMap<String, CompositionTrace>,
55}
56
57impl MemoryTraceStore {
58    pub fn new() -> Self {
59        Self::default()
60    }
61
62    pub fn put(&mut self, trace: CompositionTrace) -> String {
63        let id = trace.composition_id.clone();
64        self.traces.insert(id.clone(), trace);
65        id
66    }
67
68    pub fn get(&self, composition_id: &str) -> Option<&CompositionTrace> {
69        self.traces.get(composition_id)
70    }
71
72    pub fn list(&self) -> Vec<&CompositionTrace> {
73        self.traces.values().collect()
74    }
75}
76
77/// File-backed trace store. Persists to JSON on every put.
78#[cfg(feature = "native")]
79pub struct JsonFileTraceStore {
80    path: std::path::PathBuf,
81    traces: HashMap<String, CompositionTrace>,
82}
83
84#[cfg(feature = "native")]
85#[derive(Serialize, Deserialize)]
86struct TraceFile {
87    traces: Vec<CompositionTrace>,
88}
89
90#[cfg(feature = "native")]
91impl JsonFileTraceStore {
92    pub fn open(path: impl Into<std::path::PathBuf>) -> Result<Self, String> {
93        let path = path.into();
94        let traces = if path.exists() {
95            let content = std::fs::read_to_string(&path).map_err(|e| format!("read error: {e}"))?;
96            let file: TraceFile =
97                serde_json::from_str(&content).map_err(|e| format!("parse error: {e}"))?;
98            file.traces
99                .into_iter()
100                .map(|t| (t.composition_id.clone(), t))
101                .collect()
102        } else {
103            HashMap::new()
104        };
105        Ok(Self { path, traces })
106    }
107
108    pub fn put(&mut self, trace: CompositionTrace) -> String {
109        let id = trace.composition_id.clone();
110        self.traces.insert(id.clone(), trace);
111        let _ = self.save();
112        id
113    }
114
115    pub fn get(&self, composition_id: &str) -> Option<&CompositionTrace> {
116        self.traces.get(composition_id)
117    }
118
119    pub fn list(&self) -> Vec<&CompositionTrace> {
120        self.traces.values().collect()
121    }
122
123    fn save(&self) -> Result<(), String> {
124        if let Some(parent) = self.path.parent() {
125            std::fs::create_dir_all(parent).map_err(|e| format!("mkdir error: {e}"))?;
126        }
127        let file = TraceFile {
128            traces: self.traces.values().cloned().collect(),
129        };
130        let json = serde_json::to_string_pretty(&file).map_err(|e| format!("json error: {e}"))?;
131        std::fs::write(&self.path, json).map_err(|e| format!("write error: {e}"))?;
132        Ok(())
133    }
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139
140    fn sample_trace() -> CompositionTrace {
141        CompositionTrace {
142            composition_id: "abc123".into(),
143            started_at: "2026-04-05T10:00:00Z".into(),
144            duration_ms: 100,
145            status: TraceStatus::Ok,
146            stages: vec![StageTrace {
147                stage_id: StageId("stage1".into()),
148                step_index: 0,
149                status: StageStatus::Ok,
150                duration_ms: 50,
151                input_hash: Some("inhash".into()),
152                output_hash: Some("outhash".into()),
153            }],
154            security_events: Vec::new(),
155            warnings: Vec::new(),
156        }
157    }
158
159    #[test]
160    fn trace_store_put_get() {
161        let mut store = MemoryTraceStore::new();
162        let trace = sample_trace();
163        store.put(trace.clone());
164        let retrieved = store.get("abc123").unwrap();
165        assert_eq!(retrieved, &trace);
166    }
167
168    #[test]
169    fn trace_serde_round_trip() {
170        let trace = sample_trace();
171        let json = serde_json::to_string(&trace).unwrap();
172        let parsed: CompositionTrace = serde_json::from_str(&json).unwrap();
173        assert_eq!(trace, parsed);
174    }
175}