Skip to main content

agent_runtime/doctor/
version.rs

1//! Product version probes for `agent-runtime doctor`.
2
3use super::{DoctorFinding, DoctorSeverity};
4use crate::render::manifest::ProductRoot;
5use std::cmp::Ordering;
6use std::process::{Command, Output, Stdio};
7use std::time::{Duration, SystemTime, UNIX_EPOCH};
8
9const PROBE_TIMEOUT_POLLS: usize = 50;
10const PROBE_TIMEOUT_SLEEP: Duration = Duration::from_millis(100);
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum VersionStatus {
14    Ok,
15    RecommendedOnly,
16    Warn,
17    Outdated,
18    Unparseable,
19}
20
21impl VersionStatus {
22    pub fn as_str(self) -> &'static str {
23        match self {
24            VersionStatus::Ok => "ok",
25            VersionStatus::RecommendedOnly => "recommended-only",
26            VersionStatus::Warn => "warn",
27            VersionStatus::Outdated => "outdated",
28            VersionStatus::Unparseable => "unparseable",
29        }
30    }
31}
32
33#[derive(Debug, Clone)]
34pub struct VersionProbeInput {
35    pub product: String,
36    pub command: String,
37    pub min_version: String,
38    pub recommended_version: String,
39    pub min_version_effective_from: String,
40    pub raw_output: String,
41    pub today: String,
42}
43
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub struct VersionProbeFinding {
46    pub product: String,
47    pub command: String,
48    pub status: VersionStatus,
49    pub severity: DoctorSeverity,
50    pub parsed_version: Option<String>,
51    pub raw_output: String,
52    pub message: String,
53}
54
55impl VersionProbeFinding {
56    pub fn to_doctor_finding(&self) -> DoctorFinding {
57        let message = if self.status == VersionStatus::Unparseable {
58            format!(
59                "status={} command=`{}` raw_output={:?}",
60                self.status.as_str(),
61                self.command,
62                self.raw_output
63            )
64        } else {
65            format!(
66                "status={} parsed={} command=`{}`: {}",
67                self.status.as_str(),
68                self.parsed_version.as_deref().unwrap_or("unknown"),
69                self.command,
70                self.message
71            )
72        };
73
74        match self.severity {
75            DoctorSeverity::Ok => DoctorFinding {
76                product: self.product.clone(),
77                check: "version-probe",
78                severity: DoctorSeverity::Ok,
79                entry_id: None,
80                path: None,
81                message,
82            },
83            DoctorSeverity::Warn => {
84                DoctorFinding::warn(&self.product, "version-probe", None, None, message)
85            }
86            DoctorSeverity::Block => {
87                DoctorFinding::block(&self.product, "version-probe", None, None, message)
88            }
89        }
90    }
91}
92
93#[derive(Debug, Clone, Copy, PartialEq, Eq)]
94pub(crate) struct Version {
95    major: u64,
96    minor: u64,
97    patch: u64,
98}
99
100impl Version {
101    pub(crate) fn parse(raw: &str) -> Option<Self> {
102        let bytes = raw.as_bytes();
103        for i in 0..bytes.len() {
104            if !(bytes[i].is_ascii_digit()
105                || (bytes[i] == b'v' && bytes.get(i + 1).is_some_and(u8::is_ascii_digit)))
106            {
107                continue;
108            }
109            let start = if bytes[i] == b'v' { i + 1 } else { i };
110            if start > 0 {
111                let prev = bytes[start - 1];
112                if prev.is_ascii_alphanumeric() && prev != b'v' {
113                    continue;
114                }
115            }
116            if let Some((version, _end)) = parse_at(bytes, start) {
117                return Some(version);
118            }
119        }
120        None
121    }
122}
123
124impl Ord for Version {
125    fn cmp(&self, other: &Self) -> Ordering {
126        (self.major, self.minor, self.patch).cmp(&(other.major, other.minor, other.patch))
127    }
128}
129
130impl PartialOrd for Version {
131    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
132        Some(self.cmp(other))
133    }
134}
135
136impl std::fmt::Display for Version {
137    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
138        write!(f, "{}.{}.{}", self.major, self.minor, self.patch)
139    }
140}
141
142pub fn probe_product(product: &str, root: &ProductRoot) -> VersionProbeFinding {
143    let raw_output = run_probe_command(&root.version_probe);
144    classify(VersionProbeInput {
145        product: product.to_string(),
146        command: root.version_probe.clone(),
147        min_version: root.min_version.clone(),
148        recommended_version: root.recommended_version.clone(),
149        min_version_effective_from: root.min_version_effective_from.clone(),
150        raw_output,
151        today: today_utc(),
152    })
153}
154
155pub fn classify(input: VersionProbeInput) -> VersionProbeFinding {
156    let Some(parsed) = Version::parse(&input.raw_output) else {
157        return finding(
158            input,
159            VersionStatus::Unparseable,
160            DoctorSeverity::Warn,
161            None,
162            "version output could not be parsed",
163        );
164    };
165
166    let Some(minimum) = Version::parse(&input.min_version) else {
167        return finding(
168            input,
169            VersionStatus::Unparseable,
170            DoctorSeverity::Warn,
171            Some(parsed),
172            "min_version could not be parsed",
173        );
174    };
175    let Some(recommended) = Version::parse(&input.recommended_version) else {
176        return finding(
177            input,
178            VersionStatus::Unparseable,
179            DoctorSeverity::Warn,
180            Some(parsed),
181            "recommended_version could not be parsed",
182        );
183    };
184
185    if parsed >= recommended {
186        return finding(
187            input,
188            VersionStatus::Ok,
189            DoctorSeverity::Ok,
190            Some(parsed),
191            "version meets the recommended floor",
192        );
193    }
194
195    if parsed >= minimum {
196        return finding(
197            input,
198            VersionStatus::RecommendedOnly,
199            DoctorSeverity::Warn,
200            Some(parsed),
201            "version meets the minimum floor but is below the recommended floor",
202        );
203    }
204
205    if effective_date_has_passed(&input.today, &input.min_version_effective_from) {
206        finding(
207            input,
208            VersionStatus::Outdated,
209            DoctorSeverity::Block,
210            Some(parsed),
211            "version is below the minimum floor after the effective date",
212        )
213    } else {
214        finding(
215            input,
216            VersionStatus::Warn,
217            DoctorSeverity::Warn,
218            Some(parsed),
219            "version is below the minimum floor before the effective date",
220        )
221    }
222}
223
224fn finding(
225    input: VersionProbeInput,
226    status: VersionStatus,
227    severity: DoctorSeverity,
228    parsed_version: Option<Version>,
229    message: &str,
230) -> VersionProbeFinding {
231    VersionProbeFinding {
232        product: input.product,
233        command: input.command,
234        status,
235        severity,
236        parsed_version: parsed_version.map(|v| v.to_string()),
237        raw_output: input.raw_output,
238        message: message.to_string(),
239    }
240}
241
242pub(crate) fn run_probe_command(command: &str) -> String {
243    run_probe_command_with_timeout(command, PROBE_TIMEOUT_POLLS, PROBE_TIMEOUT_SLEEP)
244}
245
246fn run_probe_command_with_timeout(command: &str, polls: usize, sleep: Duration) -> String {
247    let mut parts = command.split_whitespace();
248    let Some(program) = parts.next() else {
249        return String::new();
250    };
251    let mut child = match Command::new(program)
252        .args(parts)
253        .stdout(Stdio::piped())
254        .stderr(Stdio::piped())
255        .spawn()
256    {
257        Ok(child) => child,
258        Err(source) => return format!("failed to run `{command}`: {source}"),
259    };
260
261    for _ in 0..polls {
262        match child.try_wait() {
263            Ok(Some(_)) => {
264                return child
265                    .wait_with_output()
266                    .map(output_to_raw)
267                    .unwrap_or_else(|source| {
268                        format!("failed to read `{command}` output: {source}")
269                    });
270            }
271            Ok(None) => std::thread::sleep(sleep),
272            Err(source) => {
273                let _ = child.kill();
274                return format!("failed to wait for `{command}`: {source}");
275            }
276        }
277    }
278
279    let timeout_ms = polls as u128 * sleep.as_millis();
280    let _ = child.kill();
281    child
282        .wait_with_output()
283        .map(|output| {
284            let mut raw = output_to_raw(output);
285            if !raw.is_empty() {
286                raw.push('\n');
287            }
288            raw.push_str(&format!("timed_out_after_ms={timeout_ms}"));
289            raw
290        })
291        .unwrap_or_else(|source| format!("timed out running `{command}`; kill failed: {source}"))
292}
293
294fn output_to_raw(output: Output) -> String {
295    let mut raw = String::new();
296    raw.push_str(&String::from_utf8_lossy(&output.stdout));
297    raw.push_str(&String::from_utf8_lossy(&output.stderr));
298    if !output.status.success() {
299        raw.push_str(&format!("\nexit_status={}", output.status));
300    }
301    raw.trim().to_string()
302}
303
304fn parse_at(bytes: &[u8], mut i: usize) -> Option<(Version, usize)> {
305    let (major, next) = parse_number(bytes, i)?;
306    i = next;
307    if bytes.get(i) != Some(&b'.') {
308        return None;
309    }
310    let (minor, next) = parse_number(bytes, i + 1)?;
311    i = next;
312    if bytes.get(i) != Some(&b'.') {
313        return None;
314    }
315    let (patch, next) = parse_number(bytes, i + 1)?;
316    if bytes
317        .get(next)
318        .is_some_and(|b| b.is_ascii_digit() || b.is_ascii_alphabetic() || *b == b'_')
319    {
320        return None;
321    }
322    Some((
323        Version {
324            major,
325            minor,
326            patch,
327        },
328        next,
329    ))
330}
331
332fn parse_number(bytes: &[u8], mut i: usize) -> Option<(u64, usize)> {
333    let start = i;
334    let mut value = 0u64;
335    while let Some(byte) = bytes.get(i) {
336        if !byte.is_ascii_digit() {
337            break;
338        }
339        value = value
340            .saturating_mul(10)
341            .saturating_add(u64::from(byte - b'0'));
342        i += 1;
343    }
344    (i > start).then_some((value, i))
345}
346
347fn effective_date_has_passed(today: &str, effective_from: &str) -> bool {
348    valid_date(today) && valid_date(effective_from) && today >= effective_from
349}
350
351fn valid_date(value: &str) -> bool {
352    let bytes = value.as_bytes();
353    bytes.len() == 10
354        && bytes[0..4].iter().all(u8::is_ascii_digit)
355        && bytes[4] == b'-'
356        && bytes[5..7].iter().all(u8::is_ascii_digit)
357        && bytes[7] == b'-'
358        && bytes[8..10].iter().all(u8::is_ascii_digit)
359}
360
361fn today_utc() -> String {
362    if let Ok(today) = std::env::var("AGENT_RUNTIME_DOCTOR_TODAY")
363        && valid_date(&today)
364    {
365        return today;
366    }
367
368    // Doctor is a read-only host posture command, not part of the render
369    // determinism path. The date gates a published version-floor deadline.
370    #[allow(clippy::disallowed_methods)]
371    let now = SystemTime::now();
372    let days = now.duration_since(UNIX_EPOCH).unwrap_or_default().as_secs() / 86_400;
373    let (year, month, day) = civil_from_days(days as i64);
374    format!("{year:04}-{month:02}-{day:02}")
375}
376
377fn civil_from_days(days_since_unix_epoch: i64) -> (i64, i64, i64) {
378    let z = days_since_unix_epoch + 719_468;
379    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
380    let doe = z - era * 146_097;
381    let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
382    let y = yoe + era * 400;
383    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
384    let mp = (5 * doy + 2) / 153;
385    let day = doy - (153 * mp + 2) / 5 + 1;
386    let month = mp + if mp < 10 { 3 } else { -9 };
387    let year = y + if month <= 2 { 1 } else { 0 };
388    (year, month, day)
389}
390
391#[cfg(test)]
392mod tests {
393    use super::*;
394    use std::fs;
395    use std::os::unix::fs::PermissionsExt;
396    use tempfile::TempDir;
397
398    #[test]
399    fn semver_matcher_tolerates_common_prefixes() {
400        assert_eq!(
401            Version::parse("codex 0.18.2 (build abc1234)").map(|v| v.to_string()),
402            Some("0.18.2".to_string())
403        );
404        assert_eq!(
405            Version::parse("claude-code v2.1.145").map(|v| v.to_string()),
406            Some("2.1.145".to_string())
407        );
408    }
409
410    #[test]
411    fn unix_epoch_date_conversion_is_stable() {
412        assert_eq!(civil_from_days(0), (1970, 1, 1));
413        assert_eq!(civil_from_days(20_229), (2025, 5, 21));
414    }
415
416    #[test]
417    fn version_probe_timeout_is_loud_and_bounded() {
418        let tmp = TempDir::new().unwrap();
419        let script = tmp.path().join("slow-version");
420        fs::write(&script, "#!/usr/bin/env sh\nsleep 10\n").unwrap();
421        let mut perms = fs::metadata(&script).unwrap().permissions();
422        perms.set_mode(0o755);
423        fs::set_permissions(&script, perms).unwrap();
424
425        let raw =
426            run_probe_command_with_timeout(&script.to_string_lossy(), 0, Duration::from_millis(0));
427
428        assert!(
429            raw.contains("timed_out_after_ms=0"),
430            "timeout should be explicit: {raw}"
431        );
432    }
433}