Skip to main content

agentmux_srv/backend/
service.rs

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