Skip to main content

a3s_code_core/
trace.rs

1//! Runtime trace primitives.
2//!
3//! Trace events are compact execution facts emitted by the harness. They are
4//! separate from model-visible tool output and from large artifacts.
5
6use serde::{Deserialize, Serialize};
7use std::collections::VecDeque;
8use std::sync::{Arc, RwLock};
9use std::time::Duration;
10
11pub const TRACE_EVENT_SCHEMA: &str = "a3s.trace_event.v1";
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
14#[serde(rename_all = "snake_case")]
15pub enum TraceEventKind {
16    ToolExecution,
17    ProgramExecution,
18}
19
20#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
21pub struct TraceEvent {
22    pub schema: String,
23    pub kind: TraceEventKind,
24    pub name: String,
25    pub success: bool,
26    pub exit_code: i32,
27    pub duration_ms: u64,
28    pub output_bytes: usize,
29    #[serde(default, skip_serializing_if = "Vec::is_empty")]
30    pub metadata_keys: Vec<String>,
31    #[serde(default, skip_serializing_if = "Vec::is_empty")]
32    pub artifact_uris: Vec<String>,
33    #[serde(default, skip_serializing_if = "Option::is_none")]
34    pub details: Option<serde_json::Value>,
35}
36
37impl TraceEvent {
38    pub fn tool_execution(
39        name: impl Into<String>,
40        success: bool,
41        exit_code: i32,
42        duration: Duration,
43        output_bytes: usize,
44        metadata: Option<&serde_json::Value>,
45    ) -> Self {
46        Self {
47            schema: TRACE_EVENT_SCHEMA.to_string(),
48            kind: TraceEventKind::ToolExecution,
49            name: name.into(),
50            success,
51            exit_code,
52            duration_ms: duration.as_millis().min(u128::from(u64::MAX)) as u64,
53            output_bytes,
54            metadata_keys: metadata_keys(metadata),
55            artifact_uris: artifact_uris(metadata),
56            details: None,
57        }
58    }
59
60    pub fn program_execution(
61        name: impl Into<String>,
62        success: bool,
63        exit_code: i32,
64        duration: Duration,
65        output_bytes: usize,
66        metadata: Option<&serde_json::Value>,
67    ) -> Self {
68        let details = metadata
69            .and_then(|metadata| metadata.get("trace"))
70            .map(program_trace_summary);
71
72        Self {
73            schema: TRACE_EVENT_SCHEMA.to_string(),
74            kind: TraceEventKind::ProgramExecution,
75            name: name.into(),
76            success,
77            exit_code,
78            duration_ms: duration.as_millis().min(u128::from(u64::MAX)) as u64,
79            output_bytes,
80            metadata_keys: metadata_keys(metadata),
81            artifact_uris: artifact_uris(metadata),
82            details,
83        }
84    }
85}
86
87pub trait TraceSink: Send + Sync {
88    fn record(&self, event: TraceEvent);
89}
90
91#[derive(Debug, Clone, Default)]
92pub struct InMemoryTraceSink {
93    events: Arc<RwLock<VecDeque<TraceEvent>>>,
94    /// FIFO retention cap (`None` = unlimited). When set, the oldest
95    /// event is dropped on each new `record` once the buffer exceeds
96    /// this size. Useful for long-running sessions that would
97    /// otherwise leak trace memory.
98    max_events: Option<usize>,
99}
100
101impl InMemoryTraceSink {
102    /// Construct a sink with no retention cap (default, unbounded).
103    pub fn new() -> Self {
104        Self::default()
105    }
106
107    /// Construct a sink that retains at most `max_events` records.
108    pub fn with_max_events(max_events: usize) -> Self {
109        Self {
110            events: Arc::new(RwLock::new(VecDeque::with_capacity(max_events.min(1024)))),
111            max_events: Some(max_events),
112        }
113    }
114
115    pub fn events(&self) -> Vec<TraceEvent> {
116        self.events.read().unwrap().iter().cloned().collect()
117    }
118
119    /// Replace the retained history while applying the same FIFO policy used
120    /// by live recording. Restored sessions must not bypass the configured
121    /// retention boundary.
122    pub fn replace_events(&self, events: Vec<TraceEvent>) {
123        let mut retained = VecDeque::with_capacity(
124            self.max_events
125                .map(|cap| events.len().min(cap))
126                .unwrap_or(events.len()),
127        );
128        let skip = self
129            .max_events
130            .map(|cap| events.len().saturating_sub(cap))
131            .unwrap_or(0);
132        retained.extend(events.into_iter().skip(skip));
133        *self.events.write().unwrap() = retained;
134    }
135
136    pub fn clear(&self) {
137        self.events.write().unwrap().clear();
138    }
139}
140
141impl TraceSink for InMemoryTraceSink {
142    fn record(&self, event: TraceEvent) {
143        let mut events = self.events.write().unwrap();
144        events.push_back(event);
145        // FIFO trim — keep the buffer at most `max_events`. VecDeque keeps
146        // this hot-path eviction O(1), including for long-lived sessions.
147        if let Some(cap) = self.max_events {
148            while events.len() > cap {
149                events.pop_front();
150            }
151        }
152    }
153}
154
155#[derive(Debug, Clone, Copy, Default)]
156pub struct NoopTraceSink;
157
158impl TraceSink for NoopTraceSink {
159    fn record(&self, _event: TraceEvent) {}
160}
161
162fn metadata_keys(metadata: Option<&serde_json::Value>) -> Vec<String> {
163    let Some(serde_json::Value::Object(object)) = metadata else {
164        return Vec::new();
165    };
166
167    let mut keys = object.keys().cloned().collect::<Vec<_>>();
168    keys.sort();
169    keys
170}
171
172fn artifact_uris(metadata: Option<&serde_json::Value>) -> Vec<String> {
173    let mut uris = Vec::new();
174    if let Some(metadata) = metadata {
175        collect_artifact_uris(metadata, &mut uris);
176    }
177    uris.sort();
178    uris.dedup();
179    uris
180}
181
182fn collect_artifact_uris(value: &serde_json::Value, uris: &mut Vec<String>) {
183    match value {
184        serde_json::Value::Object(object) => {
185            if let Some(uri) = object.get("artifact_uri").and_then(|value| value.as_str()) {
186                uris.push(uri.to_string());
187            }
188            for value in object.values() {
189                collect_artifact_uris(value, uris);
190            }
191        }
192        serde_json::Value::Array(items) => {
193            for value in items {
194                collect_artifact_uris(value, uris);
195            }
196        }
197        _ => {}
198    }
199}
200
201fn program_trace_summary(trace: &serde_json::Value) -> serde_json::Value {
202    serde_json::json!({
203        "program_name": trace.get("program_name").cloned().unwrap_or_default(),
204        "success": trace.get("success").cloned().unwrap_or_default(),
205        "step_count": trace.get("step_count").cloned().unwrap_or_default(),
206        "failed_steps": trace.get("failed_steps").cloned().unwrap_or_default(),
207    })
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213
214    #[test]
215    fn in_memory_trace_sink_records_events() {
216        let sink = InMemoryTraceSink::default();
217        sink.record(TraceEvent::tool_execution(
218            "read",
219            true,
220            0,
221            Duration::from_millis(7),
222            12,
223            Some(&serde_json::json!({
224                "artifact": {
225                    "artifact_uri": "a3s://tool-output/read/abc"
226                },
227                "file_path": "src/lib.rs"
228            })),
229        ));
230
231        let events = sink.events();
232
233        assert_eq!(events.len(), 1);
234        assert_eq!(events[0].schema, TRACE_EVENT_SCHEMA);
235        assert_eq!(events[0].kind, TraceEventKind::ToolExecution);
236        assert_eq!(events[0].metadata_keys, vec!["artifact", "file_path"]);
237        assert_eq!(events[0].artifact_uris, vec!["a3s://tool-output/read/abc"]);
238    }
239
240    #[test]
241    fn program_trace_event_stores_compact_summary() {
242        let event = TraceEvent::program_execution(
243            "program",
244            true,
245            0,
246            Duration::from_millis(3),
247            42,
248            Some(&serde_json::json!({
249                "trace": {
250                    "program_name": "program_repo_map",
251                    "success": true,
252                    "step_count": 7,
253                    "failed_steps": 0,
254                    "steps": [{"output": "not copied into event"}]
255                }
256            })),
257        );
258
259        assert_eq!(event.kind, TraceEventKind::ProgramExecution);
260        assert_eq!(
261            event.details.as_ref().unwrap()["program_name"],
262            "program_repo_map"
263        );
264        assert!(event.details.as_ref().unwrap().get("steps").is_none());
265    }
266
267    fn dummy_event(i: u32) -> TraceEvent {
268        TraceEvent::tool_execution(
269            "read",
270            true,
271            0,
272            Duration::from_millis(i as u64),
273            i as usize,
274            None,
275        )
276    }
277
278    #[test]
279    fn with_max_events_caps_buffer_fifo() {
280        let sink = InMemoryTraceSink::with_max_events(3);
281        for i in 0..10 {
282            sink.record(dummy_event(i));
283        }
284        let events = sink.events();
285        assert_eq!(events.len(), 3, "buffer must be capped");
286        // Oldest events are evicted; the surviving events are the
287        // last `cap` recorded (7, 8, 9).
288        assert_eq!(events[0].duration_ms, 7);
289        assert_eq!(events[2].duration_ms, 9);
290    }
291
292    #[test]
293    fn default_sink_is_unbounded() {
294        let sink = InMemoryTraceSink::new();
295        for i in 0..50 {
296            sink.record(dummy_event(i));
297        }
298        assert_eq!(sink.events().len(), 50);
299    }
300
301    #[test]
302    fn replacing_events_applies_fifo_retention() {
303        let sink = InMemoryTraceSink::with_max_events(3);
304        sink.replace_events((0..10).map(dummy_event).collect());
305
306        let events = sink.events();
307        assert_eq!(events.len(), 3);
308        assert_eq!(events[0].duration_ms, 7);
309        assert_eq!(events[2].duration_ms, 9);
310    }
311
312    #[test]
313    fn zero_retention_drops_restored_and_live_events() {
314        let sink = InMemoryTraceSink::with_max_events(0);
315        sink.replace_events(vec![dummy_event(1), dummy_event(2)]);
316        sink.record(dummy_event(3));
317
318        assert!(sink.events().is_empty());
319    }
320}