Skip to main content

agent_runtime/doctor/
version_alignment.rs

1//! `agent-runtime doctor --class version-alignment` — surface-pin drift gate.
2//!
3//! Answers a single question: *is the host `agent-runtime` still on the
4//! tag a downstream snapshot pinned, and do the downstream-consumed CLIs
5//! still meet their version floors?* Unlike the existing version probe —
6//! which treats "host ahead of floor" as OK — this class blocks on **any**
7//! deviation from `pinned_tag` (ahead or behind), because a silent
8//! `brew upgrade` past the pin is exactly the failure mode it guards.
9//!
10//! It is a version-number gate only. It does not diff surfaces between two
11//! tags, nor query a registry for newer releases.
12
13use super::version::Version;
14use super::{DoctorFinding, DoctorSeverity};
15use serde::{Deserialize, Serialize};
16use std::collections::BTreeMap;
17use std::path::{Path, PathBuf};
18use thiserror::Error;
19
20pub const CLASS: &str = "version-alignment";
21pub const PIN_SCHEMA_VERSION: u32 = 1;
22pub const HOST_CHECK: &str = "version-alignment.host";
23pub const REQUIRED_CLI_CHECK: &str = "version-alignment.required-cli";
24pub const ACCEPTANCE_BOUNDARY: &str = "version-number gate only; does not diff surfaces between tags or query a registry for newer releases";
25
26/// Strawman pin manifest (`<pin-spec>`), parsed from YAML or JSON.
27#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
28pub struct PinManifest {
29    pub schema_version: u32,
30    pub nils_cli: NilsCliPin,
31    #[serde(default)]
32    pub required_clis: Vec<RequiredCli>,
33}
34
35#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
36pub struct NilsCliPin {
37    /// Tag `agent-runtime --version` must report, e.g. `v0.17.7`.
38    pub pinned_tag: String,
39}
40
41#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
42pub struct RequiredCli {
43    pub bin: String,
44    pub min: String,
45}
46
47/// Pure inputs to [`evaluate`]; the I/O layer gathers `--version` outputs.
48pub struct AlignmentInputs<'a> {
49    pub manifest: &'a PinManifest,
50    /// Raw host version string (e.g. compiled `CARGO_PKG_VERSION`).
51    pub host_raw: &'a str,
52    /// `bin` -> raw `<bin> --version` output (or an error string if missing).
53    pub required_raw: &'a BTreeMap<String, String>,
54}
55
56#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
57pub struct VersionAlignmentReport {
58    pub pinned_tag: String,
59    #[serde(skip_serializing_if = "Option::is_none")]
60    pub host_observed: Option<String>,
61    pub items: Vec<AlignmentItem>,
62    pub findings: Vec<DoctorFinding>,
63    #[serde(skip_serializing_if = "Option::is_none")]
64    pub acceptance_boundary: Option<String>,
65}
66
67#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
68pub struct AlignmentItem {
69    pub check: &'static str,
70    pub target: String,
71    pub expected: String,
72    #[serde(skip_serializing_if = "Option::is_none")]
73    pub observed: Option<String>,
74    pub severity: DoctorSeverity,
75}
76
77/// Classify host + required-CLI alignment. Pure; no process spawning or I/O.
78pub fn evaluate(inputs: &AlignmentInputs) -> VersionAlignmentReport {
79    let manifest = inputs.manifest;
80    let mut items = Vec::new();
81    let mut findings = Vec::new();
82
83    // Host check: any deviation from `pinned_tag` blocks; unparseable
84    // input (manifest or host) fails closed.
85    let pinned = Version::parse(&manifest.nils_cli.pinned_tag);
86    let host = Version::parse(inputs.host_raw);
87    let host_observed = host.map(|v| v.to_string());
88    let (host_severity, host_message) = match (pinned, host) {
89        (None, _) => (
90            DoctorSeverity::Block,
91            format!(
92                "pinned_tag `{}` is not a parseable version",
93                manifest.nils_cli.pinned_tag
94            ),
95        ),
96        (Some(_), None) => (
97            DoctorSeverity::Block,
98            format!(
99                "host version `{}` is not parseable; cannot verify alignment",
100                inputs.host_raw
101            ),
102        ),
103        (Some(pin), Some(found)) if found == pin => (
104            DoctorSeverity::Ok,
105            format!(
106                "host is aligned with pinned {}",
107                manifest.nils_cli.pinned_tag
108            ),
109        ),
110        (Some(_), Some(found)) => (
111            DoctorSeverity::Block,
112            format!(
113                "host {} drifted from pinned {}",
114                found, manifest.nils_cli.pinned_tag
115            ),
116        ),
117    };
118    items.push(AlignmentItem {
119        check: HOST_CHECK,
120        target: "agent-runtime".to_string(),
121        expected: manifest.nils_cli.pinned_tag.clone(),
122        observed: host_observed.clone(),
123        severity: host_severity,
124    });
125    if host_severity != DoctorSeverity::Ok {
126        findings.push(DoctorFinding::block(
127            "host",
128            HOST_CHECK,
129            None,
130            None,
131            host_message,
132        ));
133    }
134
135    // Required CLIs: each must meet its `min` floor (existing >= semantics).
136    for cli in &manifest.required_clis {
137        let raw = inputs
138            .required_raw
139            .get(&cli.bin)
140            .map(String::as_str)
141            .unwrap_or("");
142        let min = Version::parse(&cli.min);
143        let observed = Version::parse(raw);
144        let observed_str = observed.map(|v| v.to_string());
145        let (severity, message) = match (min, observed) {
146            (None, _) => (
147                DoctorSeverity::Block,
148                format!(
149                    "required_clis[{}].min `{}` is not a parseable version",
150                    cli.bin, cli.min
151                ),
152            ),
153            (Some(_), None) => (
154                DoctorSeverity::Block,
155                format!(
156                    "{} not found on PATH or `--version` unparseable: {:?}",
157                    cli.bin, raw
158                ),
159            ),
160            (Some(floor), Some(found)) if found >= floor => (
161                DoctorSeverity::Ok,
162                format!("{} {} meets floor {}", cli.bin, found, cli.min),
163            ),
164            (Some(_), Some(found)) => (
165                DoctorSeverity::Block,
166                format!("{} {} is below required floor {}", cli.bin, found, cli.min),
167            ),
168        };
169        items.push(AlignmentItem {
170            check: REQUIRED_CLI_CHECK,
171            target: cli.bin.clone(),
172            expected: cli.min.clone(),
173            observed: observed_str,
174            severity,
175        });
176        if severity != DoctorSeverity::Ok {
177            findings.push(DoctorFinding::block(
178                &cli.bin,
179                REQUIRED_CLI_CHECK,
180                None,
181                None,
182                message,
183            ));
184        }
185    }
186
187    VersionAlignmentReport {
188        pinned_tag: manifest.nils_cli.pinned_tag.clone(),
189        host_observed,
190        items,
191        findings,
192        acceptance_boundary: Some(ACCEPTANCE_BOUNDARY.to_string()),
193    }
194}
195
196#[derive(Debug, Error)]
197pub enum VersionAlignmentError {
198    #[error("--class version-alignment requires --pin <manifest>")]
199    MissingPin,
200    #[error("missing pin manifest: {path}")]
201    Missing { path: PathBuf },
202    #[error("io error reading {path}: {source}")]
203    Io {
204        path: PathBuf,
205        #[source]
206        source: std::io::Error,
207    },
208    #[error("parse error in {path}: {source}")]
209    Parse {
210        path: PathBuf,
211        #[source]
212        source: serde_yaml_ng::Error,
213    },
214    #[error("schema_version mismatch in {path}: expected {expected}, got {found}")]
215    SchemaVersion {
216        path: PathBuf,
217        expected: u32,
218        found: u32,
219    },
220}
221
222/// Read + parse the pin manifest (YAML or JSON), gather `<bin> --version`
223/// for each `required_clis[]` entry from PATH, then classify alignment of
224/// `host_version` and those binaries via [`evaluate`].
225pub fn check(
226    pin_path: &Path,
227    host_version: &str,
228) -> Result<VersionAlignmentReport, VersionAlignmentError> {
229    if !pin_path.exists() {
230        return Err(VersionAlignmentError::Missing {
231            path: pin_path.to_path_buf(),
232        });
233    }
234    let raw = std::fs::read_to_string(pin_path).map_err(|source| VersionAlignmentError::Io {
235        path: pin_path.to_path_buf(),
236        source,
237    })?;
238    let manifest: PinManifest =
239        serde_yaml_ng::from_str(&raw).map_err(|source| VersionAlignmentError::Parse {
240            path: pin_path.to_path_buf(),
241            source,
242        })?;
243    if manifest.schema_version != PIN_SCHEMA_VERSION {
244        return Err(VersionAlignmentError::SchemaVersion {
245            path: pin_path.to_path_buf(),
246            expected: PIN_SCHEMA_VERSION,
247            found: manifest.schema_version,
248        });
249    }
250
251    let mut required_raw = BTreeMap::new();
252    for cli in &manifest.required_clis {
253        let raw = super::version::run_probe_command(&format!("{} --version", cli.bin));
254        required_raw.insert(cli.bin.clone(), raw);
255    }
256
257    Ok(evaluate(&AlignmentInputs {
258        manifest: &manifest,
259        host_raw: host_version,
260        required_raw: &required_raw,
261    }))
262}
263
264#[cfg(test)]
265mod tests {
266    use super::*;
267
268    fn manifest(pinned: &str, required: &[(&str, &str)]) -> PinManifest {
269        PinManifest {
270            schema_version: PIN_SCHEMA_VERSION,
271            nils_cli: NilsCliPin {
272                pinned_tag: pinned.to_string(),
273            },
274            required_clis: required
275                .iter()
276                .map(|(bin, min)| RequiredCli {
277                    bin: bin.to_string(),
278                    min: min.to_string(),
279                })
280                .collect(),
281        }
282    }
283
284    fn eval(m: &PinManifest, host_raw: &str, reqs: &[(&str, &str)]) -> VersionAlignmentReport {
285        let required_raw: BTreeMap<String, String> = reqs
286            .iter()
287            .map(|(bin, raw)| (bin.to_string(), raw.to_string()))
288            .collect();
289        evaluate(&AlignmentInputs {
290            manifest: m,
291            host_raw,
292            required_raw: &required_raw,
293        })
294    }
295
296    #[test]
297    fn aligned_host_is_ok() {
298        let m = manifest("v0.17.7", &[]);
299        let report = eval(&m, "agent-runtime 0.17.7 (v0.17.7, rustc 1.96.0)", &[]);
300        assert_eq!(report.items.len(), 1);
301        assert_eq!(report.items[0].check, HOST_CHECK);
302        assert_eq!(report.items[0].severity, DoctorSeverity::Ok);
303        assert!(
304            report.findings.is_empty(),
305            "findings: {:?}",
306            report.findings
307        );
308    }
309
310    #[test]
311    fn host_ahead_of_pin_blocks() {
312        // The core new capability: the existing probe treats this as OK.
313        let m = manifest("v0.17.6", &[]);
314        let report = eval(&m, "agent-runtime 0.17.7 (v0.17.7)", &[]);
315        assert_eq!(report.items[0].severity, DoctorSeverity::Block);
316        assert_eq!(report.findings.len(), 1);
317        assert_eq!(report.findings[0].severity, DoctorSeverity::Block);
318        assert_eq!(report.findings[0].check, HOST_CHECK);
319    }
320
321    #[test]
322    fn host_behind_pin_blocks() {
323        let m = manifest("v0.18.0", &[]);
324        let report = eval(&m, "agent-runtime 0.17.7", &[]);
325        assert_eq!(report.items[0].severity, DoctorSeverity::Block);
326        assert_eq!(report.findings.len(), 1);
327    }
328
329    #[test]
330    fn host_unparseable_blocks_fail_closed() {
331        let m = manifest("v0.17.7", &[]);
332        let report = eval(&m, "agent-runtime (no version available)", &[]);
333        assert_eq!(report.items[0].severity, DoctorSeverity::Block);
334    }
335
336    #[test]
337    fn required_cli_meets_min_is_ok() {
338        let m = manifest("v0.17.7", &[("plan-issue", "0.17.4")]);
339        let report = eval(
340            &m,
341            "0.17.7",
342            &[("plan-issue", "plan-issue 0.17.7 (v0.17.7)")],
343        );
344        assert_eq!(report.items.len(), 2);
345        assert!(
346            report
347                .items
348                .iter()
349                .all(|i| i.severity == DoctorSeverity::Ok),
350            "items: {:?}",
351            report.items
352        );
353        assert!(report.findings.is_empty());
354    }
355
356    #[test]
357    fn required_cli_below_min_blocks() {
358        let m = manifest("v0.17.7", &[("forge-cli", "0.16.0")]);
359        let report = eval(&m, "0.17.7", &[("forge-cli", "forge-cli 0.15.0 (v0.15.0)")]);
360        let item = report
361            .items
362            .iter()
363            .find(|i| i.target == "forge-cli")
364            .expect("forge-cli item present");
365        assert_eq!(item.check, REQUIRED_CLI_CHECK);
366        assert_eq!(item.severity, DoctorSeverity::Block);
367        assert!(report.findings.iter().any(|f| f.product == "forge-cli"));
368    }
369
370    #[test]
371    fn required_cli_missing_blocks() {
372        let m = manifest("v0.17.7", &[("ghost-cli", "0.1.0")]);
373        let report = eval(
374            &m,
375            "0.17.7",
376            &[(
377                "ghost-cli",
378                "failed to run `ghost-cli --version`: No such file or directory (os error 2)",
379            )],
380        );
381        let item = report
382            .items
383            .iter()
384            .find(|i| i.target == "ghost-cli")
385            .expect("ghost-cli item present");
386        assert_eq!(item.severity, DoctorSeverity::Block);
387    }
388}