Skip to main content

agent_doc_core/
log_time.rs

1//! # Module: log_time
2//!
3//! Human-readable log timestamps (`#opslogts`). Operators read the supervisor
4//! session log and `.agent-doc/logs/ops.log` to verify reported issues, and a
5//! bare Unix epoch like `1781771180` is not legible. This module formats every
6//! log entry's timestamp as ISO-8601 UTC and parses it back, with **no external
7//! date dependency** (Howard Hinnant's civil-date algorithms).
8//!
9//! ## Spec
10//! - [`format_log_timestamp`] renders a Unix epoch (seconds) as
11//!   `YYYY-MM-DDTHH:MM:SSZ` (UTC).
12//! - [`parse_log_timestamp`] is the matching reader: it accepts **either** a
13//!   bare Unix epoch (pre-`#opslogts` log lines and cycles.jsonl timestamps) or
14//!   an ISO-8601 UTC string and returns epoch seconds. This backward
15//!   compatibility keeps the staleness / accretion / startup-miss /
16//!   `gate_verify` scanners working across the format switch — old `[<epoch>]`
17//!   lines still parse, and `parse(format(x)) == x` round-trips.
18//!
19//! ## Agentic Contracts
20//! - Pure and deterministic for unit testing; no I/O, no clock reads.
21//! - `parse_log_timestamp` fails closed (`None`) on garbage rather than mapping
22//!   it to `0`, so a malformed line is skipped, not mis-windowed.
23
24/// Format a Unix epoch (seconds) as a human-readable ISO-8601 UTC timestamp
25/// `YYYY-MM-DDTHH:MM:SSZ` (`#opslogts`).
26pub fn format_log_timestamp(secs: u64) -> String {
27    let days = (secs / 86_400) as i64;
28    let tod = secs % 86_400;
29    let (hh, mm, ss) = (tod / 3_600, (tod % 3_600) / 60, tod % 60);
30
31    // civil_from_days: days since 1970-01-01 -> (year, month, day).
32    let z = days + 719_468;
33    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
34    let doe = (z - era * 146_097) as u64; // [0, 146096]
35    let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365; // [0, 399]
36    let y = yoe as i64 + era * 400;
37    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
38    let mp = (5 * doy + 2) / 153; // [0, 11]
39    let d = doy - (153 * mp + 2) / 5 + 1; // [1, 31]
40    let m = if mp < 10 { mp + 3 } else { mp - 9 }; // [1, 12]
41    let year = if m <= 2 { y + 1 } else { y };
42
43    format!("{year:04}-{m:02}-{d:02}T{hh:02}:{mm:02}:{ss:02}Z")
44}
45
46/// Parse a log timestamp that is either a bare Unix epoch (seconds) or an
47/// ISO-8601 UTC string `YYYY-MM-DDTHH:MM:SSZ`, returning epoch seconds
48/// (`#opslogts`). The trailing `Z` is optional. Returns `None` on anything that
49/// is neither, so the staleness/gate scanners skip a malformed line.
50pub fn parse_log_timestamp(raw: &str) -> Option<u64> {
51    let s = raw.trim();
52    if let Ok(epoch) = s.parse::<u64>() {
53        return Some(epoch);
54    }
55    let s = s.strip_suffix('Z').unwrap_or(s);
56    let (date, time) = s.split_once('T')?;
57    let mut dp = date.split('-');
58    let year: i64 = dp.next()?.parse().ok()?;
59    let month: u64 = dp.next()?.parse().ok()?;
60    let day: u64 = dp.next()?.parse().ok()?;
61    if dp.next().is_some() || !(1..=12).contains(&month) || !(1..=31).contains(&day) {
62        return None;
63    }
64    let mut tp = time.split(':');
65    let hh: u64 = tp.next()?.parse().ok()?;
66    let mm: u64 = tp.next()?.parse().ok()?;
67    let ss: u64 = tp.next()?.parse().ok()?;
68    if tp.next().is_some() || hh > 23 || mm > 59 || ss > 60 {
69        return None;
70    }
71
72    // days_from_civil: (year, month, day) -> days since 1970-01-01.
73    let y = if month <= 2 { year - 1 } else { year };
74    let era = if y >= 0 { y } else { y - 399 } / 400;
75    let yoe = (y - era * 400) as u64; // [0, 399]
76    let mp = if month > 2 { month - 3 } else { month + 9 }; // [0, 11]
77    let doy = (153 * mp + 2) / 5 + day - 1; // [0, 365]
78    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; // [0, 146096]
79    let days = era * 146_097 + doe as i64 - 719_468;
80    let secs = days * 86_400 + (hh * 3_600 + mm * 60 + ss) as i64;
81    u64::try_from(secs).ok()
82}
83
84#[cfg(test)]
85mod tests {
86    use super::*;
87
88    #[test]
89    fn format_matches_known_iso8601_utc() {
90        // 1781771180 = 2026-06-18T08:26:20Z UTC (the #tsiftmdcrash SIGTERM moment).
91        assert_eq!(format_log_timestamp(1_781_771_180), "2026-06-18T08:26:20Z");
92        assert_eq!(format_log_timestamp(0), "1970-01-01T00:00:00Z");
93        assert_eq!(format_log_timestamp(1_709_164_800), "2024-02-29T00:00:00Z");
94    }
95
96    #[test]
97    fn parse_accepts_epoch_and_iso_and_round_trips() {
98        // Backward-compat: bare epoch (pre-#opslogts log lines) still parse.
99        assert_eq!(parse_log_timestamp("1781771180"), Some(1_781_771_180));
100        // ISO-8601 UTC parses back to the same epoch.
101        assert_eq!(
102            parse_log_timestamp("2026-06-18T08:26:20Z"),
103            Some(1_781_771_180)
104        );
105        // Round-trip across a range of epochs, including a leap day.
106        for secs in [0_u64, 1, 1_709_164_800, 1_781_915_847, 4_102_444_800] {
107            assert_eq!(
108                parse_log_timestamp(&format_log_timestamp(secs)),
109                Some(secs),
110                "round-trip failed for epoch {secs}"
111            );
112        }
113        // Garbage is rejected, not silently mapped to 0.
114        assert_eq!(parse_log_timestamp("not-a-timestamp"), None);
115        assert_eq!(parse_log_timestamp("2026-13-01T00:00:00Z"), None);
116    }
117}