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}