1use chrono::{DateTime, Utc};
28use cortex_core::{EventId, Trace, TraceId, TraceStatus, SCHEMA_VERSION};
29use thiserror::Error;
30
31#[derive(Debug, Error, Clone, PartialEq, Eq)]
33pub enum TraceError {
34 #[error("trace {trace_id} is closed; cannot attach event {event_id}")]
36 Closed {
37 trace_id: TraceId,
39 event_id: EventId,
41 },
42 #[error("trace {trace_id} is already closed")]
44 AlreadyClosed {
45 trace_id: TraceId,
47 },
48}
49
50#[derive(Debug, Clone)]
55pub struct TraceAssembler {
56 id: TraceId,
57 schema_version: u16,
58 opened_at: DateTime<Utc>,
59 closed_at: Option<DateTime<Utc>>,
60 trace_type: String,
61 status: TraceStatus,
62 event_ids: Vec<EventId>,
64}
65
66impl TraceAssembler {
67 #[must_use]
69 pub fn open(id: TraceId, trace_type: impl Into<String>, opened_at: DateTime<Utc>) -> Self {
70 Self {
71 id,
72 schema_version: SCHEMA_VERSION,
73 opened_at,
74 closed_at: None,
75 trace_type: trace_type.into(),
76 status: TraceStatus::Open,
77 event_ids: Vec::new(),
78 }
79 }
80
81 #[must_use]
83 pub fn id(&self) -> TraceId {
84 self.id
85 }
86
87 #[must_use]
89 pub fn status(&self) -> TraceStatus {
90 self.status
91 }
92
93 #[must_use]
95 pub fn len(&self) -> usize {
96 self.event_ids.len()
97 }
98
99 #[must_use]
101 pub fn is_empty(&self) -> bool {
102 self.event_ids.is_empty()
103 }
104
105 pub fn attach(&mut self, event_id: EventId) -> Result<u64, TraceError> {
110 if !matches!(self.status, TraceStatus::Open) {
111 return Err(TraceError::Closed {
112 trace_id: self.id,
113 event_id,
114 });
115 }
116 let ordinal = self.event_ids.len() as u64;
117 self.event_ids.push(event_id);
118 Ok(ordinal)
119 }
120
121 pub fn close(&mut self, closed_at: DateTime<Utc>) -> Result<Trace, TraceError> {
128 if !matches!(self.status, TraceStatus::Open) {
129 return Err(TraceError::AlreadyClosed { trace_id: self.id });
130 }
131 self.status = TraceStatus::Closed;
132 self.closed_at = Some(closed_at);
133 Ok(self.snapshot())
134 }
135
136 #[must_use]
141 pub fn snapshot(&self) -> Trace {
142 Trace {
143 id: self.id,
144 schema_version: self.schema_version,
145 opened_at: self.opened_at,
146 closed_at: self.closed_at,
147 event_ids: self.event_ids.clone(),
148 trace_type: self.trace_type.clone(),
149 status: self.status,
150 }
151 }
152}
153
154#[cfg(test)]
155mod tests {
156 use super::*;
157 use chrono::TimeZone;
158
159 fn now() -> DateTime<Utc> {
160 Utc.with_ymd_and_hms(2026, 1, 1, 12, 0, 0).unwrap()
161 }
162
163 #[test]
165 fn ordinals_are_dense_and_monotonic() {
166 let tid = TraceId::new();
167 let mut a = TraceAssembler::open(tid, "agent_run", now());
168
169 let mut ordinals = Vec::new();
170 for _ in 0..16 {
171 let e = EventId::new();
172 ordinals.push(a.attach(e).expect("open trace accepts attach"));
173 }
174
175 assert_eq!(
177 ordinals,
178 (0u64..16).collect::<Vec<_>>(),
179 "ordinals must be dense"
180 );
181 for w in ordinals.windows(2) {
183 assert!(w[1] > w[0], "ordinals must be strictly monotonic");
184 }
185 assert_eq!(a.len(), 16);
186 assert_eq!(a.status(), TraceStatus::Open);
187 }
188
189 #[test]
190 fn close_seals_trace_and_blocks_further_attaches() {
191 let tid = TraceId::new();
192 let mut a = TraceAssembler::open(tid, "agent_run", now());
193 let e1 = EventId::new();
194 a.attach(e1).unwrap();
195
196 let trace = a.close(now()).expect("close succeeds");
197 assert_eq!(trace.status, TraceStatus::Closed);
198 assert_eq!(trace.event_ids, vec![e1]);
199 assert!(trace.closed_at.is_some());
200
201 let e2 = EventId::new();
203 let err = a.attach(e2).unwrap_err();
204 assert!(matches!(err, TraceError::Closed { .. }));
205
206 let err = a.close(now()).unwrap_err();
208 assert!(matches!(err, TraceError::AlreadyClosed { .. }));
209 }
210
211 #[test]
212 fn empty_trace_closes_cleanly() {
213 let tid = TraceId::new();
214 let mut a = TraceAssembler::open(tid, "manual_session", now());
215 assert!(a.is_empty());
216 let trace = a.close(now()).unwrap();
217 assert!(trace.event_ids.is_empty());
218 assert_eq!(trace.status, TraceStatus::Closed);
219 }
220
221 #[test]
222 fn snapshot_reflects_open_state_without_sealing() {
223 let tid = TraceId::new();
224 let mut a = TraceAssembler::open(tid, "agent_run", now());
225 a.attach(EventId::new()).unwrap();
226 let snap = a.snapshot();
227 assert_eq!(snap.status, TraceStatus::Open);
228 assert!(snap.closed_at.is_none());
229 assert_eq!(a.status(), TraceStatus::Open);
231 }
232}