Skip to main content

aura_agent/runtime/
effect_trace_capture.rs

1//! Effect-trace capture helpers for deterministic replay and diagnostics.
2
3use std::fs;
4use std::path::Path;
5
6use aura_core::AuraFault;
7use aura_core::AuraVmDeterminismProfileV1;
8use serde::{Deserialize, Serialize};
9use telltale_machine::{
10    canonical_effect_trace, EffectTraceCaptureMode, EffectTraceEntry, ProtocolMachineConfig,
11};
12
13/// Trace payload encoding for persisted replay artifacts.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
15pub enum AuraEffectTraceEncoding {
16    /// UTF-8 JSON payload.
17    #[default]
18    Json,
19    /// Binary CBOR payload.
20    Cbor,
21}
22
23impl AuraEffectTraceEncoding {
24    /// Parse encoding from CLI/config text.
25    #[must_use]
26    pub fn parse(raw: &str) -> Option<Self> {
27        match raw.trim().to_ascii_lowercase().as_str() {
28            "json" => Some(Self::Json),
29            "cbor" => Some(Self::Cbor),
30            _ => None,
31        }
32    }
33}
34
35/// Capture granularity mapped to Telltale protocol-machine capture modes.
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
37pub enum AuraEffectTraceGranularity {
38    /// Capture all effect kinds.
39    #[default]
40    Full,
41    /// Capture topology-only events.
42    TopologyOnly,
43    /// Disable capture.
44    Disabled,
45}
46
47impl AuraEffectTraceGranularity {
48    /// Convert to VM capture mode.
49    #[must_use]
50    pub fn to_vm_mode(self) -> EffectTraceCaptureMode {
51        match self {
52            Self::Full => EffectTraceCaptureMode::Full,
53            Self::TopologyOnly => EffectTraceCaptureMode::TopologyOnly,
54            Self::Disabled => EffectTraceCaptureMode::Disabled,
55        }
56    }
57
58    /// Convert from VM capture mode.
59    #[must_use]
60    pub fn from_vm_mode(mode: EffectTraceCaptureMode) -> Self {
61        match mode {
62            EffectTraceCaptureMode::Full => Self::Full,
63            EffectTraceCaptureMode::TopologyOnly => Self::TopologyOnly,
64            EffectTraceCaptureMode::Disabled => Self::Disabled,
65        }
66    }
67}
68
69/// Capture options used by [`EffectTraceCapture`].
70#[derive(Debug, Clone, Copy, PartialEq, Eq)]
71pub struct EffectTraceCaptureOptions {
72    /// Persisted encoding format.
73    pub encoding: AuraEffectTraceEncoding,
74    /// Capture granularity.
75    pub granularity: AuraEffectTraceGranularity,
76    /// Canonicalize trace before serialization.
77    pub canonicalize: bool,
78}
79
80impl Default for EffectTraceCaptureOptions {
81    fn default() -> Self {
82        Self {
83            encoding: AuraEffectTraceEncoding::Json,
84            granularity: AuraEffectTraceGranularity::Full,
85            canonicalize: true,
86        }
87    }
88}
89
90/// File/serialization errors from trace capture and replay tooling.
91#[derive(Debug, thiserror::Error)]
92pub enum EffectTraceCaptureError {
93    /// IO failure while reading/writing trace artifacts.
94    #[error("effect trace IO error at {path}: {source}")]
95    Io {
96        /// Path that failed.
97        path: String,
98        /// Wrapped IO error.
99        source: std::io::Error,
100    },
101    /// JSON encoding/decoding failure.
102    #[error("effect trace JSON serialization failed: {source}")]
103    Json {
104        /// Wrapped serde error.
105        source: serde_json::Error,
106    },
107    /// CBOR encoding/decoding failure.
108    #[error("effect trace CBOR serialization failed: {source}")]
109    Cbor {
110        /// Wrapped serde error.
111        source: serde_cbor::Error,
112    },
113}
114
115/// Trace-capture utility with canonicalization + granularity filtering.
116#[derive(Debug, Clone, Copy)]
117pub struct EffectTraceCapture {
118    options: EffectTraceCaptureOptions,
119}
120
121/// Fault-aware effect trace bundle used for replay/debug artifacts.
122#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
123pub struct EffectTraceBundle {
124    /// Captured effect trace entries.
125    pub entries: Vec<EffectTraceEntry>,
126    /// Serialized injected faults for replay.
127    #[serde(default)]
128    pub faults: Vec<AuraFault>,
129    /// Optional determinism profile metadata active for the captured run.
130    #[serde(default)]
131    pub vm_determinism_profile: Option<AuraVmDeterminismProfileV1>,
132}
133
134impl Default for EffectTraceCapture {
135    fn default() -> Self {
136        Self::new(EffectTraceCaptureOptions::default())
137    }
138}
139
140impl EffectTraceCapture {
141    /// Build a capture utility with explicit options.
142    #[must_use]
143    pub fn new(options: EffectTraceCaptureOptions) -> Self {
144        Self { options }
145    }
146
147    /// Read configured options.
148    #[must_use]
149    pub fn options(&self) -> EffectTraceCaptureOptions {
150        self.options
151    }
152
153    /// Apply capture granularity onto VM configuration.
154    pub fn apply_to_vm_config(&self, config: &mut ProtocolMachineConfig) {
155        config.effect_trace_capture_mode = self.options.granularity.to_vm_mode();
156    }
157
158    /// Build capture utility from VM configuration.
159    #[must_use]
160    pub fn from_vm_config(config: &ProtocolMachineConfig) -> Self {
161        Self::new(EffectTraceCaptureOptions {
162            encoding: AuraEffectTraceEncoding::Json,
163            granularity: AuraEffectTraceGranularity::from_vm_mode(config.effect_trace_capture_mode),
164            canonicalize: true,
165        })
166    }
167
168    /// Prepare a trace for persistence/replay according to capture options.
169    #[must_use]
170    pub fn capture_entries(&self, trace: &[EffectTraceEntry]) -> Vec<EffectTraceEntry> {
171        let mut entries = if self.options.canonicalize {
172            canonical_effect_trace(trace)
173        } else {
174            trace.to_vec()
175        };
176
177        match self.options.granularity {
178            AuraEffectTraceGranularity::Full => {}
179            AuraEffectTraceGranularity::TopologyOnly => {
180                entries.retain(|entry| entry.effect_kind == "topology_event");
181            }
182            AuraEffectTraceGranularity::Disabled => {
183                entries.clear();
184            }
185        }
186
187        entries
188    }
189
190    /// Serialize trace entries using configured encoding.
191    ///
192    /// # Errors
193    ///
194    /// Returns serialization errors for invalid payloads.
195    pub fn serialize_entries(
196        &self,
197        entries: &[EffectTraceEntry],
198    ) -> Result<Vec<u8>, EffectTraceCaptureError> {
199        match self.options.encoding {
200            AuraEffectTraceEncoding::Json => serde_json::to_vec(entries)
201                .map_err(|source| EffectTraceCaptureError::Json { source }),
202            AuraEffectTraceEncoding::Cbor => serde_cbor::to_vec(&entries.to_vec())
203                .map_err(|source| EffectTraceCaptureError::Cbor { source }),
204        }
205    }
206
207    /// Deserialize trace entries using configured encoding.
208    ///
209    /// # Errors
210    ///
211    /// Returns serialization errors for malformed payloads.
212    pub fn deserialize_entries(
213        &self,
214        payload: &[u8],
215    ) -> Result<Vec<EffectTraceEntry>, EffectTraceCaptureError> {
216        match self.options.encoding {
217            AuraEffectTraceEncoding::Json => serde_json::from_slice(payload)
218                .map_err(|source| EffectTraceCaptureError::Json { source }),
219            AuraEffectTraceEncoding::Cbor => serde_cbor::from_slice(payload)
220                .map_err(|source| EffectTraceCaptureError::Cbor { source }),
221        }
222    }
223
224    /// Capture + serialize one trace payload.
225    ///
226    /// # Errors
227    ///
228    /// Returns serialization errors for invalid payloads.
229    pub fn serialize_trace(
230        &self,
231        trace: &[EffectTraceEntry],
232    ) -> Result<Vec<u8>, EffectTraceCaptureError> {
233        let captured = self.capture_entries(trace);
234        self.serialize_entries(&captured)
235    }
236
237    /// Serialize a fault-aware replay bundle.
238    ///
239    /// # Errors
240    ///
241    /// Returns serialization errors for invalid payloads.
242    pub fn serialize_bundle(
243        &self,
244        trace: &[EffectTraceEntry],
245        faults: &[AuraFault],
246        vm_determinism_profile: Option<AuraVmDeterminismProfileV1>,
247    ) -> Result<Vec<u8>, EffectTraceCaptureError> {
248        let bundle = EffectTraceBundle {
249            entries: self.capture_entries(trace),
250            faults: faults.to_vec(),
251            vm_determinism_profile,
252        };
253        match self.options.encoding {
254            AuraEffectTraceEncoding::Json => serde_json::to_vec(&bundle)
255                .map_err(|source| EffectTraceCaptureError::Json { source }),
256            AuraEffectTraceEncoding::Cbor => serde_cbor::to_vec(&bundle)
257                .map_err(|source| EffectTraceCaptureError::Cbor { source }),
258        }
259    }
260
261    /// Deserialize a fault-aware replay bundle.
262    ///
263    /// # Errors
264    ///
265    /// Returns serialization errors for malformed payloads.
266    pub fn deserialize_bundle(
267        &self,
268        payload: &[u8],
269    ) -> Result<EffectTraceBundle, EffectTraceCaptureError> {
270        match self.options.encoding {
271            AuraEffectTraceEncoding::Json => serde_json::from_slice(payload)
272                .map_err(|source| EffectTraceCaptureError::Json { source }),
273            AuraEffectTraceEncoding::Cbor => serde_cbor::from_slice(payload)
274                .map_err(|source| EffectTraceCaptureError::Cbor { source }),
275        }
276    }
277
278    /// Write trace to one artifact path.
279    ///
280    /// # Errors
281    ///
282    /// Returns IO/serialization errors.
283    pub fn write_trace_file(
284        &self,
285        path: impl AsRef<Path>,
286        trace: &[EffectTraceEntry],
287    ) -> Result<(), EffectTraceCaptureError> {
288        let path_ref = path.as_ref();
289        let payload = self.serialize_trace(trace)?;
290        fs::write(path_ref, payload).map_err(|source| EffectTraceCaptureError::Io {
291            path: path_ref.display().to_string(),
292            source,
293        })
294    }
295
296    /// Read + decode trace from one artifact path.
297    ///
298    /// # Errors
299    ///
300    /// Returns IO/serialization errors.
301    pub fn read_trace_file(
302        &self,
303        path: impl AsRef<Path>,
304    ) -> Result<Vec<EffectTraceEntry>, EffectTraceCaptureError> {
305        let path_ref = path.as_ref();
306        let payload = fs::read(path_ref).map_err(|source| EffectTraceCaptureError::Io {
307            path: path_ref.display().to_string(),
308            source,
309        })?;
310        self.deserialize_entries(&payload)
311    }
312
313    /// Write a fault-aware replay bundle to one artifact path.
314    ///
315    /// # Errors
316    ///
317    /// Returns IO/serialization errors.
318    pub fn write_bundle_file(
319        &self,
320        path: impl AsRef<Path>,
321        trace: &[EffectTraceEntry],
322        faults: &[AuraFault],
323        vm_determinism_profile: Option<AuraVmDeterminismProfileV1>,
324    ) -> Result<(), EffectTraceCaptureError> {
325        let path_ref = path.as_ref();
326        let payload = self.serialize_bundle(trace, faults, vm_determinism_profile)?;
327        fs::write(path_ref, payload).map_err(|source| EffectTraceCaptureError::Io {
328            path: path_ref.display().to_string(),
329            source,
330        })
331    }
332
333    /// Read + decode a fault-aware replay bundle from one artifact path.
334    ///
335    /// # Errors
336    ///
337    /// Returns IO/serialization errors.
338    pub fn read_bundle_file(
339        &self,
340        path: impl AsRef<Path>,
341    ) -> Result<EffectTraceBundle, EffectTraceCaptureError> {
342        let path_ref = path.as_ref();
343        let payload = fs::read(path_ref).map_err(|source| EffectTraceCaptureError::Io {
344            path: path_ref.display().to_string(),
345            source,
346        })?;
347        self.deserialize_bundle(&payload)
348    }
349}
350
351#[cfg(test)]
352mod tests {
353    use super::*;
354    use serde_json::json;
355
356    fn sample_entry(effect_id: u64, effect_kind: &str) -> EffectTraceEntry {
357        EffectTraceEntry {
358            effect_id,
359            effect_kind: effect_kind.to_string(),
360            inputs: json!({"in": effect_id}),
361            outputs: json!({"out": effect_id}),
362            handler_identity: "handler".to_string(),
363            effect_interface: Some("AuraTest".to_string()),
364            effect_operation: Some(effect_kind.to_string()),
365            ordering_key: effect_id,
366            topology: None,
367        }
368    }
369
370    #[test]
371    fn topology_only_filters_non_topology_entries() {
372        let capture = EffectTraceCapture::new(EffectTraceCaptureOptions {
373            granularity: AuraEffectTraceGranularity::TopologyOnly,
374            canonicalize: false,
375            ..EffectTraceCaptureOptions::default()
376        });
377        let trace = vec![
378            sample_entry(0, "send_decision"),
379            sample_entry(1, "topology_event"),
380        ];
381
382        let captured = capture.capture_entries(&trace);
383        assert_eq!(captured.len(), 1);
384        assert_eq!(captured[0].effect_kind, "topology_event");
385    }
386
387    #[test]
388    fn disabled_capture_returns_empty_trace() {
389        let capture = EffectTraceCapture::new(EffectTraceCaptureOptions {
390            granularity: AuraEffectTraceGranularity::Disabled,
391            canonicalize: false,
392            ..EffectTraceCaptureOptions::default()
393        });
394        let trace = vec![sample_entry(0, "send_decision")];
395        assert!(capture.capture_entries(&trace).is_empty());
396    }
397
398    #[test]
399    fn cbor_roundtrip_succeeds() {
400        let capture = EffectTraceCapture::new(EffectTraceCaptureOptions {
401            encoding: AuraEffectTraceEncoding::Cbor,
402            canonicalize: false,
403            ..EffectTraceCaptureOptions::default()
404        });
405        let trace = vec![
406            sample_entry(0, "send_decision"),
407            sample_entry(1, "handle_recv"),
408        ];
409
410        let payload = capture.serialize_entries(&trace).expect("serialize cbor");
411        let decoded = capture
412            .deserialize_entries(&payload)
413            .expect("deserialize cbor");
414        assert_eq!(decoded, trace);
415    }
416
417    #[test]
418    fn json_bundle_roundtrip_preserves_faults() {
419        let capture = EffectTraceCapture::new(EffectTraceCaptureOptions {
420            encoding: AuraEffectTraceEncoding::Json,
421            canonicalize: false,
422            ..EffectTraceCaptureOptions::default()
423        });
424        let trace = vec![sample_entry(0, "send_decision")];
425        let faults = vec![AuraFault::new(aura_core::AuraFaultKind::Legacy {
426            fault_type: "network_partition".to_string(),
427            detail: Some("group=2".to_string()),
428        })];
429
430        let payload = capture
431            .serialize_bundle(&trace, &faults, None)
432            .expect("serialize bundle");
433        let decoded = capture
434            .deserialize_bundle(&payload)
435            .expect("deserialize bundle");
436        assert_eq!(decoded.entries, trace);
437        assert_eq!(decoded.faults, faults);
438        assert_eq!(decoded.vm_determinism_profile, None);
439    }
440
441    #[test]
442    fn bundle_roundtrip_preserves_determinism_profile() {
443        let capture = EffectTraceCapture::default();
444        let trace = vec![sample_entry(1, "handle_recv")];
445        let payload = capture
446            .serialize_bundle(
447                &trace,
448                &[],
449                Some(AuraVmDeterminismProfileV1 {
450                    policy_ref: "aura.vm.recovery_grant.prod".to_string(),
451                    protocol_class: "aura.recovery.grant".to_string(),
452                    runtime_mode: "cooperative".to_string(),
453                    scheduler_envelope_class: "exact".to_string(),
454                    declared_wave_width_bound: Some(1),
455                    determinism_mode: "full".to_string(),
456                    effect_determinism_tier: "strict_deterministic".to_string(),
457                    communication_replay_mode: "off".to_string(),
458                }),
459            )
460            .expect("serialize bundle");
461        let decoded = capture
462            .deserialize_bundle(&payload)
463            .expect("deserialize bundle");
464
465        assert_eq!(
466            decoded
467                .vm_determinism_profile
468                .as_ref()
469                .map(|profile| profile.policy_ref.as_str()),
470            Some("aura.vm.recovery_grant.prod")
471        );
472    }
473}