Skip to main content

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}