@@ -2,28 +2,48 @@ use std::collections::HashMap;
22
33use serde:: { Deserialize , Serialize } ;
44
5- use crate :: { ProductUpdateInfo , UpdateProductKey , UpdateSchedule } ;
5+ use crate :: update_manifest:: strip_bom;
6+ use crate :: { ProductUpdateInfo , UPDATE_MANIFEST_V2_MINOR_VERSION , UpdateProductKey , UpdateSchedule , VersionMajorV2 } ;
67
8+ /// Version 2 of the agent status format, written by agent >=2026.2.0.
9+ ///
10+ /// Uses the same major version marker ([`VersionMajorV2`]) as [`crate::UpdateManifestV2`]
11+ /// so both files share the minor-version constant and version numbering scheme.
12+ ///
13+ /// Example:
14+ /// ```json
15+ /// {
16+ /// "VersionMajor": 2,
17+ /// "VersionMinor": 1,
18+ /// "Schedule": { "Enabled": true, "Interval": 86400, "UpdateWindowStart": 7200 },
19+ /// "Products": { "Agent": { "TargetVersion": "2026.2.0" } }
20+ /// }
21+ /// ```
22+ ///
723/// Agent runtime status written to `agent_status.json` on agent start and refreshed
824/// after each updater run or auto-update schedule change.
925///
10- /// The gateway reads this file for `GET /jet/update/schedule` so that it can surface
11- /// current agent state without needing knowledge of the agent's internal `agent.json`
12- /// configuration format.
26+ /// The gateway reads this file for `GET /jet/update` and `GET /jet/update/schedule` so
27+ /// that it can surface current agent state without needing knowledge of the agent's
28+ /// internal `agent.json` configuration format.
1329///
1430/// Unlike [`crate::UpdateManifest`] (`update.json`), this file is **read-only** for
1531/// the Gateway service: its DACL grants NETWORK SERVICE read access but **no write
1632/// access**. The agent is the sole writer.
1733///
1834/// Note: if the agent itself is being updated, `agent_status.json` will be
1935/// automatically refreshed when the agent restarts after the update completes.
20- #[ derive( Debug , Clone , Serialize , Deserialize , Default ) ]
36+ #[ derive( Debug , Clone , Serialize , Deserialize ) ]
2137#[ serde( rename_all = "PascalCase" ) ]
22- pub struct UpdateStatus {
38+ pub struct UpdateStatusV2 {
39+ /// Always `2` — reuses [`VersionMajorV2`] so the version numbering is consistent
40+ /// with [`crate::UpdateManifestV2`].
41+ pub version_major : VersionMajorV2 ,
42+ /// Feature-set version within V2.
43+ pub version_minor : u32 ,
2344 /// Current auto-update schedule configured for this agent.
2445 #[ serde( skip_serializing_if = "Option::is_none" ) ]
2546 pub schedule : Option < UpdateSchedule > ,
26-
2747 /// Map of product name → currently **installed** version.
2848 ///
2949 /// Each entry's `TargetVersion` field holds the installed version of the product,
@@ -32,9 +52,136 @@ pub struct UpdateStatus {
3252 pub products : HashMap < UpdateProductKey , ProductUpdateInfo > ,
3353}
3454
55+ impl Default for UpdateStatusV2 {
56+ fn default ( ) -> Self {
57+ Self {
58+ version_major : VersionMajorV2 ,
59+ version_minor : UPDATE_MANIFEST_V2_MINOR_VERSION ,
60+ schedule : None ,
61+ products : HashMap :: new ( ) ,
62+ }
63+ }
64+ }
65+
66+ /// A parsed agent status file: currently only V2 is defined.
67+ ///
68+ /// Serde variant order is significant: `StatusV2` is tried first; its `VersionMajor`
69+ /// field causes deserialization to fail when the value is not `2`, allowing the untagged
70+ /// enum to fall through to future variants. When V3 is introduced, a `StatusV3`
71+ /// variant is inserted before `StatusV2`.
72+ #[ derive( Debug , Clone , Serialize , Deserialize ) ]
73+ #[ serde( untagged) ]
74+ pub enum UpdateStatus {
75+ /// V2 format: contains `"VersionMajor": 2`.
76+ StatusV2 ( UpdateStatusV2 ) ,
77+ }
78+
3579impl UpdateStatus {
3680 /// Parse `agent_status.json` bytes.
81+ ///
82+ /// Strips a UTF-8 BOM if present before parsing.
3783 pub fn parse ( data : & [ u8 ] ) -> serde_json:: Result < Self > {
38- serde_json:: from_slice ( data)
84+ serde_json:: from_slice ( strip_bom ( data) )
85+ }
86+
87+ /// Return the format version as a `"major.minor"` string (e.g. `"2.1"`).
88+ pub fn version_string ( & self ) -> String {
89+ match self {
90+ Self :: StatusV2 ( v2) => format ! ( "2.{}" , v2. version_minor) ,
91+ }
92+ }
93+
94+ /// Borrow the schedule from whichever version is present.
95+ pub fn schedule ( & self ) -> Option < & UpdateSchedule > {
96+ match self {
97+ Self :: StatusV2 ( v2) => v2. schedule . as_ref ( ) ,
98+ }
99+ }
100+
101+ /// Consume the status and return the product map from whichever version is present.
102+ pub fn into_products ( self ) -> HashMap < UpdateProductKey , ProductUpdateInfo > {
103+ match self {
104+ Self :: StatusV2 ( v2) => v2. products ,
105+ }
106+ }
107+ }
108+
109+ impl Default for UpdateStatus {
110+ fn default ( ) -> Self {
111+ Self :: StatusV2 ( UpdateStatusV2 :: default ( ) )
112+ }
113+ }
114+
115+ #[ cfg( test) ]
116+ mod tests {
117+ #![ allow( clippy:: unwrap_used, reason = "test code can panic on errors" ) ]
118+
119+ use super :: * ;
120+ use crate :: VersionSpecification ;
121+
122+ #[ test]
123+ fn bom_is_stripped ( ) {
124+ // UTF-8 BOM prefix
125+ let mut data = vec ! [ 0xEF , 0xBB , 0xBF ] ;
126+ data. extend_from_slice ( br#"{"VersionMajor":2,"VersionMinor":1}"# ) ;
127+ let status = UpdateStatus :: parse ( & data) . unwrap ( ) ;
128+ assert ! ( matches!( status, UpdateStatus :: StatusV2 ( _) ) ) ;
129+ }
130+
131+ #[ test]
132+ fn v2_minimal_parses ( ) {
133+ let status = UpdateStatus :: parse ( br#"{"VersionMajor":2,"VersionMinor":1}"# ) . unwrap ( ) ;
134+ assert ! ( matches!( status, UpdateStatus :: StatusV2 ( _) ) ) ;
135+ assert ! ( status. schedule( ) . is_none( ) ) ;
136+ assert ! ( status. into_products( ) . is_empty( ) ) ;
137+ }
138+
139+ #[ test]
140+ fn wrong_major_fails ( ) {
141+ assert ! ( UpdateStatus :: parse( br#"{"VersionMajor":1,"VersionMinor":1}"# ) . is_err( ) ) ;
142+ assert ! ( UpdateStatus :: parse( br#"{"VersionMajor":3,"VersionMinor":0}"# ) . is_err( ) ) ;
143+ }
144+
145+ #[ test]
146+ fn v2_with_schedule_roundtrip ( ) {
147+ let json = r#"{"VersionMajor":2,"VersionMinor":1,"Schedule":{"Enabled":true,"Interval":86400,"UpdateWindowStart":7200,"Products":[]}}"# ;
148+ let status = UpdateStatus :: parse ( json. as_bytes ( ) ) . unwrap ( ) ;
149+ let schedule = status. schedule ( ) . unwrap ( ) ;
150+ assert ! ( schedule. enabled) ;
151+ assert_eq ! ( schedule. interval, 86400 ) ;
152+ assert_eq ! ( schedule. update_window_start, 7200 ) ;
153+ let reserialized = serde_json:: to_string ( & status) . unwrap ( ) ;
154+ assert_eq ! ( reserialized, json) ;
155+ }
156+
157+ #[ test]
158+ fn v2_with_products_roundtrip ( ) {
159+ let json = r#"{"VersionMajor":2,"VersionMinor":1,"Products":{"Agent":{"TargetVersion":"2026.2.0"},"Gateway":{"TargetVersion":"latest"}}}"# ;
160+ let status = UpdateStatus :: parse ( json. as_bytes ( ) ) . unwrap ( ) ;
161+ let products = status. into_products ( ) ;
162+ assert_eq ! ( products. len( ) , 2 ) ;
163+ assert ! ( matches!(
164+ products[ & UpdateProductKey :: Gateway ] . target_version,
165+ VersionSpecification :: Latest
166+ ) ) ;
167+ assert ! ( matches!(
168+ products[ & UpdateProductKey :: Agent ] . target_version,
169+ VersionSpecification :: Specific ( _)
170+ ) ) ;
171+ }
172+
173+ #[ test]
174+ fn version_string_format ( ) {
175+ let status = UpdateStatus :: parse ( br#"{"VersionMajor":2,"VersionMinor":3}"# ) . unwrap ( ) ;
176+ assert_eq ! ( status. version_string( ) , "2.3" ) ;
177+ }
178+
179+ #[ test]
180+ fn v2_stub_serialise_roundtrip ( ) {
181+ let stub = UpdateStatus :: StatusV2 ( UpdateStatusV2 :: default ( ) ) ;
182+ let serialized = serde_json:: to_string ( & stub) . unwrap ( ) ;
183+ assert_eq ! ( serialized, r#"{"VersionMajor":2,"VersionMinor":1}"# ) ;
184+ let back = UpdateStatus :: parse ( serialized. as_bytes ( ) ) . unwrap ( ) ;
185+ assert ! ( matches!( back, UpdateStatus :: StatusV2 ( _) ) ) ;
39186 }
40187}
0 commit comments