kanade-shared 0.41.0

Shared wire types, NATS subject helpers, KV constants, YAML manifest schema, and teravars-backed config loader for the kanade endpoint-management system
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
//! JSON-RPC 2.0 envelope types for KLP (SPEC §2.12.3).
//!
//! Three message shapes flow over the framed transport (Named Pipe
//! on Windows, Unix Domain Socket on Linux/macOS):
//!
//! - [`RpcRequest`] — `{jsonrpc, id, method, params}`. Carries an
//!   `id` so the recipient can correlate the matching response.
//! - [`RpcNotification`] — `{jsonrpc, method, params}`. No `id`,
//!   no response. Used for server push (`notifications.new`,
//!   `jobs.progress`, `state.changed`).
//! - [`RpcResponse`] — `{jsonrpc, id, result|error}`. Exactly one
//!   of `result` or `error` is present.
//!
//! [`RpcMessage`] is an untagged enum over the three for the read
//! side of the connection (one decoder, three possible shapes). The
//! write side picks the concrete type directly.
//!
//! `id` is modelled as a [`String`] to match SPEC §2.12.3's "UUID v7
//! 推奨" guidance — JSON-RPC 2.0 allows numbers and null too, but
//! KLP is a closed two-party protocol where both ends are ours, so
//! we narrow to the form we actually use. Inbound non-string ids
//! fail decode and the agent returns `InvalidRequest`.
//!
//! `params` and `result` are typed as [`serde_json::Value`] at the
//! envelope layer so the dispatcher can route on `method` BEFORE
//! committing to a payload schema. Each per-method module
//! (`handshake`, `system`, `jobs`, …) then `serde_json::from_value`s
//! into its strongly-typed params/result struct. This is a
//! deliberate trade — one extra (de)serialise hop in exchange for
//! the envelope staying method-agnostic, which is what makes the
//! dispatcher implementable as a `match method.as_str()` block.

use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};

use super::error::RpcError;

/// The version string every KLP message carries in the `jsonrpc`
/// field. Pinned to `"2.0"` per the JSON-RPC spec; KLP doesn't
/// negotiate a different RPC version — protocol evolution happens
/// through the handshake's `protocol` field (SPEC §2.12.6).
pub const JSONRPC_VERSION: &str = "2.0";

/// Client → Agent request that expects a response (correlated by `id`).
///
/// SPEC shape:
/// ```jsonc
/// {"jsonrpc":"2.0","id":"01931a8e-...","method":"system.handshake",
///  "params":{...}}
/// ```
#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
pub struct RpcRequest {
    pub jsonrpc: String,
    pub id: String,
    pub method: String,
    /// `params` is wire-optional — methods like `system.ping` take
    /// no arguments and SHOULD omit the field rather than send
    /// `null`. Decoders see `serde_json::Value::Null` for either
    /// form, so callers must not rely on absent-vs-null to carry
    /// meaning.
    #[serde(default, skip_serializing_if = "is_null")]
    pub params: serde_json::Value,
}

/// Server-push or fire-and-forget message with no response (no `id`).
///
/// Used for `notifications.new`, `jobs.progress`, `state.changed`
/// (Agent → Client) and, when needed, request-shaped Client → Agent
/// messages that don't want a response (none currently — kept here
/// for symmetry with JSON-RPC 2.0).
#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
pub struct RpcNotification {
    pub jsonrpc: String,
    pub method: String,
    #[serde(default, skip_serializing_if = "is_null")]
    pub params: serde_json::Value,
}

/// Response to a [`RpcRequest`]. Exactly one of `result` or `error`
/// is populated — see [`RpcResponsePayload`].
///
/// Modelled as a struct with a flattened payload enum (rather than
/// two field options) so the type system enforces the spec's
/// "exactly one of" requirement: it's impossible to construct a
/// response that has both, or neither.
///
/// `id` is [`Option<String>`] because JSON-RPC 2.0 mandates `null`
/// for errors that fire BEFORE the request id can be parsed —
/// [`super::error::ErrorKind::ParseError`] (the body wasn't valid
/// JSON at all) and [`super::error::ErrorKind::InvalidRequest`]
/// (envelope rejected). [`RpcResponse::err_anonymous`] is the
/// dedicated constructor for that case; the happy-path [`Self::ok`]
/// / [`Self::err`] keep the `String` ergonomic.
#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
pub struct RpcResponse {
    pub jsonrpc: String,
    pub id: Option<String>,
    #[serde(flatten)]
    pub payload: RpcResponsePayload,
}

