Skip to main content

agentmux_srv/backend/rpc/
schema.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Which request and response type each RPC command carries.
5//!
6//! # Why this exists
7//!
8//! The frontend talks to srv through 284 hand-written command stubs
9//! (`frontend/app/store/rpc-api/*.ts`) and a 2,819-line hand-maintained
10//! `frontend/types/srv-types.d.ts`, every one of them headed "keep in sync
11//! with the agentmux-srv RPC". `docs/reports/REPORT_DRY_AND_MODULARITY_AUDIT_2026_09_06.md`
12//! §2.1 counted 334 such comments and one live drift found by hand.
13//!
14//! The obvious fix — derive the TypeScript types from the Rust ones — does
15//! not work on its own, and `docs/specs/SPEC_RPC_BINDINGS_CODEGEN_2026_09_07.md`
16//! §2 explains why: **which command takes which request and returns which
17//! response is not data anywhere in srv.** Each handler picks its request
18//! type inline (`serde_json::from_value::<CommandXData>(data)`) and builds
19//! its response ad hoc (`serde_json::to_value(&x)`, `json!({"ok": true})`).
20//! A type generator can emit every struct and still not know how to pair
21//! them into a callable stub.
22//!
23//! This module is that missing mapping, recorded as a side effect of
24//! registration itself (`RpcEngine::register_typed`) rather than written
25//! down a second time — so it cannot drift from the handlers the way a
26//! hand-maintained list would. It is deliberately just the mapping: no
27//! TypeScript is emitted here, and adding a code generator on top is a
28//! separate change against an already-proven registry (spec §3.4 step 1).
29//!
30//! # What a binding is
31//!
32//! One row per command registered through the typed path, holding the fully
33//! qualified Rust type names of its request and response. Handlers still on
34//! the untyped `register_handler` contribute nothing — they are simply
35//! absent, which is what makes an incremental migration possible.
36
37use std::collections::BTreeMap;
38
39/// The request/response pair for one command.
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct RpcBinding {
42    /// Wire command name, e.g. `agentdefhide`.
43    pub command: String,
44    /// Fully qualified request type, from [`std::any::type_name`].
45    pub request: &'static str,
46    /// Fully qualified response type.
47    pub response: &'static str,
48    /// The request as TypeScript, from ts-rs. `None` only for bindings
49    /// recorded by [`RpcSchema::record`] without one (the unit tests here).
50    pub request_ts: Option<TsType>,
51    /// The response as TypeScript.
52    pub response_ts: Option<TsType>,
53}
54
55/// How a Rust type reads in TypeScript, as ts-rs itself renders it.
56///
57/// Taken from ts-rs rather than derived from the Rust path, because the two
58/// differ: `#[ts(rename = "DeleteDroneResp")] struct DeleteResp` exports as
59/// `DeleteDroneResp`, so matching on the Rust name would bind a stub to a type
60/// that does not exist (or, worse, to an unrelated one of that name).
61#[derive(Debug, Clone, PartialEq, Eq)]
62pub struct TsType {
63    /// The type expression, e.g. `AgentDefinition`, `Array<Skill>`,
64    /// `CommandFooData | null`, `null` (for `()`).
65    pub name: String,
66    /// Every exported type the expression names, which a generated stub
67    /// imports from `frontend/types/rpc/`. Sorted and de-duplicated.
68    pub imports: Vec<String>,
69}
70
71/// `T` as TypeScript: its ts-rs name, and the exported types that name uses.
72///
73/// A type with its own exported file (`#[ts(export)]`) is imported by name;
74/// its fields are that file's business. A wrapper without one (`Option<X>`,
75/// `Vec<X>`, `HashMap<String, X>`) is looked through, via its generic
76/// parameters: ts-rs reports `X` as a generic of `Option<X>`, not as a
77/// dependency, so `TS::dependencies()` alone would miss it.
78pub fn ts_type<T: ts_rs::TS + 'static + ?Sized>() -> TsType {
79    // The same config the generated export tests use, so names and imports match the files.
80    let cfg = ts_rs::Config::from_env();
81    struct Collect<'a>(std::collections::BTreeSet<String>, &'a ts_rs::Config);
82    impl ts_rs::TypeVisitor for Collect<'_> {
83        fn visit<U: ts_rs::TS + 'static + ?Sized>(&mut self) {
84            if U::output_path().is_some() {
85                self.0.insert(U::ident(self.1));
86            } else {
87                U::visit_generics(self);
88            }
89        }
90    }
91    let mut c = Collect(Default::default(), &cfg);
92    ts_rs::TypeVisitor::visit::<T>(&mut c);
93    TsType { name: T::name(&cfg), imports: c.0.into_iter().collect() }
94}
95
96impl RpcBinding {
97    /// Last path segment of `request` — the name a generator would emit.
98    ///
99    /// Kept alongside the full path rather than replacing it: the short name
100    /// is what a binding is called in TypeScript, but two modules can hold
101    /// same-named types, so the full path stays authoritative.
102    #[cfg(test)]
103    pub fn request_name(&self) -> &str {
104        short_name(self.request)
105    }
106
107    /// Last path segment of `response`.
108    #[cfg(test)]
109    pub fn response_name(&self) -> &str {
110        short_name(self.response)
111    }
112}
113
114/// `a::b::Foo` -> `Foo`; also strips a generic tail so `Vec<a::B>` reads as
115/// `Vec<a::B>` rather than being truncated at a `::` inside the parameter.
116#[cfg(test)]
117fn short_name(path: &str) -> &str {
118    match path.find('<') {
119        // Generic: leave it whole. Truncating `Vec<crate::x::Y>` at the last
120        // `::` would produce `Y>`, which names nothing.
121        Some(_) => path,
122        None => path.rsplit("::").next().unwrap_or(path),
123    }
124}
125
126/// Every typed command registered on one engine.
127///
128/// Keyed by command so a duplicate registration overwrites rather than
129/// silently recording the command twice — that mirrors the handler map in
130/// `engine.rs`, where the second `register_*` for a command wins.
131#[derive(Debug, Default)]
132pub struct RpcSchema {
133    bindings: BTreeMap<String, RpcBinding>,
134}
135
136impl RpcSchema {
137    pub fn new() -> Self {
138        Self::default()
139    }
140
141    /// Record (or replace) the binding for `command`, without TypeScript.
142    #[cfg(test)]
143    pub fn record(&mut self, command: &str, request: &'static str, response: &'static str) {
144        self.bindings.insert(
145            command.to_string(),
146            RpcBinding {
147                command: command.to_string(),
148                request,
149                response,
150                request_ts: None,
151                response_ts: None,
152            },
153        );
154    }
155
156    /// Record (or replace) `command`'s binding, with its request and response
157    /// as TypeScript. This is what `register_typed` records.
158    pub fn record_typed<Req, Resp>(&mut self, command: &str)
159    where
160        Req: ts_rs::TS + 'static,
161        Resp: ts_rs::TS + 'static,
162    {
163        self.bindings.insert(
164            command.to_string(),
165            RpcBinding {
166                command: command.to_string(),
167                request: std::any::type_name::<Req>(),
168                response: std::any::type_name::<Resp>(),
169                request_ts: Some(ts_type::<Req>()),
170                response_ts: Some(ts_type::<Resp>()),
171            },
172        );
173    }
174
175    /// Bindings in command order. `BTreeMap` makes this stable across runs,
176    /// which is what lets a generated artifact be diffed in CI.
177    #[cfg(test)]
178    pub fn bindings(&self) -> Vec<&RpcBinding> {
179        self.bindings.values().collect()
180    }
181
182    /// Forget `command`'s binding, if it had one.
183    ///
184    /// Called when a command is (re-)registered through an untyped path, so
185    /// the schema always describes the handler that is actually installed. A
186    /// stale typed pair would be worse than no pair at all: it would generate
187    /// a client stub with types the live handler does not accept.
188    pub fn remove(&mut self, command: &str) {
189        self.bindings.remove(command);
190    }
191
192    #[cfg(test)]
193    pub fn len(&self) -> usize {
194        self.bindings.len()
195    }
196
197    #[cfg(test)]
198    pub fn is_empty(&self) -> bool {
199        self.bindings.is_empty()
200    }
201
202    #[cfg(test)]
203    pub fn get(&self, command: &str) -> Option<&RpcBinding> {
204        self.bindings.get(command)
205    }
206
207    /// Stable JSON, sorted by command, for the dump + CI gate.
208    #[cfg(test)]
209    pub fn to_json(&self) -> serde_json::Value {
210        serde_json::Value::Array(
211            self.bindings
212                .values()
213                .map(|b| {
214                    let ts = |t: &Option<TsType>| match t {
215                        Some(t) => serde_json::json!({ "name": t.name, "imports": t.imports }),
216                        None => serde_json::Value::Null,
217                    };
218                    serde_json::json!({
219                        "command": b.command,
220                        "request": b.request,
221                        "requestName": b.request_name(),
222                        "requestTs": ts(&b.request_ts),
223                        "response": b.response,
224                        "responseName": b.response_name(),
225                        "responseTs": ts(&b.response_ts),
226                    })
227                })
228                .collect(),
229        )
230    }
231}
232
233#[cfg(test)]
234mod tests {
235    use super::*;
236
237    /// ts-rs 10 dropped a whole `#[serde(...)]` attribute when one item in it was unknown, so a
238    /// `rename` beside `skip_serializing_if` was lost and the binding named the Rust field (the
239    /// "My Agents empty" incident). ts-rs 12 parses each item: the rename and the optionality
240    /// both reach the binding, and `check-tsrs-serde-warnings` fails the build if an item doesn't.
241    #[test]
242    fn a_rename_beside_skip_serializing_if_reaches_the_binding() {
243        #[derive(serde::Serialize, ts_rs::TS)]
244        #[allow(dead_code)]
245        struct Probe {
246            #[serde(rename = "wireName", default, skip_serializing_if = "Option::is_none")]
247            rust_name: Option<String>,
248            #[serde(rename = "kept", skip_serializing_if = "String::is_empty")]
249            also_rust: String,
250        }
251        let decl = <Probe as ts_rs::TS>::decl(&ts_rs::Config::from_env());
252        assert!(decl.contains("wireName?: string"), "rename and optionality must both survive: {decl}");
253        assert!(decl.contains("kept"), "a rename beside skip_serializing_if must survive: {decl}");
254        assert!(!decl.contains("rust_name") && !decl.contains("also_rust"), "the Rust names must not leak: {decl}");
255    }
256
257    #[derive(ts_rs::TS)]
258    #[ts(export_to = "../../../frontend/types/rpc/")]
259    #[ts(rename = "Renamed")]
260    #[allow(dead_code)]
261    struct RustName {
262        v: bool,
263    }
264
265    #[test]
266    fn ts_type_uses_the_exported_name_not_the_rust_one() {
267        // The reason the schema asks ts-rs instead of trimming the Rust path:
268        // `DeleteResp` exports as `DeleteDroneResp`, and a stub must name that.
269        let t = ts_type::<RustName>();
270        assert_eq!(t.name, "Renamed");
271        assert_eq!(t.imports, vec!["Renamed".to_string()]);
272    }
273
274    #[test]
275    fn ts_type_looks_through_option_and_vec_to_the_type_to_import() {
276        // ts-rs reports `X` as a generic parameter of `Option<X>`, not as a
277        // dependency, so collecting `dependencies()` alone would import nothing.
278        let opt = ts_type::<Option<RustName>>();
279        assert_eq!(opt.name, "Renamed | null");
280        assert_eq!(opt.imports, vec!["Renamed".to_string()]);
281        let vec = ts_type::<Vec<RustName>>();
282        assert_eq!(vec.name, "Array<Renamed>");
283        assert_eq!(vec.imports, vec!["Renamed".to_string()]);
284    }
285
286    #[test]
287    fn ts_type_of_unit_and_primitives_imports_nothing() {
288        assert_eq!(ts_type::<()>(), TsType { name: "null".into(), imports: vec![] });
289        assert_eq!(ts_type::<String>().imports, Vec::<String>::new());
290    }
291
292    #[test]
293    fn short_name_takes_the_last_segment() {
294        assert_eq!(short_name("agentmux_srv::rpc_types::CommandFooData"), "CommandFooData");
295        assert_eq!(short_name("Foo"), "Foo");
296    }
297
298    #[test]
299    fn short_name_leaves_generics_whole_rather_than_truncating_mid_parameter() {
300        // `rsplit("::")` on this would yield `AgentDefinition>` — a name that
301        // does not exist. Better to hand back something a generator can
302        // reject loudly than something that looks valid and is not.
303        let generic = "alloc::vec::Vec<agentmux_srv::storage::AgentDefinition>";
304        assert_eq!(short_name(generic), generic);
305    }
306
307    #[test]
308    fn bindings_come_back_in_command_order_regardless_of_insertion_order() {
309        let mut s = RpcSchema::new();
310        s.record("zebra", "a::Z", "a::ZR");
311        s.record("alpha", "a::A", "a::AR");
312        let got: Vec<&str> = s.bindings().iter().map(|b| b.command.as_str()).collect();
313        assert_eq!(got, vec!["alpha", "zebra"], "order must not depend on registration order");
314    }
315
316    #[test]
317    fn re_registering_a_command_replaces_it_the_way_the_handler_map_does() {
318        let mut s = RpcSchema::new();
319        s.record("dup", "a::First", "a::FirstR");
320        s.record("dup", "a::Second", "a::SecondR");
321        assert_eq!(s.len(), 1);
322        assert_eq!(s.get("dup").unwrap().request, "a::Second");
323    }
324
325    #[test]
326    fn remove_forgets_a_binding_so_a_replaced_command_stops_advertising_stale_types() {
327        let mut s = RpcSchema::new();
328        s.record("foo", "a::Req", "a::Resp");
329        s.remove("foo");
330        assert!(s.get("foo").is_none());
331        assert!(s.is_empty());
332        // Removing something that was never recorded is a no-op, not a panic:
333        // every untyped registration calls this, and most commands were never
334        // typed in the first place.
335        s.remove("never-registered");
336    }
337
338    #[test]
339    fn json_is_stable_and_carries_both_the_full_path_and_the_short_name() {
340        let mut s = RpcSchema::new();
341        s.record("agentdefhide", "crate::rpc_types::CommandAgentDefHideData", "crate::rpc_types::AgentDefHideResult");
342        let v = s.to_json();
343        assert_eq!(v[0]["command"], "agentdefhide");
344        assert_eq!(v[0]["request"], "crate::rpc_types::CommandAgentDefHideData");
345        assert_eq!(v[0]["requestName"], "CommandAgentDefHideData");
346        assert_eq!(v[0]["responseName"], "AgentDefHideResult");
347        assert_eq!(s.to_json(), v, "same input must serialize identically every time");
348    }
349}