1use std::sync::Arc;
23
24use crate::backend::notify::policy::{Family, FocusReport};
25use crate::backend::notify::router;
26use crate::backend::rpc::engine::WshRpcEngine;
27
28use super::AppState;
29
30#[derive(serde::Deserialize, ts_rs::TS)]
33#[serde(rename_all = "snake_case")]
34#[ts(export, export_to = "../../../frontend/types/rpc/")]
35pub enum NotifyPaneEvent {
36 TurnStarted,
40 TurnCompleted,
42 TurnErrored,
44 TurnStopped,
46 InputWaiting,
47 InputResolved,
48}
49
50#[derive(serde::Deserialize, ts_rs::TS)]
51#[ts(export, export_to = "../../../frontend/types/rpc/")]
52pub struct NotifyEmitParams {
53 pub block_id: String,
54 pub event: NotifyPaneEvent,
55 #[ts(optional)]
56 pub question: Option<String>,
57 #[ts(optional)]
59 pub question_count: Option<u32>,
60}
61
62#[derive(serde::Deserialize, ts_rs::TS)]
63#[ts(export, export_to = "../../../frontend/types/rpc/")]
64pub struct NotifyFocusParams {
65 pub window_focused: bool,
66 #[ts(optional)]
67 pub block_id: Option<String>,
68 #[ts(optional)]
70 pub window_id: Option<String>,
71}
72
73#[derive(serde::Deserialize, ts_rs::TS)]
74#[ts(export, export_to = "../../../frontend/types/rpc/")]
75pub struct NotifyAckParams {
76 pub id: String,
77 pub clicked: bool,
78}
79
80#[derive(serde::Serialize, serde::Deserialize, ts_rs::TS)]
83#[ts(export, export_to = "../../../frontend/types/rpc/")]
84pub struct NotifyAttentionResult {
85 pub attention: Vec<crate::backend::user_attention::UserAttentionEvent>,
86}
87
88#[derive(serde::Serialize, serde::Deserialize, ts_rs::TS)]
89#[ts(export, export_to = "../../../frontend/types/rpc/")]
90pub struct NotifyAckResult {
91 pub has_window: bool,
94 #[serde(default)]
96 pub known: bool,
97 #[serde(default, skip_serializing_if = "Option::is_none")]
99 #[ts(optional)]
100 pub window_id: Option<String>,
101 #[serde(default, skip_serializing_if = "Option::is_none")]
103 #[ts(optional)]
104 pub workspace_id: Option<String>,
105}
106
107#[derive(serde::Deserialize, ts_rs::TS)]
108#[ts(export, export_to = "../../../frontend/types/rpc/")]
109pub struct NotifyTakeActivationParams {
110 #[serde(default)]
113 #[ts(optional)]
114 pub workspace_id: Option<String>,
115}
116
117#[derive(serde::Serialize, serde::Deserialize, ts_rs::TS)]
118#[ts(export, export_to = "../../../frontend/types/rpc/")]
119pub struct NotifyTakeActivationResult {
120 #[ts(optional)]
121 pub block_id: Option<String>,
122 #[ts(optional)]
123 pub tab_id: Option<String>,
124}
125
126#[derive(serde::Deserialize, ts_rs::TS)]
127#[ts(export, export_to = "../../../frontend/types/rpc/")]
128pub struct NotifyNoArgs {}
129
130#[derive(serde::Serialize, serde::Deserialize, ts_rs::TS)]
131#[ts(export, export_to = "../../../frontend/types/rpc/")]
132pub struct NotifyOk {
133 pub ok: bool,
134}
135
136#[derive(serde::Deserialize, ts_rs::TS)]
137#[ts(export, export_to = "../../../frontend/types/rpc/")]
138pub struct BlockRevealParams {
139 pub block_id: String,
140}
141
142#[derive(serde::Serialize, serde::Deserialize, ts_rs::TS)]
143#[ts(export, export_to = "../../../frontend/types/rpc/")]
144pub struct BlockRevealResult {
145 pub found: bool,
148 #[serde(default, skip_serializing_if = "Option::is_none")]
150 #[ts(optional)]
151 pub window_id: Option<String>,
152}
153
154fn router_for(state: &AppState) -> Arc<router::Router> {
155 router::init(state.broker.clone(), state.config_watcher.clone(), state.mstore.clone(), state.reactive_handler)
156}
157
158pub(crate) async fn reveal_block(
164 state: &AppState,
165 r: &Arc<router::Router>,
166 block_id: String,
167) -> Result<BlockRevealResult, String> {
168 if block_id.is_empty() {
169 return Err("block.reveal: block_id required".to_string());
170 }
171 let rr = r.clone();
172 let target = tokio::task::spawn_blocking(move || rr.reveal_target(&block_id))
173 .await
174 .map_err(|e| format!("block.reveal: {e}"))?;
175 let Some(target) = target else {
176 return Ok(BlockRevealResult { found: false, window_id: None });
177 };
178 activate_in_stored_layout(state, &target.tab_id, &target.block_id).await;
179 r.publish_block_reveal(&target);
180 Ok(BlockRevealResult { found: true, window_id: Some(target.window_id) })
181}
182
183async fn activate_in_stored_layout(state: &AppState, tab_id: &str, block_id: &str) {
188 let events = crate::server::service::dispatch_to_reducer(
189 state,
190 agentmux_common::ipc::Command::LayoutStackActivate {
191 tab_id: tab_id.to_string(),
192 block_id: block_id.to_string(),
193 correlation_id: String::new(),
194 },
195 )
196 .await;
197 if let Some(msg) = events.iter().find_map(|e| match e {
198 agentmux_common::ipc::Event::Error { message, .. } => Some(message.clone()),
199 _ => None,
200 }) {
201 tracing::debug!(block_id, "block.reveal: stored layout not updated: {msg}");
202 return;
203 }
204 for ev in &events {
205 if let Err(e) = crate::persist_subscriber::apply_event_to_mstore(ev, &state.mstore) {
206 tracing::warn!(block_id, "block.reveal: mstore apply failed: {e}");
207 }
208 }
209 crate::server::service::publish_events(state, &events);
210}
211
212pub fn register_notify_handlers(engine: &Arc<WshRpcEngine>, state: &AppState, conn_id: String) {
213 let r = router_for(state);
214
215 let rr = r.clone();
216 engine.register_typed("notify.emit", move |p: NotifyEmitParams, _ctx| {
217 let r = rr.clone();
218 async move {
219 if p.block_id.is_empty() {
220 return Err("notify.emit: block_id required".to_string());
221 }
222 match p.event {
227 NotifyPaneEvent::TurnStarted | NotifyPaneEvent::TurnCompleted | NotifyPaneEvent::TurnErrored => {}
230 NotifyPaneEvent::TurnStopped => r.turn_stopped_nonblocking(&p.block_id),
231 NotifyPaneEvent::InputResolved => r.resolve_nonblocking(&p.block_id, Family::Input),
232 NotifyPaneEvent::InputWaiting => {
233 r.input_waiting_nonblocking(&p.block_id, p.question, p.question_count.unwrap_or(0) as usize)
234 }
235 }
236 Ok(NotifyOk { ok: true })
237 }
238 });
239
240 let rr = r.clone();
241 let conn = conn_id.clone();
242 engine.register_typed("notify.focus", move |p: NotifyFocusParams, _ctx| {
243 let r = rr.clone();
244 let conn = conn.clone();
245 async move {
246 r.focus(
247 &conn,
248 FocusReport {
249 window_focused: p.window_focused,
250 block_id: p.block_id.filter(|b| !b.is_empty()),
251 window_id: p.window_id.filter(|w| !w.is_empty()),
252 },
253 );
254 Ok(NotifyOk { ok: true })
255 }
256 });
257
258 let rr = r.clone();
259 engine.register_typed("notify.ack", move |p: NotifyAckParams, _ctx| {
260 let r = rr.clone();
261 async move {
262 let o = tokio::task::spawn_blocking(move || r.ack(&p.id, p.clicked))
264 .await
265 .map_err(|e| format!("notify.ack: {e}"))?;
266 Ok(NotifyAckResult { has_window: o.has_window, known: o.known, window_id: o.window_id, workspace_id: o.workspace_id })
267 }
268 });
269
270 let rr = r.clone();
271 engine.register_typed("notify.attention", |_p: NotifyNoArgs, _ctx| async move {
274 Ok(NotifyAttentionResult { attention: crate::backend::user_attention::open_requests() })
275 });
276
277 engine.register_typed("notify.test", move |_p: NotifyNoArgs, _ctx| {
278 let r = rr.clone();
279 async move {
280 r.test();
281 Ok(NotifyOk { ok: true })
282 }
283 });
284
285 let rr = r.clone();
286 engine.register_typed("notify.takeactivation", move |p: NotifyTakeActivationParams, _ctx| {
287 let r = rr.clone();
288 async move {
289 let taken = r.take_pending_activation(p.workspace_id.as_deref().filter(|w| !w.is_empty()));
290 Ok(NotifyTakeActivationResult {
291 block_id: taken.as_ref().map(|t| t.block_id.clone()),
292 tab_id: taken.map(|t| t.tab_id),
293 })
294 }
295 });
296
297 let rr = r;
298 let st = state.clone();
299 engine.register_typed("block.reveal", move |p: BlockRevealParams, _ctx| {
300 let r = rr.clone();
301 let st = st.clone();
302 async move { reveal_block(&st, &r, p.block_id).await }
303 });
304}
305
306#[cfg(test)]
307mod block_reveal_tests {
308 use std::sync::{Arc, Mutex};
309
310 use agentmux_common::ipc::{Command, Event};
311
312 use crate::backend::mps::MuxEvent;
313 use crate::backend::notify::policy::FocusReport;
314 use crate::backend::notify::router::{self, BlockRevealEvent, EVENT_BLOCK_REVEAL};
315 use crate::backend::obj::Window;
316 use crate::server::AppState;
317
318 async fn dispatch_apply(state: &AppState, cmd: Command) -> Vec<Event> {
319 let evs = crate::server::service::dispatch_to_reducer(state, cmd).await;
320 for ev in &evs {
321 crate::persist_subscriber::apply_event_to_mstore(ev, &state.mstore).unwrap();
322 }
323 evs
324 }
325
326 struct Fixture {
327 state: AppState,
328 router: Arc<router::Router>,
329 tab_id: String,
330 a: String,
332 b: String,
333 window_id: String,
334 reveals: Arc<Mutex<Vec<serde_json::Value>>>,
335 }
336
337 async fn fixture(open: bool) -> Fixture {
341 let state = crate::server::tests::test_state();
342 let ws_id = dispatch_apply(&state, Command::CreateWorkspace { name: "w".into() })
343 .await
344 .iter()
345 .find_map(|e| match e {
346 Event::WorkspaceCreated { workspace_id, .. } => Some(workspace_id.clone()),
347 _ => None,
348 })
349 .unwrap();
350 let tab_id = dispatch_apply(&state, Command::CreateTab { workspace_id: ws_id.clone(), name: "t".into() })
351 .await
352 .iter()
353 .find_map(|e| match e {
354 Event::TabCreated { tab_id, .. } => Some(tab_id.clone()),
355 _ => None,
356 })
357 .unwrap();
358 let mut ids = Vec::new();
359 for _ in 0..2 {
360 let id = dispatch_apply(&state, Command::CreateBlock { tab_id: tab_id.clone(), meta: serde_json::Value::Null })
361 .await
362 .iter()
363 .find_map(|e| match e {
364 Event::BlockCreated { block_id, .. } => Some(block_id.clone()),
365 _ => None,
366 })
367 .unwrap();
368 ids.push(id);
369 }
370 let (a, b) = (ids[0].clone(), ids[1].clone());
371 dispatch_apply(
372 &state,
373 Command::LayoutSetTree {
374 tab_id: tab_id.clone(),
375 new_tree: Some(agentmux_common::LayoutNode {
376 id: "pane".into(),
377 data: Some(agentmux_common::LayoutNodeData {
378 block_id: a.clone(),
379 block_stack: vec![a.clone(), b.clone()],
380 active_block_id: a.clone(),
381 ..Default::default()
382 }),
383 ..Default::default()
384 }),
385 correlation_id: String::new(),
386 slices: None,
387 },
388 )
389 .await;
390 let mut window = state.mstore.get_all::<Window>().unwrap().into_iter().next().expect("seeded window");
392 window.oid = uuid::Uuid::new_v4().to_string();
393 window.workspaceid = ws_id;
394 state.mstore.insert(&mut window).unwrap();
395
396 let reveals = Arc::new(Mutex::new(Vec::new()));
397 let rec = reveals.clone();
398 state.broker.add_observer(Arc::new(move |ev: &MuxEvent| {
399 if ev.event == EVENT_BLOCK_REVEAL {
400 rec.lock().unwrap().push(ev.data.clone().unwrap_or_default());
401 }
402 }));
403 let r = super::router_for(&state);
404 if open {
405 r.focus("conn-1", FocusReport { window_focused: false, block_id: None, window_id: Some(window.oid.clone()) });
406 }
407 Fixture { state, router: r, tab_id, a, b, window_id: window.oid, reveals }
408 }
409
410 #[tokio::test]
411 async fn reveals_a_background_pane_tab_in_the_window_showing_it() {
412 let f = fixture(true).await;
413 let res = super::reveal_block(&f.state, &f.router, f.b.clone()).await.unwrap();
414 assert!(res.found);
415 assert_eq!(res.window_id.as_deref(), Some(f.window_id.as_str()));
416
417 let tree = f.state.srv_state.lock().await.tabs[&f.tab_id].rootnode.clone().unwrap();
419 let data = tree.data.as_ref().unwrap();
420 assert_eq!((data.block_id.as_str(), data.active_block_id.as_str()), (f.b.as_str(), f.b.as_str()));
421 let tab = f.state.mstore.must_get::<crate::backend::obj::Tab>(&f.tab_id).unwrap();
422 let layout = f.state.mstore.must_get::<crate::backend::obj::LayoutState>(&tab.layoutstate).unwrap();
423 assert_eq!(layout.rootnode.as_ref(), Some(&tree), "db_layout has b active");
424
425 let reveals = f.reveals.lock().unwrap().clone();
427 assert_eq!(reveals.len(), 1, "{reveals:?}");
428 let ev: BlockRevealEvent = serde_json::from_value(reveals[0].clone()).unwrap();
429 assert_eq!(ev, BlockRevealEvent { block_id: f.b.clone(), tab_id: f.tab_id.clone(), window_id: f.window_id.clone() });
430 }
431
432 #[tokio::test]
433 async fn unknown_block_is_not_found_and_publishes_nothing() {
434 let f = fixture(true).await;
435 let res = super::reveal_block(&f.state, &f.router, "no-such-block".into()).await.unwrap();
436 assert!(!res.found);
437 assert_eq!(res.window_id, None);
438 assert!(f.reveals.lock().unwrap().is_empty());
439 }
440
441 #[tokio::test]
442 async fn workspace_open_in_no_window_is_not_found_and_changes_nothing() {
443 let f = fixture(false).await;
444 let res = super::reveal_block(&f.state, &f.router, f.b.clone()).await.unwrap();
445 assert!(!res.found);
446 assert!(f.reveals.lock().unwrap().is_empty());
447 let tree = f.state.srv_state.lock().await.tabs[&f.tab_id].rootnode.clone().unwrap();
448 assert_eq!(tree.data.as_ref().unwrap().active_block_id, f.a, "stored tree untouched");
449 }
450
451 #[tokio::test]
452 async fn empty_block_id_is_an_error() {
453 let f = fixture(true).await;
454 assert!(super::reveal_block(&f.state, &f.router, String::new()).await.is_err());
455 }
456}
457
458#[cfg(test)]
459mod live_tests {
460 use futures_util::{SinkExt, StreamExt};
461 use tokio_tungstenite::tungstenite::{ClientRequestBuilder, Message};
462
463 async fn next_event<S>(rx: &mut S, want: &str) -> serde_json::Value
464 where
465 S: futures_util::Stream<Item = Result<Message, tokio_tungstenite::tungstenite::Error>> + Unpin,
466 {
467 loop {
468 let msg = tokio::time::timeout(std::time::Duration::from_secs(5), rx.next())
469 .await
470 .unwrap_or_else(|_| panic!("timed out waiting for {want}"))
471 .unwrap()
472 .unwrap();
473 let Ok(text) = msg.to_text() else { continue };
474 let Ok(v) = serde_json::from_str::<serde_json::Value>(text) else { continue };
475 if v["data"]["command"] == "eventrecv" && v["data"]["data"]["event"] == want {
476 return v["data"]["data"]["data"].clone();
477 }
478 }
479 }
480
481 #[tokio::test]
486 async fn header_authed_client_receives_router_events_over_ws() {
487 let state = crate::server::tests::test_state();
488 state
492 .broker
493 .set_client(Box::new(crate::backend::eventbus::EventBusBridge::new(state.event_bus.clone())));
494 let auth = state.auth_key.clone();
495 let app = crate::server::build_router(state);
496 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
497 let addr = listener.local_addr().unwrap();
498 tokio::spawn(async move {
499 axum::serve(listener, app.into_make_service_with_connect_info::<std::net::SocketAddr>())
500 .await
501 .unwrap();
502 });
503
504 let uri: tokio_tungstenite::tungstenite::http::Uri = format!("ws://{addr}/ws").parse().unwrap();
505 let req = ClientRequestBuilder::new(uri).with_header("X-AuthKey", auth);
506 let (ws, _) = tokio_tungstenite::connect_async(req).await.expect("header auth accepted on /ws");
507 let (mut tx, mut rx) = ws.split();
508
509 let send = |cmd: &str, reqid: &str, data: serde_json::Value| {
510 serde_json::json!({"wscommand":"rpc","message":{"command":cmd,"reqid":reqid,"data":data}}).to_string()
511 };
512 for (i, ev) in ["notification", "notification:retract"].iter().enumerate() {
513 tx.send(Message::Text(send("eventsub", &format!("s{i}"), serde_json::json!({"event":ev,"scopes":[],"allscopes":true})).into()))
514 .await
515 .unwrap();
516 }
517 tx.send(Message::Text(send("notify.test", "t1", serde_json::json!({})).into())).await.unwrap();
518
519 let n = next_event(&mut rx, "notification").await;
520 assert_eq!(n["kind"], "test");
521 assert_eq!(n["title"], "AgentMux notifications are working");
522 let id = n["id"].as_str().unwrap().to_string();
523 let tag = n["tag"].as_str().unwrap().to_string();
524
525 tx.send(Message::Text(send("notify.ack", "a1", serde_json::json!({"id": id, "clicked": false})).into()))
526 .await
527 .unwrap();
528 let r = next_event(&mut rx, "notification:retract").await;
529 assert_eq!(r["tag"], tag);
530 }
531
532 #[tokio::test]
536 async fn clicking_a_pane_toast_names_its_open_window_and_tab() {
537 use crate::backend::obj::{Block, Window};
538 let state = crate::server::tests::test_state();
539 state
540 .broker
541 .set_client(Box::new(crate::backend::eventbus::EventBusBridge::new(state.event_bus.clone())));
542 let (broker, store) = (state.broker.clone(), state.mstore.clone());
543 crate::backend::wcore::ensure_initial_data(&store).unwrap();
544 let block = store.get_all::<Block>().unwrap().into_iter().next().expect("seeded block");
545 let window = store.get_all::<Window>().unwrap().into_iter().next().expect("seeded window");
546 let auth = state.auth_key.clone();
547 let app = crate::server::build_router(state);
548 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
549 let addr = listener.local_addr().unwrap();
550 tokio::spawn(async move {
551 axum::serve(listener, app.into_make_service_with_connect_info::<std::net::SocketAddr>())
552 .await
553 .unwrap();
554 });
555 let uri: tokio_tungstenite::tungstenite::http::Uri = format!("ws://{addr}/ws").parse().unwrap();
556 let (ws, _) = tokio_tungstenite::connect_async(ClientRequestBuilder::new(uri).with_header("X-AuthKey", auth))
557 .await
558 .unwrap();
559 let (mut tx, mut rx) = ws.split();
560 let send = |cmd: &str, reqid: &str, data: serde_json::Value| {
561 serde_json::json!({"wscommand":"rpc","message":{"command":cmd,"reqid":reqid,"data":data}}).to_string()
562 };
563 for (i, ev) in ["notification", "notification:activate"].iter().enumerate() {
564 tx.send(Message::Text(send("eventsub", &format!("s{i}"), serde_json::json!({"event":ev,"scopes":[],"allscopes":true})).into()))
565 .await
566 .unwrap();
567 }
568 tx.send(Message::Text(send("notify.focus", "f1", serde_json::json!({"window_focused": false, "window_id": window.oid})).into()))
570 .await
571 .unwrap();
572 let r = crate::backend::notify::router::get(&broker).expect("router created by the connection");
573 let b = block.oid.clone();
574 tokio::task::spawn_blocking(move || {
575 r.emit_fixed(crate::backend::notify::policy::NotifyKind::MessageNeedsReview, &b, Some("x".into()))
576 })
577 .await
578 .unwrap();
579 let n = next_event(&mut rx, "notification").await;
580 let id = n["id"].as_str().unwrap().to_string();
581
582 tx.send(Message::Text(send("notify.ack", "a1", serde_json::json!({"id": id, "clicked": true})).into()))
583 .await
584 .unwrap();
585 let (mut activate, mut response) = (None, None);
586 while activate.is_none() || response.is_none() {
587 let msg = tokio::time::timeout(std::time::Duration::from_secs(5), rx.next()).await.expect("ack answered").unwrap().unwrap();
588 let Ok(v) = serde_json::from_str::<serde_json::Value>(msg.to_text().unwrap_or("")) else { continue };
589 if v["data"]["resid"] == "a1" {
590 response = Some(v["data"]["data"].clone());
591 } else if v["data"]["command"] == "eventrecv" && v["data"]["data"]["event"] == "notification:activate" {
592 activate = Some(v["data"]["data"]["data"].clone());
593 }
594 }
595 let (a, resp) = (activate.unwrap(), response.unwrap());
596 let tab_id = block.parentoref.strip_prefix("tab:").unwrap();
597 assert_eq!(a["block_id"], block.oid.as_str());
598 assert_eq!(a["tab_id"], tab_id);
599 assert_eq!(a["window_id"], window.oid.as_str());
600 assert_eq!(a["workspace_id"], window.workspaceid.as_str());
601 assert_eq!(resp["window_id"], window.oid.as_str(), "the launcher raises that window: {resp}");
602 assert_eq!(resp["known"], true);
603 assert_eq!(resp["has_window"], true);
604 }
605}