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;
8pub mod gemini;
9pub mod opencode;
10
11use crate::model::{Activity, Attribution, CostBreakdown, Harness, ProcNode, SpanKind, TokenUsage, ToolSpan};
12use crate::process::RawProc;
13use std::collections::{BTreeMap, HashSet, VecDeque};
14use std::path::{Path, PathBuf};
15use std::time::{Duration, SystemTime};
16
17/// Evidence that the parser still understands the file it is reading.
18///
19/// Every field is read with a fallback to zero, which is the right behaviour
20/// for a genuinely absent field and the wrong behaviour for a renamed one: a
21/// harness that renames `usage` next week would show a user 0 tokens and $0.00
22/// with no error at all. So count the usage records seen and how many of them
23/// yielded nothing. Records present and all of them empty is not a quiet
24/// session, it is a parser that has fallen behind the format.
25#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
26pub struct ParseHealth {
27    /// Model responses seen. Each of these should account for some tokens.
28    pub billable_messages: u64,
29    /// Usage records found on them. Zero of these, with messages present, means
30    /// the record itself moved or was renamed.
31    pub usage_records: u64,
32    /// Records found but yielding nothing, which is what a renamed field inside
33    /// an intact record looks like.
34    pub empty_usage_records: u64,
35}
36
37impl ParseHealth {
38    /// Enough responses to accuse the parser rather than the session. A couple
39    /// of odd messages must not raise the alarm.
40    const MIN_EVIDENCE: u64 = 3;
41
42    /// The session did work that must have cost tokens, and we read none.
43    ///
44    /// Covers both ways a format change reaches us: the usage record moving or
45    /// being renamed, so we never find one, and the fields inside it being
46    /// renamed, so we find records that read as empty. Neither raises an error
47    /// on its own, because every field falls back to zero.
48    pub fn fields_unrecognised(&self) -> bool {
49        self.billable_messages >= Self::MIN_EVIDENCE && self.usage_records == self.empty_usage_records
50    }
51}
52
53#[derive(Debug, Clone, Default)]
54pub struct SessionSummary {
55    pub harness: Option<Harness>,
56    pub session_id: Option<String>,
57    pub cwd: Option<PathBuf>,
58    pub model: Option<String>,
59    pub harness_version: Option<String>,
60    pub usage: TokenUsage,
61    pub cost_usd: f64,
62    /// `cost_usd` by kind of token. See `Agent::cost_breakdown`.
63    pub cost_breakdown: CostBreakdown,
64    pub unpriced_tokens: u64,
65    pub turns: u64,
66    pub subagent_turns: u64,
67    pub tool_calls: u64,
68    /// See `Agent::web_searches`.
69    pub web_searches: u64,
70    pub spans: SpanLog,
71    /// Calls to each MCP server, by the server's name.
72    pub mcp: BTreeMap<String, McpUsage>,
73    pub health: ParseHealth,
74    pub activity: Activity,
75    pub started_at: Option<SystemTime>,
76    pub last_activity: Option<SystemTime>,
77    /// How close the session is to its rate limit, when the harness writes it.
78    pub rate_limit: Option<crate::model::RateLimit>,
79}
80
81/// What a transcript says about one MCP server: how often it was called,
82/// how often that failed, and when it was last called.
83#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
84pub struct McpUsage {
85    pub calls: u64,
86    pub errors: u64,
87    pub last_call: Option<SystemTime>,
88}
89
90impl McpUsage {
91    pub fn add(&mut self, o: &McpUsage) {
92        self.calls += o.calls;
93        self.errors += o.errors;
94        self.last_call = self.last_call.max(o.last_call);
95    }
96}
97
98/// The server behind an MCP tool name. Claude Code names them
99/// `mcp__<server>__<tool>`; the server part may itself contain underscores,
100/// so the split is on the first double underscore after the prefix and the
101/// tool part is whatever follows the last one.
102pub fn mcp_server_of(tool_name: &str) -> Option<&str> {
103    let rest = tool_name.strip_prefix("mcp__")?;
104    let server = match rest.rfind("__") {
105        Some(i) => &rest[..i],
106        None => rest,
107    };
108    if server.is_empty() { None } else { Some(server) }
109}
110
111/// Spans kept per session by the live tracker. A screenful of waterfall is
112/// a few dozen rows; the rest is history nobody scrolls to in a live view, and
113/// every span costs a clone on each refresh. An export wants the whole session
114/// and uses `SpanLog::unbounded` in a separate pass; see `SpanRetention`.
115///
116/// Sized for roughly a hundred tool calls: each model response also adds an
117/// inference span and each human prompt a turn span.
118pub const MAX_SPANS: usize = 256;
119
120/// A bounded, in-order log of tool spans, built by pairing a harness's
121/// "call started" and "call finished" records by call id.
122///
123/// Records arrive interleaved and out of order (agents run tools in parallel),
124/// so a span is closed by searching back for the still-open span with that id
125/// rather than assuming the most recent one.
126#[derive(Debug, Clone)]
127pub struct SpanLog {
128    spans: VecDeque<ToolSpan>,
129    cap: usize,
130}
131
132impl Default for SpanLog {
133    fn default() -> Self {
134        SpanLog { spans: VecDeque::new(), cap: MAX_SPANS }
135    }
136}
137
138impl SpanLog {
139    /// A log that keeps every span. For a one-shot pass over a whole
140    /// transcript, never for the live tracker, where the memory and the clone
141    /// per refresh would grow with the session.
142    pub fn unbounded() -> Self {
143        SpanLog { spans: VecDeque::new(), cap: usize::MAX }
144    }
145
146    /// Record the start of a tool call. Ignored when that id is already open,
147    /// so a transcript line replayed by the harness does not double-count.
148    pub fn open(&mut self, id: String, name: String, at: SystemTime, sidechain: bool) {
149        self.open_kind(id, name, at, sidechain, SpanKind::Tool);
150    }
151
152    /// `open`, for any kind of span.
153    pub fn open_kind(&mut self, id: String, name: String, at: SystemTime, sidechain: bool, kind: SpanKind) {
154        if id.is_empty() || self.spans.iter().any(|s| s.is_open() && s.id == id) {
155            return;
156        }
157        if self.spans.len() >= self.cap {
158            self.spans.pop_front();
159        }
160        self.spans.push_back(ToolSpan { id, name, started_at: at, duration_ms: None, sidechain, error: false, kind });
161    }
162
163    /// Move the end of the newest span with this id to `at`, open or not. An
164    /// inference span grows as the response streams in, one content block
165    /// per line, and its end is wherever the last block landed.
166    pub fn end_at(&mut self, id: &str, at: SystemTime) {
167        let Some(s) = self.spans.iter_mut().rev().find(|s| s.id == id) else { return };
168        s.duration_ms = Some(at.duration_since(s.started_at).map(|d| d.as_millis() as u64).unwrap_or(0));
169    }
170
171    /// The newest open span of this kind, if any.
172    pub fn open_of_kind(&self, kind: SpanKind) -> Option<&ToolSpan> {
173        self.spans.iter().rev().find(|s| s.is_open() && s.kind == kind)
174    }
175
176    /// Remove the newest span with this id if it is still open. For a span
177    /// that turned out not to be one: an inference that never produced a
178    /// reply because the user interrupted or submitted again.
179    pub fn discard_open(&mut self, id: &str) {
180        if let Some(i) = self.spans.iter().rposition(|s| s.is_open() && s.id == id) {
181            self.spans.remove(i);
182        }
183    }
184
185    /// Close the open call with this id. A result whose call scrolled out of
186    /// the window, or that we never saw start, is dropped.
187    pub fn close(&mut self, id: &str, at: SystemTime, error: bool) {
188        let Some(s) = self.spans.iter_mut().rev().find(|s| s.is_open() && s.id == id) else { return };
189        s.duration_ms = Some(at.duration_since(s.started_at).map(|d| d.as_millis() as u64).unwrap_or(0));
190        s.error = error;
191    }
192
193    pub fn len(&self) -> usize {
194        self.spans.len()
195    }
196
197    pub fn is_empty(&self) -> bool {
198        self.spans.is_empty()
199    }
200
201    /// Oldest first. Double-ended so callers can take the newest spans without
202    /// collecting the whole log first, which the UI and the golden tests both do.
203    pub fn iter(&self) -> impl DoubleEndedIterator<Item = &ToolSpan> + ExactSizeIterator {
204        self.spans.iter()
205    }
206
207    pub fn to_vec(&self) -> Vec<ToolSpan> {
208        self.spans.iter().cloned().collect()
209    }
210
211    /// One log from several, ordered by start time, keeping the newest `cap`.
212    /// A parent's spans and its subagents' spans interleave in wall-clock
213    /// order, which is what a waterfall wants.
214    pub fn merged<'a>(logs: impl IntoIterator<Item = &'a SpanLog>, cap: usize) -> SpanLog {
215        let mut spans: Vec<ToolSpan> = logs.into_iter().flat_map(|l| l.spans.iter().cloned()).collect();
216        spans.sort_by_key(|s| s.started_at);
217        if spans.len() > cap {
218            spans.drain(..spans.len() - cap);
219        }
220        SpanLog { spans: spans.into(), cap }
221    }
222
223    pub fn cap(&self) -> usize {
224        self.cap
225    }
226}
227
228/// How many of a session's tool spans a tracker keeps.
229#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
230pub enum SpanRetention {
231    /// The newest `MAX_SPANS`, enough for the live waterfall. The default.
232    #[default]
233    Recent,
234    /// Every span in the transcript, for a trace export. Memory grows with
235    /// the session, so this is for a single pass, not a tracker kept across
236    /// refreshes.
237    All,
238}
239
240impl SpanRetention {
241    pub(crate) fn log(self) -> SpanLog {
242        match self {
243            SpanRetention::Recent => SpanLog::default(),
244            SpanRetention::All => SpanLog::unbounded(),
245        }
246    }
247}
248
249pub trait SessionTracker {
250    /// Ingest whatever was appended since the last call. Returns true when
251    /// there is still unread data (the byte budget was exhausted).
252    fn refresh(&mut self) -> anyhow::Result<bool>;
253    fn summary(&self) -> &SessionSummary;
254    fn path(&self) -> &Path;
255
256    /// Ingest the whole file, however many refreshes that takes. For a
257    /// one-shot read such as an export; the live collector spreads a large
258    /// transcript over several ticks instead.
259    fn refresh_all(&mut self) -> anyhow::Result<()> {
260        while self.refresh()? {}
261        Ok(())
262    }
263}
264
265/// What a harness's own registry says about one of its processes, when it
266/// keeps one (Claude Code's `~/.claude/sessions/<pid>.json`). Every field is
267/// optional; a harness with no registry returns none of this.
268#[derive(Debug, Clone, Default, PartialEq, Eq)]
269pub struct RegistryHints {
270    pub name: Option<String>,
271    pub session_id: Option<String>,
272    pub cwd: Option<PathBuf>,
273    pub version: Option<String>,
274    /// The harness's own word for its state (`busy`, `idle`, ...), which beats
275    /// any transcript heuristic.
276    pub status: Option<String>,
277}
278
279/// What the collector knows about a process when it asks an adapter which
280/// transcript is the process's.
281pub struct AttributeContext<'a> {
282    pub cwd: Option<&'a Path>,
283    pub proc_start: SystemTime,
284    pub now: SystemTime,
285    /// Transcripts already given to another process this pass. An adapter
286    /// must not hand one out twice.
287    pub attached: &'a HashSet<PathBuf>,
288    /// A transcript idle for longer than this is a finished conversation, not
289    /// a thread of a process that cannot otherwise be matched.
290    pub activity_timeout: Duration,
291}
292
293/// One harness, as the collector sees it: where its transcripts are, which
294/// belongs to which process, and how to read one. The collector holds a list
295/// of these and never names a harness itself, so adding a harness is one
296/// module and one line in `adapters()`. Process recognition stays in
297/// `process::classify_agent`, which also knows the harnesses that have no
298/// transcript adapter yet.
299pub trait HarnessAdapter {
300    fn harness(&self) -> Harness;
301
302    /// Re-list the transcripts written since `since`. Called every
303    /// `fs_scan_interval`, not every tick.
304    fn rescan(&mut self, since: SystemTime);
305
306    /// Called once per pass with this harness's root processes, before any
307    /// of them is attributed. For work that must see every process at once:
308    /// Codex reads which rollouts each process holds open here, so that no
309    /// process's fallback can claim a thread another is demonstrably writing.
310    fn prepare(&mut self, _roots: &[&ProcNode]) {}
311
312    /// The harness's own registry entry for a process, if it keeps one.
313    fn hints(&self, _pid: u32) -> Option<RegistryHints> {
314        None
315    }
316
317    /// The transcripts this process is writing, newest activity first, and
318    /// how sure the adapter is. One per conversation: a Codex app-server hosts
319    /// many, a CLI runs one, a process with none gets an empty list.
320    fn attribute(&self, root: &ProcNode, raw: Option<&RawProc>, ctx: &AttributeContext) -> (Vec<PathBuf>, Attribution);
321
322    /// Recently written transcripts no process owns: the stopped list.
323    fn unowned(&self, attached: &HashSet<PathBuf>) -> Vec<PathBuf>;
324
325    /// A tracker for one transcript.
326    fn open(&self, path: &Path, spans: SpanRetention) -> Box<dyn SessionTracker>;
327
328    /// Whether this harness wrote the file, judged from its first few lines.
329    fn detect(&self, path: &Path) -> bool;
330
331    /// Every transcript on disk, however old, with the id a user would type
332    /// to name it. For `agent-top trace --session <id>`.
333    fn transcripts(&self) -> Vec<(String, PathBuf)>;
334}
335
336/// Every harness that has a transcript adapter, in the order they are asked.
337/// The order matters to `detect` alone: Gemini's metadata line carries a
338/// `sessionId` like Claude Code's lines do, so it is asked first.
339pub fn adapters() -> Vec<Box<dyn HarnessAdapter>> {
340    vec![
341        Box::new(codex::CodexAdapter::default()),
342        Box::new(gemini::GeminiAdapter::default()),
343        Box::new(opencode::OpenCodeAdapter::default()),
344        Box::new(claude::ClaudeAdapter::default()),
345    ]
346}
347
348/// The adapter for one harness, or none when it has only a process table entry.
349pub fn adapter_for(harness: Harness) -> Option<Box<dyn HarnessAdapter>> {
350    adapters().into_iter().find(|a| a.harness() == harness)
351}
352
353/// Which harness wrote a transcript, judged from its first few lines. Anything
354/// no adapter recognises is not a transcript agent-top reads.
355pub fn detect(path: &Path) -> Option<Harness> {
356    adapters().iter().find(|a| a.detect(path)).map(|a| a.harness())
357}
358
359/// A tracker for a transcript whose harness is already known, or none when
360/// that harness has no transcript adapter.
361pub fn open_transcript(path: &Path, harness: Harness, spans: SpanRetention) -> Option<Box<dyn SessionTracker>> {
362    adapter_for(harness).map(|a| a.open(path, spans))
363}
364
365/// The first few lines of a file, parsed, for `HarnessAdapter::detect`.
366pub(crate) fn head_lines(path: &Path) -> Vec<serde_json::Value> {
367    use std::io::{BufRead, BufReader};
368    let Ok(f) = std::fs::File::open(path) else { return Vec::new() };
369    BufReader::new(f).lines().map_while(Result::ok).take(5).filter_map(|l| serde_json::from_str(&l).ok()).collect()
370}
371
372/// Bytes ingested per tracker per refresh. Keeps a cold start on a 100 MB
373/// transcript from freezing the first frame; the rest streams in on later ticks.
374pub const REFRESH_BUDGET_BYTES: usize = 8 * 1024 * 1024;
375
376/// Parse an RFC 3339 timestamp like `2026-09-03T07:15:34.123Z` into SystemTime
377/// without pulling in a date crate. Only the UTC `Z` form is handled, which is
378/// what both Claude Code and Codex write.
379pub fn parse_rfc3339_utc(s: &str) -> Option<SystemTime> {
380    let s = s.strip_suffix('Z')?;
381    let (date, time) = s.split_once('T')?;
382    let mut d = date.split('-');
383    let (y, mo, da) = (d.next()?.parse::<i64>().ok()?, d.next()?.parse::<u32>().ok()?, d.next()?.parse::<u32>().ok()?);
384    let mut t = time.split(':');
385    let (h, mi) = (t.next()?.parse::<u64>().ok()?, t.next()?.parse::<u64>().ok()?);
386    let sec_str = t.next()?;
387    let (sec, frac) = match sec_str.split_once('.') {
388        Some((s, f)) => (s.parse::<u64>().ok()?, f),
389        None => (sec_str.parse::<u64>().ok()?, ""),
390    };
391    let nanos: u32 = if frac.is_empty() {
392        0
393    } else {
394        let mut f = frac.to_string();
395        f.truncate(9);
396        while f.len() < 9 {
397            f.push('0');
398        }
399        f.parse().ok()?
400    };
401    let days = days_from_civil(y, mo, da);
402    let secs = days * 86_400 + (h * 3600 + mi * 60 + sec) as i64;
403    if secs < 0 {
404        return None;
405    }
406    Some(SystemTime::UNIX_EPOCH + std::time::Duration::new(secs as u64, nanos))
407}
408
409// Howard Hinnant's days-from-civil algorithm.
410fn days_from_civil(y: i64, m: u32, d: u32) -> i64 {
411    let y = if m <= 2 { y - 1 } else { y };
412    let era = if y >= 0 { y } else { y - 399 } / 400;
413    let yoe = y - era * 400;
414    let mp = (m as i64 + 9) % 12;
415    let doy = (153 * mp + 2) / 5 + d as i64 - 1;
416    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
417    era * 146_097 + doe - 719_468
418}
419
420#[cfg(test)]
421mod tests {
422    use super::*;
423    use std::time::Duration;
424
425    fn at(secs: u64) -> SystemTime {
426        SystemTime::UNIX_EPOCH + Duration::from_secs(secs)
427    }
428
429    #[test]
430    fn pairs_spans_by_id_out_of_order() {
431        let mut log = SpanLog::default();
432        log.open("a".into(), "Bash".into(), at(10), false);
433        log.open("b".into(), "Read".into(), at(11), true);
434        // Replay of the same start line must not open a second span.
435        log.open("a".into(), "Bash".into(), at(10), false);
436        // Results come back in the other order.
437        log.close("b", at(12), false);
438        log.close("a", at(14), true);
439        // A result with no matching call is ignored.
440        log.close("zzz", at(15), false);
441        let v = log.to_vec();
442        assert_eq!(v.len(), 2);
443        assert_eq!(v[0].name, "Bash");
444        assert_eq!(v[0].duration_ms, Some(4_000));
445        assert!(v[0].error);
446        assert_eq!(v[1].duration_ms, Some(1_000));
447        assert!(v[1].sidechain);
448        assert!(!v[1].error);
449    }
450
451    #[test]
452    fn keeps_the_newest_spans_and_reports_open_ones() {
453        let mut log = SpanLog::default();
454        for i in 0..(MAX_SPANS + 10) {
455            log.open(format!("id{i}"), "T".into(), at(i as u64), false);
456            log.close(&format!("id{i}"), at(i as u64), false);
457        }
458        assert_eq!(log.len(), MAX_SPANS);
459        assert_eq!(log.iter().next().unwrap().id, "id10");
460        log.open("live".into(), "Bash".into(), at(500), false);
461        let last = log.to_vec().pop().unwrap();
462        assert!(last.is_open());
463        assert_eq!(last.elapsed_ms(at(503)), 3_000);
464    }
465
466    #[test]
467    fn end_at_moves_the_end_of_any_kind_of_span() {
468        let mut log = SpanLog::default();
469        log.open_kind("inference:1".into(), "inference".into(), at(10), false, SpanKind::Inference);
470        assert!(log.open_of_kind(SpanKind::Inference).is_some());
471        assert!(log.open_of_kind(SpanKind::Turn).is_none());
472        // The response streams in over three lines; the span ends at the last one.
473        log.end_at("inference:1", at(11));
474        log.end_at("inference:1", at(13));
475        log.end_at("nope", at(99));
476        let v = log.to_vec();
477        assert_eq!(v[0].duration_ms, Some(3_000));
478        assert_eq!(v[0].kind, SpanKind::Inference);
479        assert!(log.open_of_kind(SpanKind::Inference).is_none());
480        // Discarding only removes open spans; the ended one stays.
481        log.discard_open("inference:1");
482        assert_eq!(log.len(), 1);
483        log.open_kind("inference:2".into(), "inference".into(), at(20), false, SpanKind::Inference);
484        log.discard_open("inference:2");
485        assert_eq!(log.len(), 1);
486    }
487
488    #[test]
489    fn unbounded_log_keeps_everything() {
490        let mut log = SpanLog::unbounded();
491        for i in 0..(MAX_SPANS * 3) {
492            log.open(format!("id{i}"), "T".into(), at(i as u64), false);
493            log.close(&format!("id{i}"), at(i as u64 + 1), false);
494        }
495        assert_eq!(log.len(), MAX_SPANS * 3);
496        assert_eq!(log.iter().next().unwrap().id, "id0");
497        assert_eq!(SpanRetention::default(), SpanRetention::Recent);
498    }
499
500    #[test]
501    fn detects_the_harness_from_the_first_lines() {
502        let dir = std::env::temp_dir().join(format!("agent-top-detect-{}", std::process::id()));
503        std::fs::create_dir_all(&dir).unwrap();
504        let codex = dir.join("rollout.jsonl");
505        std::fs::write(&codex, "{\"type\":\"session_meta\",\"payload\":{\"id\":\"x\"}}\n").unwrap();
506        let claude = dir.join("s.jsonl");
507        // A summary line first, as Claude Code writes on resume, then a real one.
508        std::fs::write(&claude, "{\"type\":\"summary\",\"leafUuid\":\"u\"}\n{\"type\":\"user\",\"sessionId\":\"abc\"}\n").unwrap();
509        let other = dir.join("other.jsonl");
510        std::fs::write(&other, "{\"hello\":1}\nnot json\n").unwrap();
511        assert_eq!(detect(&codex), Some(Harness::Codex));
512        assert_eq!(detect(&claude), Some(Harness::Claude));
513        assert_eq!(detect(&other), None);
514        assert_eq!(detect(&dir.join("missing.jsonl")), None);
515        let _ = std::fs::remove_dir_all(&dir);
516    }
517
518    #[test]
519    fn names_the_server_behind_an_mcp_tool() {
520        assert_eq!(mcp_server_of("mcp__filesystem__read_file"), Some("filesystem"));
521        assert_eq!(mcp_server_of("mcp__chrome-devtools__take_screenshot"), Some("chrome-devtools"));
522        assert_eq!(mcp_server_of("mcp__claude_ai_Gmail__authenticate"), Some("claude_ai_Gmail"));
523        assert_eq!(mcp_server_of("mcp__odd"), Some("odd"));
524        assert_eq!(mcp_server_of("mcp____x"), None);
525        assert_eq!(mcp_server_of("Bash"), None);
526    }
527
528    #[test]
529    fn accuses_the_parser_only_with_enough_evidence() {
530        // Healthy: records found on the messages, tokens read from them.
531        let h = ParseHealth { billable_messages: 40, usage_records: 40, empty_usage_records: 0 };
532        assert!(!h.fields_unrecognised());
533        // One odd message among many is a message, not a format change.
534        let h = ParseHealth { billable_messages: 40, usage_records: 40, empty_usage_records: 39 };
535        assert!(!h.fields_unrecognised());
536        // Fields inside the record renamed: records found, all of them empty.
537        let h = ParseHealth { billable_messages: 40, usage_records: 40, empty_usage_records: 40 };
538        assert!(h.fields_unrecognised());
539        // The record itself renamed or moved: messages, but no records at all.
540        // This is the case a naive check misses, because there is nothing to count.
541        let h = ParseHealth { billable_messages: 40, usage_records: 0, empty_usage_records: 0 };
542        assert!(h.fields_unrecognised());
543        // Too early to tell: a session that has barely started.
544        let h = ParseHealth { billable_messages: 2, usage_records: 0, empty_usage_records: 0 };
545        assert!(!h.fields_unrecognised());
546        // Nothing parsed at all is silence, not evidence.
547        assert!(!ParseHealth::default().fields_unrecognised());
548    }
549
550    #[test]
551    fn parses_timestamps() {
552        let t = parse_rfc3339_utc("1970-01-02T00:00:00.000Z").unwrap();
553        assert_eq!(t, SystemTime::UNIX_EPOCH + Duration::from_secs(86_400));
554        let t = parse_rfc3339_utc("2026-09-03T07:15:34.5Z").unwrap();
555        let secs = t.duration_since(SystemTime::UNIX_EPOCH).unwrap();
556        assert_eq!(secs.as_secs(), 1_788_419_734);
557        assert_eq!(secs.subsec_millis(), 500);
558        assert!(parse_rfc3339_utc("nope").is_none());
559    }
560}