Skip to main content

agentmux_srv/server/service/
introspect.rs

1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Agent-facing introspection: `AgentContext` resolution plus the read-only
5//! window → workspace → tab → pane snapshots that back `/api/v1/self`,
6//! `/api/v1/layout`, and the naming verbs. Pure mstore reads — no reducer.
7
8use serde_json::json;
9
10use crate::backend::obj::*;
11use crate::backend::storage::store::Store;
12
13/// The agent pane's place in the object tree, resolved from its block id.
14/// Powers `GET /api/v1/self` and lets naming verbs default to "my own X".
15#[derive(Debug, serde::Serialize)]
16pub(crate) struct AgentContext {
17    pub block_id: String,
18    pub block_title: String,
19    pub tab_id: String,
20    pub tab_name: String,
21    pub window_id: Option<String>,
22    pub window_name: String,
23    pub workspace_id: Option<String>,
24    pub workspace_name: String,
25}
26
27/// Find the id of the workspace that owns `tab_id` (in `tabids` or
28/// `pinnedtabids`), or `None` if no workspace references it.
29pub(crate) fn workspace_id_for_tab(store: &Store, tab_id: &str) -> Option<String> {
30    store
31        .get_all::<Workspace>()
32        .unwrap_or_default()
33        .into_iter()
34        .find(|w| {
35            w.tabids.iter().any(|t| t == tab_id) || w.pinnedtabids.iter().any(|t| t == tab_id)
36        })
37        .map(|w| w.oid)
38}
39
40/// Read-only snapshot of the window → workspace → tab → pane tree, for agent
41/// introspection (`GET /api/v1/layout`). Pure mstore reads — no reducer, so
42/// it's hermetic and safe. Lookups use linear scans (a handful of objects).
43pub(crate) fn agent_layout(store: &Store) -> serde_json::Value {
44    let windows = store.get_all::<Window>().unwrap_or_default();
45    let workspaces = store.get_all::<Workspace>().unwrap_or_default();
46    let tabs = store.get_all::<Tab>().unwrap_or_default();
47    let blocks = store.get_all::<Block>().unwrap_or_default();
48
49    let panes_of = |tab: &Tab| -> Vec<serde_json::Value> {
50        tab.blockids
51            .iter()
52            .filter_map(|bid| blocks.iter().find(|b| &b.oid == bid))
53            .map(|b| {
54                json!({
55                    "block_id": b.oid,
56                    "view": meta_get_string(&b.meta, "view", ""),
57                    "title": meta_get_string(&b.meta, "frame:title", ""),
58                })
59            })
60            .collect()
61    };
62
63    let windows_json: Vec<serde_json::Value> = windows
64        .iter()
65        .map(|w| {
66            let ws = workspaces.iter().find(|x| x.oid == w.workspaceid);
67            let tabs_json: Vec<serde_json::Value> = ws
68                .map(|ws| {
69                    // Pinned tabs render first, then the regular tab order.
70                    ws.pinnedtabids
71                        .iter()
72                        .chain(ws.tabids.iter())
73                        .filter_map(|tid| tabs.iter().find(|t| &t.oid == tid))
74                        .map(|t| {
75                            json!({
76                                "tab_id": t.oid,
77                                "name": t.name,
78                                "active": ws.activetabid == t.oid,
79                                "panes": panes_of(t),
80                            })
81                        })
82                        .collect()
83                })
84                .unwrap_or_default();
85            json!({
86                "window_id": w.oid,
87                "name": meta_get_string(&w.meta, "window:displayname", ""),
88                "workspace_id": w.workspaceid,
89                "workspace_name": ws.map(|x| x.name.clone()).unwrap_or_default(),
90                "tabs": tabs_json,
91            })
92        })
93        .collect();
94
95    json!({ "windows": windows_json })
96}
97
98/// Flat list of all windows (id, display name, assigned workspace).
99pub(crate) fn agent_windows(store: &Store) -> serde_json::Value {
100    let workspaces = store.get_all::<Workspace>().unwrap_or_default();
101    let windows: Vec<serde_json::Value> = store
102        .get_all::<Window>()
103        .unwrap_or_default()
104        .iter()
105        .map(|w| {
106            let ws_name = workspaces
107                .iter()
108                .find(|x| x.oid == w.workspaceid)
109                .map(|x| x.name.clone())
110                .unwrap_or_default();
111            json!({
112                "window_id": w.oid,
113                "name": meta_get_string(&w.meta, "window:displayname", ""),
114                "workspace_id": w.workspaceid,
115                "workspace_name": ws_name,
116            })
117        })
118        .collect();
119    json!({ "windows": windows })
120}
121
122/// Flat list of all workspaces (id, name, tab count, active tab).
123pub(crate) fn agent_workspaces(store: &Store) -> serde_json::Value {
124    let workspaces: Vec<serde_json::Value> = store
125        .get_all::<Workspace>()
126        .unwrap_or_default()
127        .iter()
128        .map(|w| {
129            json!({
130                "workspace_id": w.oid,
131                "name": w.name,
132                "tab_count": w.tabids.len() + w.pinnedtabids.len(),
133                "active_tab_id": w.activetabid,
134            })
135        })
136        .collect();
137    json!({ "workspaces": workspaces })
138}
139
140/// Flat list of tabs (id, name, pane count). When `workspace_id` is given,
141/// only that workspace's tabs are returned.
142pub(crate) fn agent_tabs(store: &Store, workspace_id: Option<&str>) -> serde_json::Value {
143    let workspaces = store.get_all::<Workspace>().unwrap_or_default();
144    let scope: Option<Vec<String>> = workspace_id.map(|wid| {
145        workspaces
146            .iter()
147            .find(|w| w.oid == wid)
148            .map(|w| w.pinnedtabids.iter().chain(w.tabids.iter()).cloned().collect())
149            .unwrap_or_default()
150    });
151    let tabs: Vec<serde_json::Value> = store
152        .get_all::<Tab>()
153        .unwrap_or_default()
154        .iter()
155        .filter(|t| scope.as_ref().map(|ids| ids.contains(&t.oid)).unwrap_or(true))
156        .map(|t| {
157            json!({
158                "tab_id": t.oid,
159                "name": t.name,
160                "pane_count": t.blockids.len(),
161            })
162        })
163        .collect();
164    json!({ "tabs": tabs })
165}
166
167/// Walk block → tab → workspace → window from an agent pane's block id.
168///
169/// Tabs carry no parent reference, so the workspace and window are found by
170/// reverse lookup: the workspace whose `tabids`/`pinnedtabids` contains the
171/// tab, then the window assigned that workspace. `window_id`/`workspace_id`
172/// are `None` when the tab isn't attached to a live window (e.g. a torn-off
173/// tab mid-transition) — callers should treat that as "no window to name".
174pub(crate) fn resolve_agent_context(store: &Store, block_id: &str) -> Result<AgentContext, String> {
175    let block = store
176        .get::<Block>(block_id)
177        .map_err(|e| e.to_string())?
178        .ok_or_else(|| format!("block not found: {block_id}"))?;
179    let block_title = meta_get_string(&block.meta, "frame:title", "");
180    let tab_id = block.parentoref.strip_prefix("tab:").unwrap_or("").to_string();
181    let tab = store
182        .get::<Tab>(&tab_id)
183        .map_err(|e| e.to_string())?
184        .ok_or_else(|| format!("tab not found for block {block_id}"))?;
185
186    let mut workspace_id = None;
187    let mut workspace_name = String::new();
188    let mut window_id = None;
189    let mut window_name = String::new();
190
191    if let Ok(workspaces) = store.get_all::<Workspace>() {
192        if let Some(ws) = workspaces
193            .into_iter()
194            .find(|w| w.tabids.contains(&tab_id) || w.pinnedtabids.contains(&tab_id))
195        {
196            workspace_name = ws.name.clone();
197            workspace_id = Some(ws.oid.clone());
198            if let Ok(windows) = store.get_all::<Window>() {
199                if let Some(win) = windows.into_iter().find(|w| w.workspaceid == ws.oid) {
200                    window_name = meta_get_string(&win.meta, "window:displayname", "");
201                    window_id = Some(win.oid);
202                }
203            }
204        }
205    }
206
207    Ok(AgentContext {
208        block_id: block_id.to_string(),
209        block_title,
210        tab_id,
211        tab_name: tab.name,
212        window_id,
213        window_name,
214        workspace_id,
215        workspace_name,
216    })
217}
218
219#[cfg(test)]
220mod agent_context_tests {
221    use super::{resolve_agent_context, workspace_id_for_tab};
222    use crate::backend::obj::Tab;
223    use crate::backend::storage::store::Store;
224    use crate::backend::wcore;
225
226    #[test]
227    fn workspace_id_for_tab_finds_owner_and_misses_cleanly() {
228        let store = Store::open_in_memory().unwrap();
229        wcore::ensure_initial_data(&store).unwrap();
230        let tab = store
231            .get_all::<Tab>()
232            .unwrap()
233            .into_iter()
234            .next()
235            .expect("seeded tab");
236        assert!(workspace_id_for_tab(&store, &tab.oid).is_some(), "owner found");
237        assert!(workspace_id_for_tab(&store, "nope").is_none(), "miss is None");
238    }
239
240    // `ensure_initial_data` seeds one workspace ("Starter workspace") with a
241    // window and an initial tab holding a default agent block. The resolver
242    // must walk that agent block back up to its tab, workspace, and window.
243    #[test]
244    fn resolves_block_to_tab_workspace_and_window() {
245        let store = Store::open_in_memory().unwrap();
246        wcore::ensure_initial_data(&store).unwrap();
247
248        let tab = store
249            .get_all::<Tab>()
250            .unwrap()
251            .into_iter()
252            .next()
253            .expect("seeded tab");
254        let block_id = tab.blockids.first().expect("seeded agent block").clone();
255
256        let ctx = resolve_agent_context(&store, &block_id).expect("resolves context");
257        assert_eq!(ctx.tab_id, tab.oid);
258        assert_eq!(ctx.workspace_name, "Starter workspace");
259        assert!(ctx.workspace_id.is_some(), "workspace should resolve");
260        assert!(ctx.window_id.is_some(), "window should resolve via reverse lookup");
261    }
262
263    #[test]
264    fn unknown_block_errors() {
265        let store = Store::open_in_memory().unwrap();
266        wcore::ensure_initial_data(&store).unwrap();
267        let err = resolve_agent_context(&store, "does-not-exist").unwrap_err();
268        assert!(err.contains("block not found"), "unexpected error: {err}");
269    }
270}