1pub mod claude;
7pub mod codex;
8
9use crate::model::{Activity, Harness, TokenUsage, ToolSpan};
10use std::collections::VecDeque;
11use std::path::{Path, PathBuf};
12use std::time::SystemTime;
13
14#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
23pub struct ParseHealth {
24 pub billable_messages: u64,
26 pub usage_records: u64,
29 pub empty_usage_records: u64,
32}
33
34impl ParseHealth {
35 const MIN_EVIDENCE: u64 = 3;
38
39 pub fn fields_unrecognised(&self) -> bool {
46 self.billable_messages >= Self::MIN_EVIDENCE && self.usage_records == self.empty_usage_records
47 }
48}
49
50#[derive(Debug, Clone, Default)]
51pub struct SessionSummary {
52 pub harness: Option<Harness>,
53 pub session_id: Option<String>,
54 pub cwd: Option<PathBuf>,
55 pub model: Option<String>,
56 pub harness_version: Option<String>,
57 pub usage: TokenUsage,
58 pub cost_usd: f64,
59 pub unpriced_tokens: u64,
60 pub turns: u64,
61 pub subagent_turns: u64,
62 pub tool_calls: u64,
63 pub spans: SpanLog,
64 pub health: ParseHealth,
65 pub activity: Activity,
66 pub started_at: Option<SystemTime>,
67 pub last_activity: Option<SystemTime>,
68}
69
70pub const MAX_SPANS: usize = 128;
74
75#[derive(Debug, Clone, Default)]
82pub struct SpanLog {
83 spans: VecDeque<ToolSpan>,
84}
85
86impl SpanLog {
87 pub fn open(&mut self, id: String, name: String, at: SystemTime, sidechain: bool) {
90 if id.is_empty() || self.spans.iter().any(|s| s.is_open() && s.id == id) {
91 return;
92 }
93 if self.spans.len() == MAX_SPANS {
94 self.spans.pop_front();
95 }
96 self.spans.push_back(ToolSpan { id, name, started_at: at, duration_ms: None, sidechain, error: false });
97 }
98
99 pub fn close(&mut self, id: &str, at: SystemTime, error: bool) {
102 let Some(s) = self.spans.iter_mut().rev().find(|s| s.is_open() && s.id == id) else { return };
103 s.duration_ms = Some(at.duration_since(s.started_at).map(|d| d.as_millis() as u64).unwrap_or(0));
104 s.error = error;
105 }
106
107 pub fn len(&self) -> usize {
108 self.spans.len()
109 }
110
111 pub fn is_empty(&self) -> bool {
112 self.spans.is_empty()
113 }
114
115 pub fn iter(&self) -> impl DoubleEndedIterator<Item = &ToolSpan> + ExactSizeIterator {
118 self.spans.iter()
119 }
120
121 pub fn to_vec(&self) -> Vec<ToolSpan> {
122 self.spans.iter().cloned().collect()
123 }
124}
125
126pub trait SessionTracker {
127 fn refresh(&mut self) -> anyhow::Result<bool>;
130 fn summary(&self) -> &SessionSummary;
131 fn path(&self) -> &Path;
132}
133
134pub const REFRESH_BUDGET_BYTES: usize = 8 * 1024 * 1024;
137
138pub fn parse_rfc3339_utc(s: &str) -> Option<SystemTime> {
142 let s = s.strip_suffix('Z')?;
143 let (date, time) = s.split_once('T')?;
144 let mut d = date.split('-');
145 let (y, mo, da) = (d.next()?.parse::<i64>().ok()?, d.next()?.parse::<u32>().ok()?, d.next()?.parse::<u32>().ok()?);
146 let mut t = time.split(':');
147 let (h, mi) = (t.next()?.parse::<u64>().ok()?, t.next()?.parse::<u64>().ok()?);
148 let sec_str = t.next()?;
149 let (sec, frac) = match sec_str.split_once('.') {
150 Some((s, f)) => (s.parse::<u64>().ok()?, f),
151 None => (sec_str.parse::<u64>().ok()?, ""),
152 };
153 let nanos: u32 = if frac.is_empty() {
154 0
155 } else {
156 let mut f = frac.to_string();
157 f.truncate(9);
158 while f.len() < 9 {
159 f.push('0');
160 }
161 f.parse().ok()?
162 };
163 let days = days_from_civil(y, mo, da);
164 let secs = days * 86_400 + (h * 3600 + mi * 60 + sec) as i64;
165 if secs < 0 {
166 return None;
167 }
168 Some(SystemTime::UNIX_EPOCH + std::time::Duration::new(secs as u64, nanos))
169}
170
171fn days_from_civil(y: i64, m: u32, d: u32) -> i64 {
173 let y = if m <= 2 { y - 1 } else { y };
174 let era = if y >= 0 { y } else { y - 399 } / 400;
175 let yoe = y - era * 400;
176 let mp = (m as i64 + 9) % 12;
177 let doy = (153 * mp + 2) / 5 + d as i64 - 1;
178 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
179 era * 146_097 + doe - 719_468
180}
181
182#[cfg(test)]
183mod tests {
184 use super::*;
185 use std::time::Duration;
186
187 fn at(secs: u64) -> SystemTime {
188 SystemTime::UNIX_EPOCH + Duration::from_secs(secs)
189 }
190
191 #[test]
192 fn pairs_spans_by_id_out_of_order() {
193 let mut log = SpanLog::default();
194 log.open("a".into(), "Bash".into(), at(10), false);
195 log.open("b".into(), "Read".into(), at(11), true);
196 log.open("a".into(), "Bash".into(), at(10), false);
198 log.close("b", at(12), false);
200 log.close("a", at(14), true);
201 log.close("zzz", at(15), false);
203 let v = log.to_vec();
204 assert_eq!(v.len(), 2);
205 assert_eq!(v[0].name, "Bash");
206 assert_eq!(v[0].duration_ms, Some(4_000));
207 assert!(v[0].error);
208 assert_eq!(v[1].duration_ms, Some(1_000));
209 assert!(v[1].sidechain);
210 assert!(!v[1].error);
211 }
212
213 #[test]
214 fn keeps_the_newest_spans_and_reports_open_ones() {
215 let mut log = SpanLog::default();
216 for i in 0..(MAX_SPANS + 10) {
217 log.open(format!("id{i}"), "T".into(), at(i as u64), false);
218 log.close(&format!("id{i}"), at(i as u64), false);
219 }
220 assert_eq!(log.len(), MAX_SPANS);
221 assert_eq!(log.iter().next().unwrap().id, "id10");
222 log.open("live".into(), "Bash".into(), at(500), false);
223 let last = log.to_vec().pop().unwrap();
224 assert!(last.is_open());
225 assert_eq!(last.elapsed_ms(at(503)), 3_000);
226 }
227
228 #[test]
229 fn accuses_the_parser_only_with_enough_evidence() {
230 let h = ParseHealth { billable_messages: 40, usage_records: 40, empty_usage_records: 0 };
232 assert!(!h.fields_unrecognised());
233 let h = ParseHealth { billable_messages: 40, usage_records: 40, empty_usage_records: 39 };
235 assert!(!h.fields_unrecognised());
236 let h = ParseHealth { billable_messages: 40, usage_records: 40, empty_usage_records: 40 };
238 assert!(h.fields_unrecognised());
239 let h = ParseHealth { billable_messages: 40, usage_records: 0, empty_usage_records: 0 };
242 assert!(h.fields_unrecognised());
243 let h = ParseHealth { billable_messages: 2, usage_records: 0, empty_usage_records: 0 };
245 assert!(!h.fields_unrecognised());
246 assert!(!ParseHealth::default().fields_unrecognised());
248 }
249
250 #[test]
251 fn parses_timestamps() {
252 let t = parse_rfc3339_utc("1970-01-02T00:00:00.000Z").unwrap();
253 assert_eq!(t, SystemTime::UNIX_EPOCH + Duration::from_secs(86_400));
254 let t = parse_rfc3339_utc("2026-09-03T07:15:34.5Z").unwrap();
255 let secs = t.duration_since(SystemTime::UNIX_EPOCH).unwrap();
256 assert_eq!(secs.as_secs(), 1_788_419_734);
257 assert_eq!(secs.subsec_millis(), 500);
258 assert!(parse_rfc3339_utc("nope").is_none());
259 }
260}