kanade_shared/ipc/system.rs
1//! `system.*` (non-handshake) method types — `system.ping`,
2//! `system.version`, `system.log_tail`.
3//!
4//! `system.handshake` lives in [`super::handshake`] because it has
5//! enough surface area (params, result, session, features) to
6//! deserve its own module.
7
8use serde::{Deserialize, Serialize};
9
10// ---------- system.ping ----------
11
12/// `system.ping` takes no params and returns no body. Both shapes
13/// are kept as explicit unit-like structs (rather than `()`) so the
14/// dispatcher can write `from_value::<PingParams>(_)` symmetrically
15/// with every other method.
16///
17/// Wire form: `{}` (empty object). Decoders accept absent params
18/// too thanks to the envelope's `serde(default)` on `params`.
19#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Default)]
20pub struct PingParams {}
21
22/// `system.ping` response. Carries the agent's monotonic clock at
23/// the moment it answered — clients use the (sent_at, received_at)
24/// pair for one-way latency estimates without needing a separate
25/// API.
26#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
27pub struct PingResult {
28 /// Agent-side wall-clock time when the ping was answered, UTC.
29 /// Round-trip variance comes from queueing + scheduling, not
30 /// clock skew — this is wall-clock for log correlation, not for
31 /// monotonic measurement.
32 pub agent_time: chrono::DateTime<chrono::Utc>,
33}
34
35// ---------- system.version ----------
36
37/// `system.version` takes no params.
38#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Default)]
39pub struct VersionParams {}
40
41/// `system.version` response — agent + client app version pair (the
42/// client may not know its own published "intended" version when
43/// auto-update is in flight, hence why both come from the agent
44/// which owns the manifest).
45#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
46pub struct VersionResult {
47 /// Current agent binary version (`CARGO_PKG_VERSION` of the
48 /// running agent).
49 pub agent_version: String,
50 /// Version the agent self-updater is currently targeting. Equal
51 /// to `agent_version` in steady state; differs while an update
52 /// is downloading or pending restart. Lets the client surface a
53 /// "restart pending" banner without scraping logs.
54 pub target_agent_version: String,
55 /// Version the client SHOULD be running (published by the
56 /// backend through `agent_config`). The client compares this to
57 /// its own `CARGO_PKG_VERSION` and prompts the user to relaunch
58 /// when they differ. `None` until the backend has published a
59 /// pinned client version (Sprint 8 deferred).
60 #[serde(default, skip_serializing_if = "Option::is_none")]
61 pub target_client_version: Option<String>,
62}
63
64// ---------- system.log_tail ----------
65
66/// `system.log_tail` params — sized "last N lines of agent.log" so
67/// support handoff diagnostics fit a single message inside the 1 MiB
68/// framing cap (SPEC §2.12.2).
69#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
70pub struct LogTailParams {
71 /// How many trailing lines to return. Agent clamps to its
72 /// internal cap (currently 1000) to keep responses bounded —
73 /// callers asking for more will see truncation, not an error.
74 /// Defaults to 200 when the field is absent.
75 #[serde(default = "default_log_tail_lines")]
76 pub lines: u32,
77}
78
79impl Default for LogTailParams {
80 fn default() -> Self {
81 Self {
82 lines: default_log_tail_lines(),
83 }
84 }
85}
86
87fn default_log_tail_lines() -> u32 {
88 200
89}
90
91/// `system.log_tail` response.
92#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
93pub struct LogTailResult {
94 /// Raw log lines, newest last. Each entry is one line without
95 /// its trailing newline. Agent's logger uses the standard
96 /// `tracing-subscriber` format, so SPA support flows can
97 /// concatenate with `"\n"` for display.
98 pub lines: Vec<String>,
99 /// `true` when the agent truncated the response — caller asked
100 /// for more than the agent's per-call cap. Surfaced so support
101 /// tooling can warn the user to also pull the file from disk
102 /// via `support.upload_diagnostics`.
103 #[serde(default)]
104 pub truncated: bool,
105}
106
107#[cfg(test)]
108mod tests {
109 use super::*;
110 use chrono::TimeZone;
111
112 #[test]
113 fn ping_params_decodes_from_empty_object() {
114 let _: PingParams = serde_json::from_str("{}").unwrap();
115 }
116
117 #[test]
118 fn ping_result_round_trips_through_json() {
119 let t = chrono::Utc.with_ymd_and_hms(2026, 5, 24, 0, 0, 0).unwrap();
120 let r = PingResult { agent_time: t };
121 let json = serde_json::to_string(&r).unwrap();
122 let back: PingResult = serde_json::from_str(&json).unwrap();
123 assert_eq!(back.agent_time, t);
124 }
125
126 #[test]
127 fn version_result_target_client_optional() {
128 // Sprint 8 may ship without a pinned client version; the
129 // field must round-trip cleanly when absent.
130 let wire = r#"{"agent_version":"0.4.0","target_agent_version":"0.4.0"}"#;
131 let r: VersionResult = serde_json::from_str(wire).unwrap();
132 assert_eq!(r.agent_version, "0.4.0");
133 assert!(r.target_client_version.is_none());
134 let json = serde_json::to_string(&r).unwrap();
135 assert!(!json.contains("target_client_version"));
136 }
137
138 #[test]
139 fn log_tail_params_defaults_to_200_lines() {
140 let p = LogTailParams::default();
141 assert_eq!(p.lines, 200);
142 // Wire decode of an empty object also gets the default.
143 let p: LogTailParams = serde_json::from_str("{}").unwrap();
144 assert_eq!(p.lines, 200);
145 }
146
147 #[test]
148 fn log_tail_result_truncated_defaults_to_false() {
149 let wire = r#"{"lines":["a","b","c"]}"#;
150 let r: LogTailResult = serde_json::from_str(wire).unwrap();
151 assert_eq!(r.lines.len(), 3);
152 assert!(!r.truncated);
153 }
154}