/// Either-or payload for [`RpcResponse`]. `serde(untagged)` means
/// each variant is recognised purely by which key (`result` or
/// `error`) is present on the wire.
#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
#[serde(untagged)]
pub enum RpcResponsePayload {
    /// Success path. `result` may be any JSON value — including
    /// `null` for void methods like `notifications.unsubscribe`.
    Ok { result: serde_json::Value },
    /// Failure path. See [`RpcError`] for the error model.
    Err { error: RpcError },
}

/// Top-level decoded message for the agent's read loop. Inbound
/// bytes are parsed into this enum once; the dispatcher then
/// matches on the variant to route.
///
/// Untagged enum, decoded by trying variants in declaration order:
/// `Response` first (it owns `result`/`error`, neither of which
/// appear on requests), then `Request` (has both `id` and
/// `method`), then `Notification` (has `method` but no `id`). The
/// ordering matters — putting `Request` first would let it greedily
/// match `{id, method, error}` because `params` is optional and the
/// extra `error` field is silently ignored by serde-derived structs.
#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
#[serde(untagged)]
pub enum RpcMessage {
    Response(RpcResponse),
    Request(RpcRequest),
    Notification(RpcNotification),
}

impl RpcRequest {
    /// Build a typed request. Serialises `params` to JSON eagerly so
    /// later dispatch is cheap and the failure surface is just this
    /// call — no surprises mid-send.
    pub fn new<P: Serialize>(
        id: impl Into<String>,
        method: impl Into<String>,
        params: &P,
    ) -> Result<Self, serde_json::Error> {
        Ok(Self {
            jsonrpc: JSONRPC_VERSION.to_string(),
            id: id.into(),
            method: method.into(),
            params: serde_json::to_value(params)?,
        })
    }
}

impl RpcNotification {
    /// Build a typed notification (no id, no response).
    pub fn new<P: Serialize>(
        method: impl Into<String>,
        params: &P,
    ) -> Result<Self, serde_json::Error> {
        Ok(Self {
            jsonrpc: JSONRPC_VERSION.to_string(),
            method: method.into(),
            params: serde_json::to_value(params)?,
        })
    }
}

impl RpcResponse {
    /// Build a success response for a given request `id` from a
    /// typed result. `R = ()` is encoded as JSON `null`, matching
    /// SPEC §2.12.7's `{"result":null}` for void method returns.
    pub fn ok<R: Serialize>(id: impl Into<String>, result: &R) -> Result<Self, serde_json::Error> {
        Ok(Self {
            jsonrpc: JSONRPC_VERSION.to_string(),
            id: Some(id.into()),
            payload: RpcResponsePayload::Ok {
                result: serde_json::to_value(result)?,
            },
        })
    }

    /// Build an error response correlated to a known request `id`.
    pub fn err(id: impl Into<String>, error: RpcError) -> Self {
        Self {
            jsonrpc: JSONRPC_VERSION.to_string(),
            id: Some(id.into()),
            payload: RpcResponsePayload::Err { error },
        }
    }

    /// Build an error response with `id: null` — the JSON-RPC 2.0
    /// shape for errors that fire before the request id can be
    /// parsed (`ParseError` on un-decodable JSON; `InvalidRequest`
    /// on an envelope missing required fields). Distinct from
    /// [`Self::err`] so the type system makes "I don't have an id
    /// to correlate" an explicit choice.
    pub fn err_anonymous(error: RpcError) -> Self {
        Self {
            jsonrpc: JSONRPC_VERSION.to_string(),
            id: None,
            payload: RpcResponsePayload::Err { error },
        }
    }
}

