Skip to main content

agent_doc_log_time/
lib.rs

1//! # Module: log_time
2//!
3//! Human-readable log timestamps (`#opslogts`) and low-level ops-log line
4//! formatting. Operators read the supervisor session log and
5//! `.agent-doc/logs/ops.log` to verify reported issues, and a bare Unix epoch
6//! like `1781771180` is not legible. This module formats every log entry's
7//! timestamp as ISO-8601 UTC and parses it back, with **no external date
8//! dependency** (Howard Hinnant's civil-date algorithms).
9//!
10//! ## Spec
11//! - [`format_log_timestamp`] renders a Unix epoch (seconds) as
12//!   `YYYY-MM-DDTHH:MM:SSZ` (UTC).
13//! - [`parse_log_timestamp`] is the matching reader: it accepts **either** a
14//!   bare Unix epoch (pre-`#opslogts` log lines and cycles.jsonl timestamps) or
15//!   an ISO-8601 UTC string and returns epoch seconds. This backward
16//!   compatibility keeps the staleness / accretion / startup-miss /
17//!   `gate_verify` scanners working across the format switch — old `[<epoch>]`
18//!   lines still parse, and `parse(format(x)) == x` round-trips.
19//! - [`current_log_timestamp`] samples the system clock and returns the current
20//!   timestamp in the same ISO-8601 UTC format.
21//! - [`format_ops_log_tracking_suffix`] renders the appended
22//!   ` doc=<stem>[ session=<id>][ turn=<cycle_id>]` suffix without knowing how
23//!   callers found those values.
24//! - [`format_ops_log_line`] renders the final bracketed ops-log line.
25//!
26//! ## Agentic Contracts
27//! - Formatting/parsing helpers are pure and deterministic for unit testing; no
28//!   I/O and no orchestration dependencies.
29//! - Clock helpers fail closed to Unix epoch `0` when the system clock is before
30//!   `UNIX_EPOCH`.
31//! - `parse_log_timestamp` fails closed (`None`) on garbage rather than mapping
32//!   it to `0`, so a malformed line is skipped, not mis-windowed.
33
34use std::{
35    process::Command,
36    time::{SystemTime, UNIX_EPOCH},
37};
38
39/// Return the current Unix epoch seconds, defaulting to `0` when the system
40/// clock is earlier than `UNIX_EPOCH`.
41pub fn current_epoch_secs() -> u64 {
42    SystemTime::now()
43        .duration_since(UNIX_EPOCH)
44        .map(|d| d.as_secs())
45        .unwrap_or_default()
46}
47
48/// Return the current timestamp in the log format `YYYY-MM-DDTHH:MM:SSZ`.
49pub fn current_log_timestamp() -> String {
50    format_log_timestamp(current_epoch_secs())
51}
52
53/// Return the current timestamp in a compact human display format
54/// `YYYY-MM-DD HH:MM:SS` (UTC).
55pub fn current_human_timestamp() -> String {
56    format_human_timestamp(current_epoch_secs())
57}
58
59/// Return the current local date as `YYYY-MM-DD` using the platform `date`
60/// command, falling back to `unknown-date` when it is unavailable.
61///
62/// Archive headings historically used local operator dates, not UTC log dates.
63/// Keep that behavior here without adding a chrono dependency.
64pub fn current_local_date_ymd() -> String {
65    Command::new("date")
66        .args(["+%Y-%m-%d"])
67        .output()
68        .ok()
69        .and_then(|o| trimmed_date_stdout(&o.stdout))
70        .unwrap_or_else(|| "unknown-date".to_string())
71}
72
73fn trimmed_date_stdout(stdout: &[u8]) -> Option<String> {
74    let trimmed = String::from_utf8_lossy(stdout).trim().to_string();
75    (!trimmed.is_empty()).then_some(trimmed)
76}
77
78/// Format a Unix epoch (seconds) as a human-readable ISO-8601 UTC timestamp
79/// `YYYY-MM-DDTHH:MM:SSZ` (`#opslogts`).
80pub fn format_log_timestamp(secs: u64) -> String {
81    let days = (secs / 86_400) as i64;
82    let tod = secs % 86_400;
83    let (hh, mm, ss) = (tod / 3_600, (tod % 3_600) / 60, tod % 60);
84
85    // civil_from_days: days since 1970-01-01 -> (year, month, day).
86    let z = days + 719_468;
87    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
88    let doe = (z - era * 146_097) as u64; // [0, 146096]
89    let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365; // [0, 399]
90    let y = yoe as i64 + era * 400;
91    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
92    let mp = (5 * doy + 2) / 153; // [0, 11]
93    let d = doy - (153 * mp + 2) / 5 + 1; // [1, 31]
94    let m = if mp < 10 { mp + 3 } else { mp - 9 }; // [1, 12]
95    let year = if m <= 2 { y + 1 } else { y };
96
97    format!("{year:04}-{m:02}-{d:02}T{hh:02}:{mm:02}:{ss:02}Z")
98}
99
100/// Format a Unix epoch (seconds) as `YYYY-MM-DD HH:MM:SS` (UTC).
101pub fn format_human_timestamp(secs: u64) -> String {
102    let iso = format_log_timestamp(secs);
103    format!("{} {}", &iso[..10], &iso[11..19])
104}
105
106/// Render the `#opslogtrack` suffix appended to each ops-log line.
107///
108/// The caller owns data collection. This helper only preserves the wire format:
109/// ` doc=<stem>[ session=<id>][ turn=<cycle_id>]`.
110pub fn format_ops_log_tracking_suffix(
111    doc_stem: Option<&str>,
112    session: Option<&str>,
113    turn: Option<&str>,
114) -> String {
115    let mut suffix = String::new();
116    if let Some(stem) = doc_stem {
117        suffix.push_str(" doc=");
118        suffix.push_str(stem);
119    }
120    if let Some(session) = session.filter(|session| !session.is_empty()) {
121        suffix.push_str(" session=");
122        suffix.push_str(session);
123    }
124    if let Some(turn) = turn.filter(|turn| !turn.is_empty()) {
125        suffix.push_str(" turn=");
126        suffix.push_str(turn);
127    }
128    suffix
129}
130
131/// Render a full bracketed `.agent-doc/logs/ops.log` line.
132pub fn format_ops_log_line(timestamp_secs: u64, message: &str, tracking_suffix: &str) -> String {
133    format!(
134        "[{}] {}{}",
135        format_log_timestamp(timestamp_secs),
136        message,
137        tracking_suffix
138    )
139}
140
141/// Parse a log timestamp that is either a bare Unix epoch (seconds) or an
142/// ISO-8601 UTC string `YYYY-MM-DDTHH:MM:SSZ`, returning epoch seconds
143/// (`#opslogts`). The trailing `Z` is optional. Returns `None` on anything that
144/// is neither, so the staleness/gate scanners skip a malformed line.
145pub fn parse_log_timestamp(raw: &str) -> Option<u64> {
146    let s = raw.trim();
147    if let Ok(epoch) = s.parse::<u64>() {
148        return Some(epoch);
149    }
150    let s = s.strip_suffix('Z').unwrap_or(s);
151    let (date, time) = s.split_once('T')?;
152    let mut dp = date.split('-');
153    let year: i64 = dp.next()?.parse().ok()?;
154    let month: u64 = dp.next()?.parse().ok()?;
155    let day: u64 = dp.next()?.parse().ok()?;
156    if dp.next().is_some() || !(1..=12).contains(&month) || !(1..=31).contains(&day) {
157        return None;
158    }
159    let mut tp = time.split(':');
160    let hh: u64 = tp.next()?.parse().ok()?;
161    let mm: u64 = tp.next()?.parse().ok()?;
162    let ss: u64 = tp.next()?.parse().ok()?;
163    if tp.next().is_some() || hh > 23 || mm > 59 || ss > 60 {
164        return None;
165    }
166
167    // days_from_civil: (year, month, day) -> days since 1970-01-01.
168    let y = if month <= 2 { year - 1 } else { year };
169    let era = if y >= 0 { y } else { y - 399 } / 400;
170    let yoe = (y - era * 400) as u64; // [0, 399]
171    let mp = if month > 2 { month - 3 } else { month + 9 }; // [0, 11]
172    let doy = (153 * mp + 2) / 5 + day - 1; // [0, 365]
173    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; // [0, 146096]
174    let days = era * 146_097 + doe as i64 - 719_468;
175    let secs = days * 86_400 + (hh * 3_600 + mm * 60 + ss) as i64;
176    u64::try_from(secs).ok()
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182
183    #[test]
184    fn format_matches_known_iso8601_utc() {
185        // 1781771180 = 2026-06-18T08:26:20Z UTC (the #tsiftmdcrash SIGTERM moment).
186        assert_eq!(format_log_timestamp(1_781_771_180), "2026-06-18T08:26:20Z");
187        assert_eq!(format_log_timestamp(0), "1970-01-01T00:00:00Z");
188        assert_eq!(format_log_timestamp(1_709_164_800), "2024-02-29T00:00:00Z");
189    }
190
191    #[test]
192    fn human_timestamp_preserves_space_separated_shape() {
193        assert_eq!(format_human_timestamp(1_781_771_180), "2026-06-18 08:26:20");
194        assert_eq!(format_human_timestamp(0), "1970-01-01 00:00:00");
195    }
196
197    #[test]
198    fn local_date_stdout_trims_and_rejects_empty_output() {
199        assert_eq!(
200            trimmed_date_stdout(b"2026-07-02\n"),
201            Some("2026-07-02".to_string())
202        );
203        assert_eq!(trimmed_date_stdout(b"\n"), None);
204    }
205
206    #[test]
207    fn current_local_date_ymd_has_archive_shape() {
208        let date = current_local_date_ymd();
209        assert!(
210            date == "unknown-date"
211                || (date.len() == 10 && date.as_bytes()[4] == b'-' && date.as_bytes()[7] == b'-'),
212            "unexpected archive date shape: {date:?}"
213        );
214    }
215
216    #[test]
217    fn parse_accepts_epoch_and_iso_and_round_trips() {
218        // Backward-compat: bare epoch (pre-#opslogts log lines) still parse.
219        assert_eq!(parse_log_timestamp("1781771180"), Some(1_781_771_180));
220        // ISO-8601 UTC parses back to the same epoch.
221        assert_eq!(
222            parse_log_timestamp("2026-06-18T08:26:20Z"),
223            Some(1_781_771_180)
224        );
225        // Round-trip across a range of epochs, including a leap day.
226        for secs in [0_u64, 1, 1_709_164_800, 1_781_915_847, 4_102_444_800] {
227            assert_eq!(
228                parse_log_timestamp(&format_log_timestamp(secs)),
229                Some(secs),
230                "round-trip failed for epoch {secs}"
231            );
232        }
233        // Garbage is rejected, not silently mapped to 0.
234        assert_eq!(parse_log_timestamp("not-a-timestamp"), None);
235        assert_eq!(parse_log_timestamp("2026-13-01T00:00:00Z"), None);
236    }
237
238    #[test]
239    fn current_log_timestamp_is_parseable() {
240        let timestamp = current_log_timestamp();
241        assert!(
242            parse_log_timestamp(&timestamp).is_some(),
243            "current timestamp should parse: {timestamp:?}"
244        );
245    }
246
247    #[test]
248    fn tracking_suffix_preserves_ops_log_wire_format() {
249        assert_eq!(
250            format_ops_log_tracking_suffix(Some("plan"), Some("sess-1"), Some("turn-2")),
251            " doc=plan session=sess-1 turn=turn-2"
252        );
253        assert_eq!(
254            format_ops_log_tracking_suffix(Some("plan"), None, Some("")),
255            " doc=plan"
256        );
257    }
258
259    #[test]
260    fn ops_log_line_uses_bracketed_iso_timestamp() {
261        assert_eq!(
262            format_ops_log_line(0, "event happened", " doc=plan"),
263            "[1970-01-01T00:00:00Z] event happened doc=plan"
264        );
265    }
266}