agentmux_srv\backend/
service.rs

1#![allow(dead_code)]
2// Copyright 2025-2026, AgentMux Corp.
3// SPDX-License-Identifier: Apache-2.0
4
5//! Service dispatcher: routes web/RPC calls to backend services.
6//! Port of Go's pkg/service/service.go and all sub-services.
7//!
8//! Replaces Go's reflection-based dispatch with a match-based router.
9//! Each service method is a typed function; argument conversion from
10//! `serde_json::Value` is handled at the boundary.
11
12
13use std::collections::HashMap;
14
15use serde::{Deserialize, Serialize};
16
17use super::obj;
18
19// ---- Wire types ----
20
21/// Incoming service call from the frontend.
22/// Matches Go's `service.WebCallType`.
23#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct WebCallType {
25    pub service: String,
26    pub method: String,
27    #[serde(default, skip_serializing_if = "Option::is_none")]
28    pub uicontext: Option<UIContext>,
29    #[serde(default)]
30    pub args: Vec<serde_json::Value>,
31}
32
33/// UI context passed with service calls.
34/// Matches Go's `obj.UIContext`.
35#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct UIContext {
37    #[serde(default, rename = "activetabid")]
38    pub active_tab_id: String,
39    #[serde(flatten)]
40    pub extra: HashMap<String, serde_json::Value>,
41}
42
43/// Service call response.
44/// Matches Go's `service.WebReturnType`.
45#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct WebReturnType {
47    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
48    pub success: bool,
49    #[serde(default, skip_serializing_if = "Option::is_none")]
50    pub error: Option<String>,
51    #[serde(default, skip_serializing_if = "Option::is_none")]
52    pub data: Option<serde_json::Value>,
53    #[serde(default, skip_serializing_if = "Option::is_none")]
54    pub updates: Option<Vec<obj::WaveObjUpdate>>,
55}
56
57impl WebReturnType {
58    /// Create a success response with data.
59    pub fn success(data: serde_json::Value) -> Self {
60        Self {
61            success: true,
62            error: None,
63            data: Some(data),
64            updates: None,
65        }
66    }
67
68    /// Create a success response with no data.
69    pub fn success_empty() -> Self {
70        Self {
71            success: true,
72            error: None,
73            data: None,
74            updates: None,
75        }
76    }
77
78    /// Create a success response with updates.
79    pub fn success_with_updates(updates: Vec<obj::WaveObjUpdate>) -> Self {
80        Self {
81            success: true,
82            error: None,
83            data: None,
84            updates: if updates.is_empty() {
85                None
86            } else {
87                Some(updates)
88            },
89        }
90    }
91
92    /// Create a success response with both data and updates.
93    pub fn success_data_updates(
94        data: serde_json::Value,
95        updates: Vec<obj::WaveObjUpdate>,
96    ) -> Self {
97        Self {
98            success: true,
99            error: None,
100            data: Some(data),
101            updates: if updates.is_empty() {
102                None
103            } else {
104                Some(updates)
105            },
106        }
107    }
108
109    /// Create an error response.
110    pub fn error(msg: impl Into<String>) -> Self {
111        Self {
112            success: false,
113            error: Some(msg.into()),
114            data: None,
115            updates: None,
116        }
117    }
118}
119
120// ---- WaveObjUpdate (matches Go's obj.WaveObjUpdate) ----
121// This is re-exported from obj where it's defined.
122
123// ---- Method metadata (for documentation and code generation) ----
124
125/// Metadata about a service method. Matches Go's `tsgenmeta.MethodMeta`.
126/// Used for TypeScript code generation and documentation.
127#[derive(Debug, Clone, Serialize, Deserialize)]
128pub struct MethodMeta {
129    #[serde(default, skip_serializing_if = "Option::is_none")]
130    pub desc: Option<String>,
131    #[serde(default, skip_serializing_if = "Vec::is_empty")]
132    pub arg_names: Vec<String>,
133    #[serde(default, skip_serializing_if = "Option::is_none")]
134    pub return_desc: Option<String>,
135}
136
137/// Registry of service method metadata (built at startup).
138pub fn get_method_meta(service: &str, method: &str) -> Option<MethodMeta> {
139    match (service, method) {
140        // BlockService
141        ("block", "SendCommand") => Some(MethodMeta {
142            desc: Some("send command to block".into()),
143            arg_names: vec!["blockid".into(), "cmd".into()],
144            return_desc: None,
145        }),
146        ("block", "GetControllerStatus") => Some(MethodMeta {
147            desc: Some("get block controller status".into()),
148            arg_names: vec!["blockid".into()],
149            return_desc: None,
150        }),
151        ("block", "SaveTerminalState") => Some(MethodMeta {
152            desc: Some("save the terminal state to a blockfile".into()),
153            arg_names: vec![
154                "ctx".into(),
155                "blockId".into(),
156                "state".into(),
157                "stateType".into(),
158                "ptyOffset".into(),
159                "termSize".into(),
160            ],
161            return_desc: None,
162        }),
163
164        // ObjectService
165        ("object", "GetObject") => Some(MethodMeta {
166            desc: Some("get wave object by oref".into()),
167            arg_names: vec!["oref".into()],
168            return_desc: None,
169        }),
170        ("object", "GetObjects") => Some(MethodMeta {
171            desc: Some("get multiple wave objects".into()),
172            arg_names: vec!["orefs".into()],
173            return_desc: Some("objects".into()),
174        }),
175        ("object", "UpdateTabName") => Some(MethodMeta {
176            desc: Some("update tab name".into()),
177            arg_names: vec!["uiContext".into(), "tabId".into(), "name".into()],
178            return_desc: None,
179        }),
180        ("object", "CreateBlock") => Some(MethodMeta {
181            desc: Some("create a new block".into()),
182            // arg_names is documented as if `uiContext` were args[0], but
183            // uiContext actually rides in the call envelope (call.uicontext),
184            // not in the args array. So the handler reads positions
185            // shifted left by 1: blockDef = args[0], rtOpts = args[1],
186            // tabId = args[2]. (Same shift convention as every other
187            // CreateBlock-style handler in this file.) tabId overrides
188            // uicontext.active_tab_id when present — see
189            // SPEC_VERSION_INSTANCE_PANEL_2026_04_25 follow-up + the
190            // tab-presets TOCTOU note in frontend/app/tab/tab-presets.ts.
191            arg_names: vec![
192                "uiContext".into(),
193                "blockDef".into(),
194                "rtOpts".into(),
195                "tabId".into(),
196            ],
197            return_desc: Some("blockId".into()),
198        }),
199        ("object", "DeleteBlock") => Some(MethodMeta {
200            desc: Some("delete a block".into()),
201            arg_names: vec!["uiContext".into(), "blockId".into()],
202            return_desc: None,
203        }),
204        ("object", "UpdateObjectMeta") => Some(MethodMeta {
205            desc: Some("update object meta".into()),
206            arg_names: vec!["uiContext".into(), "oref".into(), "meta".into()],
207            return_desc: None,
208        }),
209        ("object", "UpdateObject") => Some(MethodMeta {
210            desc: Some("update a wave object".into()),
211            arg_names: vec!["uiContext".into(), "waveObj".into(), "returnUpdates".into()],
212            return_desc: None,
213        }),
214
215        // ClientService
216        ("client", "GetClientData") => Some(MethodMeta {
217            desc: Some("get client data".into()),
218            arg_names: vec![],
219            return_desc: None,
220        }),
221        ("client", "GetTab") => Some(MethodMeta {
222            desc: Some("get tab by ID".into()),
223            arg_names: vec!["tabId".into()],
224            return_desc: None,
225        }),
226        ("client", "GetAllConnStatus") => Some(MethodMeta {
227            desc: Some("get all connection statuses".into()),
228            arg_names: vec![],
229            return_desc: None,
230        }),
231        ("client", "FocusWindow") => Some(MethodMeta {
232            desc: Some("focus a window".into()),
233            arg_names: vec!["windowId".into()],
234            return_desc: None,
235        }),
236        ("client", "AgreeTos") => Some(MethodMeta {
237            desc: Some("agree to terms of service".into()),
238            arg_names: vec![],
239            return_desc: None,
240        }),
241        ("client", "TelemetryUpdate") => Some(MethodMeta {
242            desc: Some("update telemetry setting".into()),
243            arg_names: vec!["telemetryEnabled".into()],
244            return_desc: None,
245        }),
246
247        // WindowService
248        ("window", "GetWindow") => Some(MethodMeta {
249            desc: Some("get window by ID".into()),
250            arg_names: vec!["windowId".into()],
251            return_desc: None,
252        }),
253        ("window", "CreateWindow") => Some(MethodMeta {
254            desc: Some("create a new window".into()),
255            arg_names: vec!["ctx".into(), "winSize".into(), "workspaceId".into()],
256            return_desc: None,
257        }),
258        ("window", "SetWindowPosAndSize") => Some(MethodMeta {
259            desc: Some("set window position and size".into()),
260            arg_names: vec!["ctx".into(), "windowId".into(), "pos".into(), "size".into()],
261            return_desc: None,
262        }),
263        ("window", "MoveBlockToNewWindow") => Some(MethodMeta {
264            desc: Some("move block to new window".into()),
265            arg_names: vec!["ctx".into(), "currentTabId".into(), "blockId".into()],
266            return_desc: None,
267        }),
268        ("window", "SwitchWorkspace") => Some(MethodMeta {
269            desc: Some("switch workspace".into()),
270            arg_names: vec!["ctx".into(), "windowId".into(), "workspaceId".into()],
271            return_desc: None,
272        }),
273        ("window", "CloseWindow") => Some(MethodMeta {
274            desc: Some("close a window".into()),
275            arg_names: vec!["ctx".into(), "windowId".into(), "fromElectron".into()],
276            return_desc: None,
277        }),
278
279        // WorkspaceService
280        ("workspace", "CreateWorkspace") => Some(MethodMeta {
281            desc: Some("create a new workspace".into()),
282            arg_names: vec![
283                "ctx".into(),
284                "name".into(),
285            ],
286            return_desc: Some("workspaceId".into()),
287        }),
288        ("workspace", "UpdateWorkspace") => Some(MethodMeta {
289            desc: Some("update workspace properties".into()),
290            arg_names: vec![
291                "ctx".into(),
292                "workspaceId".into(),
293                "name".into(),
294            ],
295            return_desc: None,
296        }),
297        ("workspace", "GetWorkspace") => Some(MethodMeta {
298            desc: Some("get workspace by ID".into()),
299            arg_names: vec!["workspaceId".into()],
300            return_desc: Some("workspace".into()),
301        }),
302        ("workspace", "DeleteWorkspace") => Some(MethodMeta {
303            desc: Some("delete a workspace".into()),
304            arg_names: vec!["workspaceId".into()],
305            return_desc: None,
306        }),
307        ("workspace", "ListWorkspaces") => Some(MethodMeta {
308            desc: Some("list all workspaces".into()),
309            arg_names: vec![],
310            return_desc: None,
311        }),
312        ("workspace", "CreateTab") => Some(MethodMeta {
313            desc: Some("create a new tab".into()),
314            arg_names: vec![
315                "workspaceId".into(),
316                "tabName".into(),
317                "activateTab".into(),
318                "pinned".into(),
319            ],
320            return_desc: Some("tabId".into()),
321        }),
322        ("workspace", "UpdateTabIds") => Some(MethodMeta {
323            desc: Some("update tab ordering".into()),
324            arg_names: vec![
325                "uiContext".into(),
326                "workspaceId".into(),
327                "tabIds".into(),
328                "pinnedTabIds".into(),
329            ],
330            return_desc: None,
331        }),
332        ("workspace", "SetActiveTab") => Some(MethodMeta {
333            desc: Some("set active tab".into()),
334            arg_names: vec!["workspaceId".into(), "tabId".into()],
335            return_desc: None,
336        }),
337        ("workspace", "CloseTab") => Some(MethodMeta {
338            desc: Some("close a tab".into()),
339            arg_names: vec![
340                "ctx".into(),
341                "workspaceId".into(),
342                "tabId".into(),
343                "fromElectron".into(),
344            ],
345            return_desc: Some("CloseTabRtn".into()),
346        }),
347
348        // UserInputService
349        ("userinput", "SendUserInputResponse") => Some(MethodMeta {
350            desc: Some("send user input response".into()),
351            arg_names: vec!["response".into()],
352            return_desc: None,
353        }),
354
355        _ => None,
356    }
357}
358
359// ---- Service-specific types ----
360
361/// Return type from workspace CloseTab.
362/// Matches Go's `workspaceservice.CloseTabRtnType`.
363#[derive(Debug, Clone, Serialize, Deserialize)]
364pub struct CloseTabRtnType {
365    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
366    pub closewindow: bool,
367    #[serde(default, skip_serializing_if = "String::is_empty")]
368    pub newactivetabid: String,
369}
370
371/// List of available services. Matches Go's `ServiceMap` keys.
372pub const SERVICES: &[&str] = &[
373    "block",
374    "object",
375    "client",
376    "window",
377    "workspace",
378    "userinput",
379];
380
381/// Check if a service name is valid.
382pub fn is_valid_service(name: &str) -> bool {
383    SERVICES.contains(&name)
384}
385
386/// Extract a typed argument from the args array.
387pub fn get_arg<T: serde::de::DeserializeOwned>(
388    args: &[serde_json::Value],
389    idx: usize,
390) -> Result<T, String> {
391    let val = args
392        .get(idx)
393        .ok_or_else(|| format!("missing argument at index {}", idx))?;
394    serde_json::from_value(val.clone()).map_err(|e| format!("invalid argument at index {}: {}", idx, e))
395}
396
397/// Extract an optional typed argument from the args array.
398/// Returns Ok(None) if the index is out of bounds or the value is null.
399pub fn get_optional_arg<T: serde::de::DeserializeOwned>(
400    args: &[serde_json::Value],
401    idx: usize,
402) -> Result<Option<T>, String> {
403    match args.get(idx) {
404        None | Some(serde_json::Value::Null) => Ok(None),
405        Some(val) => serde_json::from_value(val.clone())
406            .map(Some)
407            .map_err(|e| format!("invalid argument at index {}: {}", idx, e)),
408    }
409}
410
411#[cfg(test)]
412mod tests {
413    use super::*;
414
415    #[test]
416    fn test_web_call_type_deserialize() {
417        let json = r#"{
418            "service": "object",
419            "method": "GetObject",
420            "args": ["block:abc123"]
421        }"#;
422        let call: WebCallType = serde_json::from_str(json).unwrap();
423        assert_eq!(call.service, "object");
424        assert_eq!(call.method, "GetObject");
425        assert_eq!(call.args.len(), 1);
426        assert!(call.uicontext.is_none());
427    }
428
429    #[test]
430    fn test_web_call_type_with_uicontext() {
431        let json = r#"{
432            "service": "object",
433            "method": "CreateBlock",
434            "uicontext": {"activetabid": "tab-123"},
435            "args": [{"view": "term"}, {}]
436        }"#;
437        let call: WebCallType = serde_json::from_str(json).unwrap();
438        assert_eq!(call.service, "object");
439        assert_eq!(call.method, "CreateBlock");
440        assert_eq!(call.uicontext.as_ref().unwrap().active_tab_id, "tab-123");
441        assert_eq!(call.args.len(), 2);
442    }
443
444    #[test]
445    fn test_web_return_type_success() {
446        let rtn = WebReturnType::success(serde_json::json!("hello"));
447        assert!(rtn.success);
448        assert!(rtn.error.is_none());
449        assert_eq!(rtn.data, Some(serde_json::json!("hello")));
450    }
451
452    #[test]
453    fn test_web_return_type_error() {
454        let rtn = WebReturnType::error("something went wrong");
455        assert!(!rtn.success);
456        assert_eq!(rtn.error.as_deref(), Some("something went wrong"));
457        assert!(rtn.data.is_none());
458    }
459
460    #[test]
461    fn test_web_return_type_success_empty() {
462        let rtn = WebReturnType::success_empty();
463        assert!(rtn.success);
464        assert!(rtn.data.is_none());
465        assert!(rtn.updates.is_none());
466    }
467
468    #[test]
469    fn test_web_return_type_with_updates() {
470        let updates = vec![obj::WaveObjUpdate {
471            updatetype: "update".into(),
472            otype: "tab".into(),
473            oid: "123".into(),
474            obj: None,
475        }];
476        let rtn = WebReturnType::success_with_updates(updates);
477        assert!(rtn.success);
478        assert_eq!(rtn.updates.as_ref().unwrap().len(), 1);
479    }
480
481    #[test]
482    fn test_web_return_type_empty_updates_omitted() {
483        let rtn = WebReturnType::success_with_updates(vec![]);
484        let json = serde_json::to_string(&rtn).unwrap();
485        assert!(!json.contains("updates"));
486    }
487
488    #[test]
489    fn test_web_return_type_serde_roundtrip() {
490        let rtn = WebReturnType::success_data_updates(
491            serde_json::json!({"id": "block-1"}),
492            vec![obj::WaveObjUpdate {
493                updatetype: "update".into(),
494                otype: "block".into(),
495                oid: "abc".into(),
496                obj: Some(serde_json::json!({"otype": "block"})),
497            }],
498        );
499        let json = serde_json::to_string(&rtn).unwrap();
500        let parsed: WebReturnType = serde_json::from_str(&json).unwrap();
501        assert!(parsed.success);
502        assert!(parsed.data.is_some());
503        assert_eq!(parsed.updates.as_ref().unwrap().len(), 1);
504    }
505
506    #[test]
507    fn test_close_tab_rtn_type() {
508        let rtn = CloseTabRtnType {
509            closewindow: true,
510            newactivetabid: String::new(),
511        };
512        let json = serde_json::to_string(&rtn).unwrap();
513        assert!(json.contains("closewindow"));
514        assert!(!json.contains("newactivetabid")); // empty string skipped
515    }
516
517    #[test]
518    fn test_method_meta_exists() {
519        assert!(get_method_meta("object", "GetObject").is_some());
520        assert!(get_method_meta("workspace", "CreateTab").is_some());
521        assert!(get_method_meta("block", "SaveTerminalState").is_some());
522        assert!(get_method_meta("nonexistent", "Foo").is_none());
523    }
524
525    #[test]
526    fn test_is_valid_service() {
527        assert!(is_valid_service("block"));
528        assert!(is_valid_service("object"));
529        assert!(is_valid_service("workspace"));
530        assert!(!is_valid_service("invalid"));
531        assert!(!is_valid_service(""));
532    }
533
534    #[test]
535    fn test_get_arg() {
536        let args = vec![
537            serde_json::json!("hello"),
538            serde_json::json!(42),
539            serde_json::json!(true),
540        ];
541        assert_eq!(get_arg::<String>(&args, 0).unwrap(), "hello");
542        assert_eq!(get_arg::<i64>(&args, 1).unwrap(), 42);
543        assert_eq!(get_arg::<bool>(&args, 2).unwrap(), true);
544        assert!(get_arg::<String>(&args, 5).is_err());
545    }
546
547    #[test]
548    fn test_get_optional_arg() {
549        let args = vec![
550            serde_json::json!("hello"),
551            serde_json::Value::Null,
552        ];
553        assert_eq!(
554            get_optional_arg::<String>(&args, 0).unwrap(),
555            Some("hello".to_string())
556        );
557        assert_eq!(get_optional_arg::<String>(&args, 1).unwrap(), None);
558        assert_eq!(get_optional_arg::<String>(&args, 5).unwrap(), None);
559    }
560
561    #[test]
562    fn test_ui_context_deserialize() {
563        let json = r#"{"activetabid": "tab-1", "extra_field": true}"#;
564        let ctx: UIContext = serde_json::from_str(json).unwrap();
565        assert_eq!(ctx.active_tab_id, "tab-1");
566        assert_eq!(ctx.extra.get("extra_field"), Some(&serde_json::json!(true)));
567    }
568
569    #[test]
570    fn test_web_call_no_args() {
571        let json = r#"{"service": "client", "method": "GetClientData"}"#;
572        let call: WebCallType = serde_json::from_str(json).unwrap();
573        assert_eq!(call.service, "client");
574        assert!(call.args.is_empty());
575    }
576}