/// Decode a method's `params` payload from the envelope's
/// [`serde_json::Value`] slot, treating `Value::Null` (the wire
/// shape for an omitted `params` field, per SPEC §2.12.3) as
/// equivalent to `P::default()`.
///
/// Solves the "empty params struct can't deserialize from null"
/// hole: methods like `system.ping` SHOULD omit `params`
/// (envelope.rs:55 doc), which arrives as `Value::Null`, but
/// `serde_json::from_value::<PingParams>(Null)` would fail because
/// PingParams expects an object. Routing every params decode
/// through this helper lets the dispatcher accept both the
/// canonical absent form and an explicit `params: {}` without
/// per-method branching.
///
/// Wrong-shape non-null inputs (e.g. an array where an object is
/// expected) still fail loudly through normal serde decoding — the
/// helper only widens the null case.
pub fn decode_params<P: DeserializeOwned + Default>(
    value: serde_json::Value,
) -> Result<P, serde_json::Error> {
    if value.is_null() {
        Ok(P::default())
    } else {
        serde_json::from_value(value)
    }
}

fn is_null(v: &serde_json::Value) -> bool {
    v.is_null()
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::ipc::error::ErrorKind;

    #[derive(Serialize, Deserialize, Debug, PartialEq)]
    struct DummyParams {
        foo: String,
        bar: u32,
    }

    #[test]
    fn request_round_trips_through_json() {
        let req = RpcRequest::new(
            "u1",
            "system.handshake",
            &DummyParams {
                foo: "hello".into(),
                bar: 7,
            },
        )
        .expect("encode");
        let json = serde_json::to_string(&req).unwrap();
        // Spot-check wire shape — `params` is nested, not flattened.
        assert!(json.contains("\"jsonrpc\":\"2.0\""), "wire: {json}");
        assert!(json.contains("\"method\":\"system.handshake\""));
        assert!(json.contains("\"id\":\"u1\""));
        let back: RpcRequest = serde_json::from_str(&json).unwrap();
        assert_eq!(back.id, "u1");
        assert_eq!(back.method, "system.handshake");
        let p: DummyParams = serde_json::from_value(back.params).unwrap();
        assert_eq!(p.foo, "hello");
        assert_eq!(p.bar, 7);
    }

    #[test]
    fn request_without_params_omits_field_on_wire() {
        // SPEC §2.12.6's `system.ping` has no params — the
        // serializer SHOULD drop the field rather than emit
        // `"params":null`, since strict JSON-RPC parsers reject the
        // latter for some methods.
        let req = RpcRequest {
            jsonrpc: JSONRPC_VERSION.into(),
            id: "ping-1".into(),
            method: "system.ping".into(),
            params: serde_json::Value::Null,
        };
        let v = serde_json::to_value(&req).unwrap();
        assert!(v.get("params").is_none(), "wire: {v:?}");
    }

    #[test]
    fn notification_decodes_without_id() {
        // SPEC §2.12.7 push: `notifications.new` arrives with no id.
        let wire = r#"{"jsonrpc":"2.0","method":"notifications.new",
                       "params":{"id":"notif-9f3a"}}"#;
        let m: RpcMessage = serde_json::from_str(wire).unwrap();
        match m {
            RpcMessage::Notification(n) => {
                assert_eq!(n.method, "notifications.new");
                assert_eq!(n.params["id"], "notif-9f3a");
            }
            other => panic!("expected Notification, got {other:?}"),
        }
    }

    #[test]
    fn success_response_decodes_and_round_trips() {
        let r =
            RpcResponse::ok("u3", &serde_json::json!({"subscription":"sub-n-1"})).expect("encode");
        let json = serde_json::to_string(&r).unwrap();
        // Critical: `result` must appear on the wire, not nested in
        // a `payload` field — the flatten attribute does the work.
        let v: serde_json::Value = serde_json::from_str(&json).unwrap();
        assert!(v.get("result").is_some(), "wire: {v:?}");
        assert!(v.get("error").is_none());
        // And the message-level decoder must classify it as Response.
        let m: RpcMessage = serde_json::from_str(&json).unwrap();
        assert!(matches!(m, RpcMessage::Response(_)));
    }

    #[test]
    fn error_response_decodes_and_round_trips() {
        let r = RpcResponse::err(
            "u5",
            RpcError::new(
                ErrorKind::Unauthorized,
                "manifest 'reboot' has user_invokable=false",
            ),
        );
        let json = serde_json::to_string(&r).unwrap();
        let v: serde_json::Value = serde_json::from_str(&json).unwrap();
        assert!(v.get("error").is_some(), "wire: {v:?}");
        assert!(v.get("result").is_none());
        assert_eq!(v["error"]["code"], -32000);

        // Round-trip preserves the discriminant.
        let back: RpcResponse = serde_json::from_str(&json).unwrap();
        match back.payload {
            RpcResponsePayload::Err { error } => assert_eq!(error.code, -32000),
            other => panic!("expected Err payload, got {other:?}"),
        }
    }

    #[test]
    fn message_decoder_distinguishes_request_from_response() {
        // The tricky case: a Request and a Response both carry `id`.
        // The decoder MUST recognise Response by the presence of
        // `result` (or `error`), not by id-vs-method, because there
        // are no required-method requests we send today that lack
        // params.
        let request_wire = r#"{"jsonrpc":"2.0","id":"u1","method":"system.ping"}"#;
        let response_wire = r#"{"jsonrpc":"2.0","id":"u1","result":null}"#;

        match serde_json::from_str::<RpcMessage>(request_wire).unwrap() {
            RpcMessage::Request(r) => assert_eq!(r.method, "system.ping"),
            other => panic!("expected Request, got {other:?}"),
        }
        match serde_json::from_str::<RpcMessage>(response_wire).unwrap() {
            RpcMessage::Response(r) => assert_eq!(r.id.as_deref(), Some("u1")),
            other => panic!("expected Response, got {other:?}"),
        }
    }

    #[test]
    fn void_result_serialises_as_null() {
        // SPEC §2.12.7's unsubscribe response is `{"result":null}`.
        let r = RpcResponse::ok("u4", &()).expect("encode");
        let v = serde_json::to_value(&r).unwrap();
        assert!(v["result"].is_null(), "wire: {v}");
    }

    #[test]
    fn err_anonymous_serialises_id_as_null() {
        // JSON-RPC 2.0 mandates `id: null` for errors that fire
        // before the request id can be parsed (ParseError /
        // InvalidRequest). Wire MUST carry `"id": null` literally,
        // not omit the field.
        let r = RpcResponse::err_anonymous(RpcError::bare(ErrorKind::ParseError));
        let v = serde_json::to_value(&r).unwrap();
        assert!(v["id"].is_null(), "wire: {v}");
        assert_eq!(v["error"]["code"], -32700);
    }

    #[test]
    fn anonymous_error_response_round_trips() {
        let wire = r#"{"jsonrpc":"2.0","id":null,"error":{"code":-32700,"message":"Parse error"}}"#;
        let back: RpcResponse = serde_json::from_str(wire).expect("decode");
        assert!(back.id.is_none(), "decoded id should be None for null wire");
        match back.payload {
            RpcResponsePayload::Err { error } => assert_eq!(error.code, -32700),
            other => panic!("expected Err payload, got {other:?}"),
        }
    }

    // --- decode_params helper (Gemini #1 fix) ---

    #[derive(Serialize, Deserialize, Default, Debug, PartialEq)]
    struct EmptyParams {}

    #[derive(Serialize, Deserialize, Default, Debug, PartialEq)]
    struct WithDefaults {
        #[serde(default)]
        lines: u32,
    }

    #[test]
    fn decode_params_treats_null_as_default() {
        // The reason this helper exists: methods like system.ping
        // SHOULD omit `params` (envelope wire-form), which decodes
        // as Value::Null. Direct from_value::<EmptyParams>(Null)
        // would fail; the helper routes it to P::default().
        let p: EmptyParams = decode_params(serde_json::Value::Null).expect("null → default");
        assert_eq!(p, EmptyParams {});

        let p: WithDefaults = decode_params(serde_json::Value::Null).expect("null → default");
        assert_eq!(p.lines, 0);
    }

    #[test]
    fn decode_params_passes_through_explicit_object() {
        // Non-null inputs go through normal serde — wrong shape
        // still fails loudly so InvalidParams detection isn't
        // weakened.
        let p: WithDefaults =
            decode_params(serde_json::json!({"lines": 42})).expect("explicit object");
        assert_eq!(p.lines, 42);

        let err: Result<WithDefaults, _> = decode_params(serde_json::json!(["wrong", "shape"]));
        assert!(err.is_err(), "non-object input must still fail");
    }
}