Skip to main content

agent_top_core/harness/
mod.rs

1//! Per-harness transcript readers.
2//!
3//! Each harness writes a different append-only log. A `SessionTracker` turns
4//! one of those logs into the harness-neutral `SessionSummary` incrementally.
5
6pub 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, Default)]
15pub struct SessionSummary {
16    pub harness: Option<Harness>,
17    pub session_id: Option<String>,
18    pub cwd: Option<PathBuf>,
19    pub model: Option<String>,
20    pub harness_version: Option<String>,
21    pub usage: TokenUsage,
22    pub cost_usd: f64,
23    pub unpriced_tokens: u64,
24    pub turns: u64,
25    pub subagent_turns: u64,
26    pub tool_calls: u64,
27    pub spans: SpanLog,
28    pub activity: Activity,
29    pub started_at: Option<SystemTime>,
30    pub last_activity: Option<SystemTime>,
31}
32
33/// Spans kept per session. A screenful of waterfall is a few dozen rows; the
34/// rest is history nobody scrolls to in a live view, and every span costs a
35/// clone on each refresh.
36pub const MAX_SPANS: usize = 128;
37
38/// A bounded, in-order log of tool spans, built by pairing a harness's
39/// "call started" and "call finished" records by call id.
40///
41/// Records arrive interleaved and out of order (agents run tools in parallel),
42/// so a span is closed by searching back for the still-open span with that id
43/// rather than assuming the most recent one.
44#[derive(Debug, Clone, Default)]
45pub struct SpanLog {
46    spans: VecDeque<ToolSpan>,
47}
48
49impl SpanLog {
50    /// Record the start of a call. Ignored when that id is already open, so a
51    /// transcript line replayed by the harness does not double-count.
52    pub fn open(&mut self, id: String, name: String, at: SystemTime, sidechain: bool) {
53        if id.is_empty() || self.spans.iter().any(|s| s.is_open() && s.id == id) {
54            return;
55        }
56        if self.spans.len() == MAX_SPANS {
57            self.spans.pop_front();
58        }
59        self.spans.push_back(ToolSpan { id, name, started_at: at, duration_ms: None, sidechain, error: false });
60    }
61
62    /// Close the open call with this id. A result whose call scrolled out of
63    /// the window, or that we never saw start, is dropped.
64    pub fn close(&mut self, id: &str, at: SystemTime, error: bool) {
65        let Some(s) = self.spans.iter_mut().rev().find(|s| s.is_open() && s.id == id) else { return };
66        s.duration_ms = Some(at.duration_since(s.started_at).map(|d| d.as_millis() as u64).unwrap_or(0));
67        s.error = error;
68    }
69
70    pub fn len(&self) -> usize {
71        self.spans.len()
72    }
73
74    pub fn is_empty(&self) -> bool {
75        self.spans.is_empty()
76    }
77
78    /// Oldest first. Double-ended so callers can take the newest spans without
79    /// collecting the whole log first, which the UI and the golden tests both do.
80    pub fn iter(&self) -> impl DoubleEndedIterator<Item = &ToolSpan> + ExactSizeIterator {
81        self.spans.iter()
82    }
83
84    pub fn to_vec(&self) -> Vec<ToolSpan> {
85        self.spans.iter().cloned().collect()
86    }
87}
88
89pub trait SessionTracker {
90    /// Ingest whatever was appended since the last call. Returns true when
91    /// there is still unread data (the byte budget was exhausted).
92    fn refresh(&mut self) -> anyhow::Result<bool>;
93    fn summary(&self) -> &SessionSummary;
94    fn path(&self) -> &Path;
95}
96
97/// Bytes ingested per tracker per refresh. Keeps a cold start on a 100 MB
98/// transcript from freezing the first frame; the rest streams in on later ticks.
99pub const REFRESH_BUDGET_BYTES: usize = 8 * 1024 * 1024;
100
101/// Parse an RFC 3339 timestamp like `2026-09-03T07:15:34.123Z` into SystemTime
102/// without pulling in a date crate. Only the UTC `Z` form is handled, which is
103/// what both Claude Code and Codex write.
104pub fn parse_rfc3339_utc(s: &str) -> Option<SystemTime> {
105    let s = s.strip_suffix('Z')?;
106    let (date, time) = s.split_once('T')?;
107    let mut d = date.split('-');
108    let (y, mo, da) = (d.next()?.parse::<i64>().ok()?, d.next()?.parse::<u32>().ok()?, d.next()?.parse::<u32>().ok()?);
109    let mut t = time.split(':');
110    let (h, mi) = (t.next()?.parse::<u64>().ok()?, t.next()?.parse::<u64>().ok()?);
111    let sec_str = t.next()?;
112    let (sec, frac) = match sec_str.split_once('.') {
113        Some((s, f)) => (s.parse::<u64>().ok()?, f),
114        None => (sec_str.parse::<u64>().ok()?, ""),
115    };
116    let nanos: u32 = if frac.is_empty() {
117        0
118    } else {
119        let mut f = frac.to_string();
120        f.truncate(9);
121        while f.len() < 9 {
122            f.push('0');
123        }
124        f.parse().ok()?
125    };
126    let days = days_from_civil(y, mo, da);
127    let secs = days * 86_400 + (h * 3600 + mi * 60 + sec) as i64;
128    if secs < 0 {
129        return None;
130    }
131    Some(SystemTime::UNIX_EPOCH + std::time::Duration::new(secs as u64, nanos))
132}
133
134// Howard Hinnant's days-from-civil algorithm.
135fn days_from_civil(y: i64, m: u32, d: u32) -> i64 {
136    let y = if m <= 2 { y - 1 } else { y };
137    let era = if y >= 0 { y } else { y - 399 } / 400;
138    let yoe = y - era * 400;
139    let mp = (m as i64 + 9) % 12;
140    let doy = (153 * mp + 2) / 5 + d as i64 - 1;
141    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
142    era * 146_097 + doe - 719_468
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148    use std::time::Duration;
149
150    fn at(secs: u64) -> SystemTime {
151        SystemTime::UNIX_EPOCH + Duration::from_secs(secs)
152    }
153
154    #[test]
155    fn pairs_spans_by_id_out_of_order() {
156        let mut log = SpanLog::default();
157        log.open("a".into(), "Bash".into(), at(10), false);
158        log.open("b".into(), "Read".into(), at(11), true);
159        // Replay of the same start line must not open a second span.
160        log.open("a".into(), "Bash".into(), at(10), false);
161        // Results come back in the other order.
162        log.close("b", at(12), false);
163        log.close("a", at(14), true);
164        // A result with no matching call is ignored.
165        log.close("zzz", at(15), false);
166        let v = log.to_vec();
167        assert_eq!(v.len(), 2);
168        assert_eq!(v[0].name, "Bash");
169        assert_eq!(v[0].duration_ms, Some(4_000));
170        assert!(v[0].error);
171        assert_eq!(v[1].duration_ms, Some(1_000));
172        assert!(v[1].sidechain);
173        assert!(!v[1].error);
174    }
175
176    #[test]
177    fn keeps_the_newest_spans_and_reports_open_ones() {
178        let mut log = SpanLog::default();
179        for i in 0..(MAX_SPANS + 10) {
180            log.open(format!("id{i}"), "T".into(), at(i as u64), false);
181            log.close(&format!("id{i}"), at(i as u64), false);
182        }
183        assert_eq!(log.len(), MAX_SPANS);
184        assert_eq!(log.iter().next().unwrap().id, "id10");
185        log.open("live".into(), "Bash".into(), at(500), false);
186        let last = log.to_vec().pop().unwrap();
187        assert!(last.is_open());
188        assert_eq!(last.elapsed_ms(at(503)), 3_000);
189    }
190
191    #[test]
192    fn parses_timestamps() {
193        let t = parse_rfc3339_utc("1970-01-02T00:00:00.000Z").unwrap();
194        assert_eq!(t, SystemTime::UNIX_EPOCH + Duration::from_secs(86_400));
195        let t = parse_rfc3339_utc("2026-09-03T07:15:34.5Z").unwrap();
196        let secs = t.duration_since(SystemTime::UNIX_EPOCH).unwrap();
197        assert_eq!(secs.as_secs(), 1_788_419_734);
198        assert_eq!(secs.subsec_millis(), 500);
199        assert!(parse_rfc3339_utc("nope").is_none());
200    }
201}