kanade_shared/ipc/state.rs
1//! `state.*` method types — endpoint health snapshot + push notifications.
2//!
3//! Drives the Client App's "Health" tab (SPEC §2.1 use case 2):
4//! BitLocker / AV signature / OS-patch / cert-expiry / disk-free /
5//! agent-self-update + arbitrary additional compliance checks. The
6//! snapshot is computed agent-side on demand (`state.snapshot`) and
7//! pushed when underlying checks flip via `state.changed`.
8
9use serde::{Deserialize, Serialize};
10
11// ---------- state.snapshot ----------
12
13/// `state.snapshot` takes no params.
14#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Default)]
15pub struct StateSnapshotParams {}
16
17/// Full state bundle — the SPA renders this verbatim on the Health
18/// tab. SPEC §2.12.8's complete-conversation example pins the
19/// shape:
20///
21/// ```jsonc
22/// {"pc_id":"PC1234","online":true,"vpn":"connected",
23/// "checks":[{"name":"bitlocker","status":"ok"}],
24/// "agent_version":"0.4.0","target_version":"0.4.0"}
25/// ```
26#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
27pub struct StateSnapshot {
28 /// Agent's `pc_id` — duplicated here from the handshake so the
29 /// SPA can refresh the snapshot independently without
30 /// re-handshaking.
31 pub pc_id: String,
32 /// `true` when the agent currently has a NATS connection open.
33 /// Distinct from the OS-level network state — operators care
34 /// about "is fleet management reachable" specifically.
35 pub online: bool,
36 /// VPN posture. Free-form string today (`"connected"` /
37 /// `"disconnected"` / `"unknown"` / a vendor-specific status)
38 /// because SPEC §2.1's compliance checks are
39 /// site-specific. Future SPEC version may tighten this into an
40 /// enum.
41 pub vpn: String,
42 /// Ordered list of compliance check results. Each [`Check`]
43 /// item is rendered as a row on the Health tab; failing rows
44 /// surface a "修復する" button per SPEC §2.1.
45 pub checks: Vec<Check>,
46 /// Currently-running agent binary version
47 /// (`CARGO_PKG_VERSION`). Same value as
48 /// [`super::system::VersionResult::agent_version`].
49 pub agent_version: String,
50 /// Version the agent self-updater is targeting. When this
51 /// differs from `agent_version`, the SPA shows "restart pending"
52 /// on the Health tab.
53 pub target_version: String,
54}
55
56/// One compliance check result. `name` is the stable id (used as
57/// React key + analytics label); `status` drives the row's color;
58/// `detail` is human-readable text for the row body. `troubleshoot`
59/// is the optional `Manifest.id` of the job whose execute button
60/// fixes this check — `None` means the check has no auto-remediation.
61#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
62pub struct Check {
63 pub name: String,
64 pub status: CheckStatus,
65 #[serde(default, skip_serializing_if = "Option::is_none")]
66 pub detail: Option<String>,
67 /// Manifest id of a `category: troubleshoot` job that fixes
68 /// this check. The Client App renders a "修復する" button when
69 /// present (SPEC §2.1). The job MUST have `user_invokable:
70 /// true` — if not, `jobs.execute` returns `Unauthorized` when
71 /// the button is clicked.
72 #[serde(default, skip_serializing_if = "Option::is_none")]
73 pub troubleshoot: Option<String>,
74}
75
76/// Four-state result mirroring the SPA's color palette: ok = green,
77/// warn = yellow, fail = red, unknown = grey. Wire-encoded as
78/// snake_case (`"ok"` / `"warn"` / `"fail"` / `"unknown"`) — the
79/// PascalCase convention is reserved for [`super::error::ErrorKind`]
80/// where SPEC §2.12.9 specifically pins it.
81#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Copy, PartialEq, Eq, Hash)]
82#[serde(rename_all = "snake_case")]
83pub enum CheckStatus {
84 /// Check passed.
85 Ok,
86 /// Non-blocking finding. SPA renders yellow; user can ignore.
87 Warn,
88 /// Failed — SPA renders red. If a `troubleshoot` manifest is
89 /// declared, the "修復する" button is enabled.
90 Fail,
91 /// Check couldn't run (agent timed out, WMI hang, …). SPA
92 /// renders grey "Unknown" — operator should investigate via
93 /// `system.log_tail`.
94 Unknown,
95}
96
97// ---------- state.subscribe ----------
98
99/// `state.subscribe` takes no params.
100#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Default)]
101pub struct StateSubscribeParams {}
102
103/// `state.subscribe` returns an opaque subscription handle. The
104/// client passes it back to `state.unsubscribe` to stop the push
105/// stream; SPEC §2.12.7 says subscriptions are auto-cleaned on
106/// disconnect, so a well-behaved client never needs to remember
107/// these across reconnects.
108#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
109pub struct StateSubscribeResult {
110 pub subscription: String,
111}
112
113/// `state.unsubscribe` params.
114#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
115pub struct StateUnsubscribeParams {
116 pub subscription: String,
117}
118
119// ---------- state.changed (push) ----------
120
121/// Push payload for `state.changed`. Pushed by the agent when one
122/// or more compliance checks flip status, or when `online` / `vpn`
123/// / `agent_version` change. A full [`StateSnapshot`] is included
124/// so the client doesn't need a second round-trip — the push is
125/// strictly idempotent: applying a `state.changed` payload onto the
126/// client's cached snapshot is a no-op replace, not a diff merge.
127#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
128pub struct StateChangedParams {
129 /// Full snapshot at the time of the change.
130 pub snapshot: StateSnapshot,
131 /// Wall-clock when the agent detected the change. Lets the
132 /// client surface "updated 3 s ago" without trusting its own
133 /// clock for the agent's processing time.
134 pub at: chrono::DateTime<chrono::Utc>,
135}
136
137#[cfg(test)]
138mod tests {
139 use super::*;
140
141 #[test]
142 fn check_status_serialises_snake_case() {
143 for (variant, expected) in [
144 (CheckStatus::Ok, "\"ok\""),
145 (CheckStatus::Warn, "\"warn\""),
146 (CheckStatus::Fail, "\"fail\""),
147 (CheckStatus::Unknown, "\"unknown\""),
148 ] {
149 let s = serde_json::to_string(&variant).unwrap();
150 assert_eq!(s, expected, "encode {variant:?}");
151 let back: CheckStatus = serde_json::from_str(expected).unwrap();
152 assert_eq!(back, variant, "round-trip {expected}");
153 }
154 }
155
156 #[test]
157 fn state_snapshot_spec_example_decodes() {
158 // SPEC §2.12.8 — pinned so a rename can't drift the
159 // documented contract.
160 let wire = r#"{
161 "pc_id":"PC1234","online":true,"vpn":"connected",
162 "checks":[{"name":"bitlocker","status":"ok"},
163 {"name":"av_signature","status":"warn","detail":"3 日前"}],
164 "agent_version":"0.4.0","target_version":"0.4.0"
165 }"#;
166 let s: StateSnapshot = serde_json::from_str(wire).expect("decode");
167 assert_eq!(s.pc_id, "PC1234");
168 assert!(s.online);
169 assert_eq!(s.vpn, "connected");
170 assert_eq!(s.checks.len(), 2);
171 assert_eq!(s.checks[0].name, "bitlocker");
172 assert_eq!(s.checks[0].status, CheckStatus::Ok);
173 assert_eq!(s.checks[1].name, "av_signature");
174 assert_eq!(s.checks[1].status, CheckStatus::Warn);
175 assert_eq!(s.checks[1].detail.as_deref(), Some("3 日前"));
176 assert_eq!(s.agent_version, "0.4.0");
177 assert_eq!(s.target_version, "0.4.0");
178 }
179
180 #[test]
181 fn check_with_troubleshoot_round_trips() {
182 let c = Check {
183 name: "av_signature".into(),
184 status: CheckStatus::Fail,
185 detail: Some("Signatures > 7 days old".into()),
186 troubleshoot: Some("update-av-signatures".into()),
187 };
188 let json = serde_json::to_string(&c).unwrap();
189 let back: Check = serde_json::from_str(&json).unwrap();
190 assert_eq!(back.name, c.name);
191 assert_eq!(back.status, c.status);
192 assert_eq!(back.detail, c.detail);
193 assert_eq!(back.troubleshoot, c.troubleshoot);
194 }
195
196 #[test]
197 fn check_without_optional_fields_decodes() {
198 // Minimal check — `detail` + `troubleshoot` should both be
199 // absent on the wire (not `null`) thanks to
200 // `skip_serializing_if`.
201 let c = Check {
202 name: "bitlocker".into(),
203 status: CheckStatus::Ok,
204 detail: None,
205 troubleshoot: None,
206 };
207 let v = serde_json::to_value(&c).unwrap();
208 assert!(v.get("detail").is_none(), "wire: {v:?}");
209 assert!(v.get("troubleshoot").is_none(), "wire: {v:?}");
210 }
211
212 #[test]
213 fn state_changed_push_round_trips() {
214 let p = StateChangedParams {
215 snapshot: StateSnapshot {
216 pc_id: "PC1234".into(),
217 online: true,
218 vpn: "connected".into(),
219 checks: vec![],
220 agent_version: "0.4.0".into(),
221 target_version: "0.4.0".into(),
222 },
223 at: chrono::Utc::now(),
224 };
225 let json = serde_json::to_string(&p).unwrap();
226 let back: StateChangedParams = serde_json::from_str(&json).unwrap();
227 assert_eq!(back.snapshot.pc_id, "PC1234");
228 }
229}