1use super::*;
2use agentmux_common::AUTH_KEY_HEADER;
3
4pub fn register(engine: &Arc<WshRpcEngine>, state: &AppState) {
5 register_pane_open(engine, state);
6 register_pane_move_tab(engine, state);
7}
8
9fn register_pane_open(engine: &Arc<WshRpcEngine>, state: &AppState) {
10 let state = state.clone();
11 engine.register_typed(COMMAND_PANE_OPEN, move |mut cmd: CommandPaneOpenData, ctx| {
12 let state = state.clone();
13 async move {
14 if let Some(meta) = cmd.meta.as_mut() {
15 crate::server::browser_owner::strip_srv_only_keys(meta);
16 crate::server::browser_identity::check_new_tab(&state, meta)?;
18 if !ctx.agent_id.is_empty() {
20 crate::server::browser_identity::check_agent_may_use(meta)?;
21 }
22 }
23 open_pane(&state, cmd).await
24 }
25 });
26}
27
28fn register_pane_move_tab(engine: &Arc<WshRpcEngine>, state: &AppState) {
30 let state = state.clone();
31 engine.register_typed(COMMAND_PANE_MOVE_TAB, move |cmd: CommandPaneMoveTabData, _ctx| {
32 let state = state.clone();
33 async move { super::move_tab(&state, cmd).await }
34 });
35}
36
37pub(super) async fn open_pane_floating(
45 state: &AppState,
46 mstore: &Store,
47 event_bus: &crate::backend::eventbus::EventBus,
48 view: String,
49 source_tab_id: String,
50 meta: MetaMapType,
51) -> Result<PaneOpenResult, String> {
52 use agentmux_common::ipc::{Command, Event};
53
54 let source_ws_id = {
56 let s = state.srv_state.lock().await;
57 s.tabs
58 .get(&source_tab_id)
59 .map(|t| t.workspace_id.clone())
60 .ok_or_else(|| format!("pane.open: floating: tab {source_tab_id} not in reducer state"))?
61 };
62
63 let meta_val = serde_json::to_value(&meta)
71 .map_err(|e| format!("pane.open: floating: meta serialize: {e}"))?;
72 let create_events = crate::server::service::dispatch_to_reducer(
73 state,
74 Command::CreateBlock {
75 tab_id: source_tab_id.clone(),
76 meta: meta_val,
77 },
78 )
79 .await;
80 if let Some(msg) = create_events.iter().find_map(|e| match e {
81 Event::Error { message, .. } => Some(message.clone()),
82 _ => None,
83 }) {
84 return Err(format!("pane.open: floating: CreateBlock: {msg}"));
85 }
86 let block_id = create_events
87 .iter()
88 .find_map(|e| match e {
89 Event::BlockCreated { block_id, .. } => Some(block_id.clone()),
90 _ => None,
91 })
92 .ok_or_else(|| "pane.open: floating: CreateBlock emitted no BlockCreated".to_string())?;
93 for ev in &create_events {
94 if let Err(e) = crate::persist_subscriber::apply_event_to_mstore(ev, mstore) {
95 tracing::warn!("pane.open: floating: CreateBlock mstore apply failed: {e}");
96 }
97 }
98 crate::server::service::publish_events(state, &create_events);
99
100 let saga_val = crate::sagas::tear_off_block::run(
103 state,
104 block_id.clone(),
105 source_tab_id.clone(),
106 source_ws_id.clone(),
107 )
108 .await?;
109 let new_ws_id = saga_val
110 .get("new_workspace_id")
111 .and_then(|v| v.as_str())
112 .unwrap_or_default()
113 .to_string();
114 let new_tab_id = saga_val
115 .get("new_tab_id")
116 .and_then(|v| v.as_str())
117 .unwrap_or_default()
118 .to_string();
119 if new_ws_id.is_empty() || new_tab_id.is_empty() {
120 return Err("pane.open: floating: tear_off_block returned empty ids".to_string());
121 }
122
123 if let Err(e) =
125 crate::server::service::setup_torn_off_block_layout(state, &new_tab_id, &block_id).await
126 {
127 tracing::warn!(
128 new_tab = %new_tab_id,
129 "pane.open: floating: layout setup failed: {e} (block moved but layout malformed)"
130 );
131 }
132
133 {
136 let mut updates: Vec<obj::MuxObjUpdate> = Vec::new();
137 if let Ok(ws) = mstore.must_get::<Workspace>(&new_ws_id) {
138 updates.push(obj::MuxObjUpdate {
139 updatetype: "update".into(),
140 otype: "workspace".into(),
141 oid: new_ws_id.clone(),
142 obj: Some(obj::mux_obj_to_value(&ws)),
143 });
144 }
145 if let Ok(t) = mstore.must_get::<Tab>(&new_tab_id) {
146 if let Ok(layout) = mstore.must_get::<obj::LayoutState>(&t.layoutstate) {
147 updates.push(obj::MuxObjUpdate {
148 updatetype: "update".into(),
149 otype: "layout".into(),
150 oid: t.layoutstate.clone(),
151 obj: Some(obj::mux_obj_to_value(&layout)),
152 });
153 }
154 updates.push(obj::MuxObjUpdate {
155 updatetype: "update".into(),
156 otype: "tab".into(),
157 oid: new_tab_id.clone(),
158 obj: Some(obj::mux_obj_to_value(&t)),
159 });
160 }
161 if let Ok(b) = mstore.must_get::<Block>(&block_id) {
162 updates.push(obj::MuxObjUpdate {
163 updatetype: "update".into(),
164 otype: "block".into(),
165 oid: block_id.clone(),
166 obj: Some(obj::mux_obj_to_value(&b)),
167 });
168 }
169 event_bus.broadcast_mux_obj_updates(&updates);
172 }
173
174 let window_id = {
179 let s = state.srv_state.lock().await;
180 s.windows
181 .iter()
182 .find(|(_, w)| w.workspace_id == source_ws_id)
183 .map(|(id, _)| id.clone())
184 };
185 match window_id {
186 Some(win) => {
187 let open = crate::backend::mps_events::OpenFloatingPaneEvent { block_id: block_id.to_string(), workspace_id: new_ws_id.to_string() };
188 state.broker.publish_event::<crate::backend::mps_events::OpenFloatingPane>(&open, vec![win], 0);
189 }
190 None => {
191 tracing::warn!(
192 source_ws = %source_ws_id,
193 "pane.open: floating: no window mapped to source workspace — floater not opened"
194 );
195 }
196 }
197
198 Ok(PaneOpenResult {
199 block_id,
200 tab_id: new_tab_id,
201 view,
202 created: true,
203 })
204}
205
206pub(crate) fn build_pane_meta(cmd: &CommandPaneOpenData) -> Result<MetaMapType, String> {
208 let mut meta = MetaMapType::new();
209
210 match cmd.view.as_str() {
211 "editor" => {
212 let file = cmd.file.as_deref().filter(|s| !s.is_empty())
213 .ok_or_else(|| "MISSING_ARG: view=editor requires 'file'".to_string())?;
214 meta.insert("view".to_string(), json!("editor"));
215 meta.insert("file".to_string(), json!(file));
216
217 let lower = file.to_ascii_lowercase();
218 let is_markdown = lower.ends_with(".md") || lower.ends_with(".mdx");
219
220 if let Some(expanded) = cmd.tree_expanded {
223 meta.insert("editor:tree_expanded".to_string(), json!(expanded));
224 } else if is_markdown {
225 meta.insert("editor:tree_expanded".to_string(), json!(false));
226 }
227 if let Some(line) = cmd.line.filter(|&n| n > 0) {
228 meta.insert("editor:line".to_string(), json!(line));
229 }
230 }
231 "term" => {
232 meta.insert("view".to_string(), json!("term"));
233 meta.insert("controller".to_string(), json!("shell"));
234 if let Some(cwd) = cmd.cwd.as_deref().filter(|s| !s.is_empty()) {
235 meta.insert("cmd:cwd".to_string(), json!(cwd));
236 }
237 }
238 "browser" => {
239 let url = cmd.url.as_deref().filter(|s| !s.is_empty())
240 .ok_or_else(|| "MISSING_ARG: view=browser requires 'url'".to_string())?;
241 meta.insert("view".to_string(), json!("browser"));
242 meta.insert("url".to_string(), json!(url));
243 }
244 "sysinfo" => {
245 meta.insert("view".to_string(), json!("sysinfo"));
246 }
247 "help" => {
248 meta.insert("view".to_string(), json!("help"));
249 }
250 "tower" => {
253 meta.insert("view".to_string(), json!("tower"));
254 }
255 "media" => {
256 let file = cmd.file.as_deref().filter(|s| !s.is_empty())
257 .ok_or_else(|| "MISSING_ARG: view=media requires 'file'".to_string())?;
258 meta.insert("view".to_string(), json!("media"));
259 meta.insert("media:path".to_string(), json!(file));
260 }
261 "files" => {
266 let path = cmd
267 .file
268 .as_deref()
269 .filter(|s| !s.is_empty())
270 .or_else(|| cmd.cwd.as_deref().filter(|s| !s.is_empty()))
271 .map(str::to_string)
272 .or_else(|| dirs::home_dir().map(|h| h.to_string_lossy().into_owned()))
273 .ok_or_else(|| "MISSING_ARG: view=files requires 'file' (no home directory to default to)".to_string())?;
274 meta.insert("view".to_string(), json!("files"));
275 meta.insert("files:path".to_string(), json!(path));
276 if let Some(select) = cmd.select.as_ref().filter(|s| !s.is_empty()) {
277 meta.insert("files:select".to_string(), json!(select));
278 }
279 }
280 other => {
281 return Err(format!(
282 "INVALID_VIEW: unsupported view '{other}' (expected editor/term/browser/sysinfo/help/media/files/tower)"
283 ));
284 }
285 }
286
287 if let Some(title) = cmd.title.as_deref().filter(|s| !s.is_empty()) {
288 meta.insert("frame:title".to_string(), json!(title));
289 }
290
291 if let Some(conn) = cmd.connection.as_deref().map(str::trim).filter(|c| !c.is_empty() && *c != "local") {
294 match cmd.view.as_str() {
295 "editor" | "term" | "files" => {
296 meta.insert("connection".to_string(), json!(conn));
297 }
298 "media" if crate::backend::fs_ops::remote::ssh_connection(Some(conn)).is_some() => {
299 return Err(format!("INVALID_ARG: media files on {conn} can't be shown yet"));
300 }
301 _ => {}
302 }
303 }
304 Ok(meta)
305}
306
307const META_PENDING_OPEN_FILES: &str = "editor:pending_open_files";
352
353pub(super) async fn maybe_reuse_editor_pane(
381 state: &AppState,
382 caller_block_id: &str,
383 file: &str,
384 connection: &str,
385) -> Result<Option<PaneOpenResult>, String> {
386 let mstore = &state.mstore;
387 let tab_id = match super::resolve_tab_id_for_block(mstore, caller_block_id) {
388 Ok(id) => id,
389 Err(_) => return Ok(None), };
391
392 let existing = match super::find_editor_block(mstore, &tab_id, connection)? {
395 Some(block) => block,
396 None => return Ok(None),
397 };
398
399 let mut pending: Vec<String> = existing
407 .meta
408 .get(META_PENDING_OPEN_FILES)
409 .and_then(|v| v.as_array())
410 .map(|arr| arr.iter().filter_map(|v| v.as_str().map(str::to_string)).collect())
411 .unwrap_or_default();
412 pending.push(file.to_string());
413
414 let meta_events = crate::server::service::dispatch_to_reducer(
415 state,
416 agentmux_common::ipc::Command::UpdateBlockMeta {
417 block_id: existing.oid.clone(),
418 meta_patch: json!({ META_PENDING_OPEN_FILES: pending }),
419 },
420 )
421 .await;
422 for ev in &meta_events {
423 if let Err(e) = crate::persist_subscriber::apply_event_to_mstore(ev, mstore) {
424 tracing::warn!("pane.open: reuse: UpdateBlockMeta mstore apply failed: {e}");
425 }
426 }
427 crate::server::service::publish_events(state, &meta_events);
428
429 tracing::info!(
430 block_id = %existing.oid,
431 tab_id = %tab_id,
432 "pane.open: reused existing editor pane instead of creating a new one"
433 );
434
435 Ok(Some(PaneOpenResult {
436 block_id: existing.oid,
437 tab_id,
438 view: "editor".to_string(),
439 created: false,
440 }))
441}
442
443pub(super) fn resolve_placement(
447 direction: Option<&str>,
448 reference: Option<&str>,
449) -> (String, String, String) {
450 let reference = match reference.filter(|s| !s.is_empty()) {
451 Some(r) => r,
452 None => return ("insert".to_string(), String::new(), String::new()),
453 };
454
455 let (actiontype, position) = match direction {
456 Some("right") => (crate::backend::wcore::LAYOUT_ACTION_SPLIT_HORIZONTAL, "after"),
457 Some("left") => (crate::backend::wcore::LAYOUT_ACTION_SPLIT_HORIZONTAL, "before"),
458 Some("down") | Some("below") => (crate::backend::wcore::LAYOUT_ACTION_SPLIT_VERTICAL, "after"),
459 Some("up") | Some("above") => (crate::backend::wcore::LAYOUT_ACTION_SPLIT_VERTICAL, "before"),
460 _ => return ("insert".to_string(), String::new(), String::new()),
461 };
462
463 (actiontype.to_string(), reference.to_string(), position.to_string())
464}
465
466pub(crate) async fn handle_quit_self(
485 axum::extract::State(state): axum::extract::State<AppState>,
486 caller: Option<axum::Extension<crate::server::caller::Caller>>,
487 axum::Json(req): axum::Json<agentmux_common::api_types::QuitSelfRequest>,
488) -> impl axum::response::IntoResponse {
489 use axum::http::StatusCode;
490 use axum::response::IntoResponse;
491 use axum::Json;
492
493 let block_id = match crate::server::ui_handlers::verified_block_id(&state, caller.as_deref(), &req.auth) {
494 Ok(b) => b,
495 Err(e) => return (StatusCode::UNAUTHORIZED, Json(json!({ "error": e }))).into_response(),
496 };
497 quit_self(&state, &block_id, &req.auth.agent_id, "QuitSelf", req.reason.trim(), &req.user_instruction)
498}
499
500fn quit_self(
505 state: &AppState,
506 block_id: &str,
507 agent: &str,
508 via: &str,
509 reason: &str,
510 user_instruction: &str,
511) -> axum::response::Response {
512 use crate::sagas::self_quit;
513 use axum::http::StatusCode;
514 use axum::response::IntoResponse;
515 use axum::Json;
516
517 let detail = format!("{reason} | user said: {user_instruction:?}");
518 let under_way = crate::sagas::pending_shutdown::active_for(block_id).is_some_and(|v| v.status == "proceeding");
520 if self_quit::is_quitting(block_id) || under_way {
521 return (StatusCode::OK, Json(json!({ "status": "already_quitting" }))).into_response();
522 }
523 let provenance = crate::backend::blockcontroller::get_controller(block_id).and_then(|c| c.turn_provenance());
524 if let Err(refusal) = self_quit::gate(provenance.as_ref(), user_instruction) {
525 let pending = crate::sagas::pending_shutdown::request(
528 state,
529 block_id,
530 agent,
531 via,
532 reason,
533 crate::sagas::pending_shutdown::Action::SelfQuit { detail: detail.clone() },
534 );
535 state.reactive_handler.log_fleet_action_audit(
536 Some(agent),
537 agent,
538 block_id,
539 "agent.quit_self",
540 true,
541 Some(refusal.as_str()),
542 &pending.request_id,
543 Some(&format!("{} ({via}): {detail}", crate::sagas::pending_shutdown::audit_note(&pending, agent, via))),
544 );
545 return (StatusCode::ACCEPTED, Json(pending_body(&pending))).into_response();
546 }
547 if !self_quit::schedule_after_turn(state, block_id, detail) {
548 return (StatusCode::OK, Json(json!({ "status": "already_quitting" }))).into_response();
549 }
550 (StatusCode::ACCEPTED, Json(json!({ "status": "scheduled" }))).into_response()
551}
552
553pub(crate) fn pending_body(p: &crate::sagas::pending_shutdown::PendingView) -> serde_json::Value {
555 json!({
556 "status": "pending_user_override",
557 "request_id": p.request_id,
558 "by": p.by,
560 "via": p.via,
561 "deadline_ms": p.deadline_ms,
562 "wait_at_least_ms": crate::sagas::pending_shutdown::OVERRIDE_WINDOW.as_millis() as u64,
563 })
564}
565
566pub(crate) async fn handle_shutdown_status(
570 axum::extract::State(state): axum::extract::State<AppState>,
571 axum::extract::Path(request_id): axum::extract::Path<String>,
572) -> impl axum::response::IntoResponse {
573 use axum::http::StatusCode;
574 use axum::response::IntoResponse;
575 use axum::Json;
576 if let Some(v) = crate::sagas::pending_shutdown::status(&request_id) {
577 return (StatusCode::OK, Json(serde_json::to_value(v).unwrap_or_default())).into_response();
578 }
579 if let Some(remote) = crate::sagas::pending_shutdown::remote_for(&request_id) {
582 let url = format!("{}/api/v1/agent/shutdown/{request_id}", remote.local_url.trim_end_matches('/'));
583 let mut req = state.http_client.get(&url);
584 if !remote.auth_key.is_empty() {
585 req = req.header(AUTH_KEY_HEADER, &remote.auth_key);
586 }
587 return match req.send().await {
588 Ok(r) => {
589 let code = StatusCode::from_u16(r.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY);
590 let body: serde_json::Value = r.json().await.unwrap_or_default();
591 (code, Json(body)).into_response()
592 }
593 Err(e) => (StatusCode::BAD_GATEWAY, Json(json!({ "error": format!("its AgentMux instance: {e}") }))).into_response(),
594 };
595 }
596 (StatusCode::NOT_FOUND, Json(json!({ "error": "no such shutdown request" }))).into_response()
597}
598
599pub(crate) async fn handle_close_pane(
600 axum::extract::State(state): axum::extract::State<AppState>,
601 caller: Option<axum::Extension<crate::server::caller::Caller>>,
602 axum::Json(req): axum::Json<agentmux_common::api_types::ClosePaneRequest>,
603) -> impl axum::response::IntoResponse {
604 use axum::http::StatusCode;
605 use axum::response::IntoResponse;
606 use axum::Json;
607
608 let caller_agent_id = req.auth.agent_id.clone();
609 let verified_own_block_id = match crate::server::ui_handlers::verified_block_id(&state, caller.as_deref(), &req.auth) {
610 Ok(b) => b,
611 Err(e) => return (StatusCode::UNAUTHORIZED, Json(json!({ "error": e }))).into_response(),
612 };
613
614 if req.block_id.is_none() {
618 let reason = req.reason.clone().unwrap_or_default();
619 return quit_self(&state, &verified_own_block_id, &caller_agent_id, "ClosePane", reason.trim(), "");
620 }
621
622 let target = req.block_id.clone().unwrap_or_else(|| verified_own_block_id.clone());
623 let (code, body) = close_pane_as(&state, &caller_agent_id, &verified_own_block_id, &target, req.reason.as_deref(), "ClosePane").await;
624 (code, Json(body)).into_response()
625}
626
627pub(crate) async fn close_pane_as(
635 state: &AppState,
636 caller_agent_id: &str,
637 own_block_id: &str,
638 target_block_id: &str,
639 reason: Option<&str>,
640 via: &str,
641) -> (axum::http::StatusCode, serde_json::Value) {
642 use axum::http::StatusCode;
643 let target_block_id = target_block_id.to_string();
644 let verified_own_block_id = own_block_id.to_string();
645 let caller_agent_id = caller_agent_id.to_string();
646
647 let target_agent = state
654 .reactive_handler
655 .get_agent_by_block(&target_block_id)
656 .map(|a| a.agent_id)
657 .unwrap_or_else(|| target_block_id.clone());
658
659 let pane_block_ids = {
662 let s = state.srv_state.lock().await;
663 match s.blocks.get(&target_block_id) {
664 Some(b) => s
665 .tabs
666 .get(&b.tab_id)
667 .and_then(|t| t.rootnode.as_ref())
668 .and_then(|root| {
669 crate::backend::layout::find_leaf_containing_block(root, &target_block_id)
670 })
671 .and_then(|leaf| leaf.data.as_ref())
672 .map(crate::backend::layout::leaf_members)
673 .unwrap_or_else(|| vec![target_block_id.clone()]),
674 None => {
675 let err = format!("block not found: {target_block_id}");
676 let request_id = uuid::Uuid::new_v4().to_string();
677 state.reactive_handler.log_fleet_action_audit(
678 Some(&caller_agent_id), &target_agent, &target_block_id,
679 "pane.close", false, Some(&err), &request_id, reason,
680 );
681 return (StatusCode::NOT_FOUND, json!({ "error": err }));
682 }
683 }
684 };
685
686 if !pane_block_ids.contains(&verified_own_block_id) {
689 let reason = reason.unwrap_or_default().to_string();
690 let pending = crate::sagas::pending_shutdown::request(
691 state,
692 &target_block_id,
693 &caller_agent_id,
694 via,
695 &reason,
696 crate::sagas::pending_shutdown::Action::ClosePane { block_ids: pane_block_ids },
697 );
698 state.reactive_handler.log_fleet_action_audit(
699 Some(&caller_agent_id),
700 &target_agent,
701 &target_block_id,
702 "pane.close",
703 true,
704 None,
705 &pending.request_id,
706 Some(&format!("{}: {reason}", crate::sagas::pending_shutdown::audit_note(&pending, &caller_agent_id, via))),
707 );
708 return (StatusCode::ACCEPTED, pending_body(&pending));
709 }
710
711 let result = crate::sagas::close_pane::run(state, pane_block_ids).await;
712
713 let request_id = uuid::Uuid::new_v4().to_string();
714 match &result {
715 Ok(_) => state.reactive_handler.log_fleet_action_audit(
716 Some(&caller_agent_id), &target_agent, &target_block_id,
717 "pane.close", true, None, &request_id, reason,
718 ),
719 Err(e) => state.reactive_handler.log_fleet_action_audit(
720 Some(&caller_agent_id), &target_agent, &target_block_id,
721 "pane.close", false, Some(e), &request_id, reason,
722 ),
723 }
724
725 match result {
726 Ok(v) => (StatusCode::OK, v),
727 Err(e) => (StatusCode::BAD_REQUEST, json!({ "error": e })),
728 }
729}
730
731#[cfg(test)]
732mod close_pane_tests {
733 use super::*;
734 use axum::response::IntoResponse;
735 use crate::server::tests::test_state;
736 use agentmux_common::api_types::{ClosePaneRequest, UiAutomationAuth};
737 use agentmux_common::ipc::{Command, Event};
738
739 async fn dispatch_apply(state: &AppState, cmd: Command) -> Vec<Event> {
740 let evs = crate::server::service::dispatch_to_reducer(state, cmd).await;
741 for ev in &evs {
742 crate::persist_subscriber::apply_event_to_mstore(ev, &state.mstore).unwrap();
743 }
744 evs
745 }
746
747 async fn seed(state: &AppState) -> (String, String) {
751 let ws_evs = dispatch_apply(state, Command::CreateWorkspace { name: "w".into() }).await;
752 let ws_id = ws_evs
753 .iter()
754 .find_map(|e| match e {
755 Event::WorkspaceCreated { workspace_id, .. } => Some(workspace_id.clone()),
756 _ => None,
757 })
758 .unwrap();
759 let tab_evs = dispatch_apply(
760 state,
761 Command::CreateTab { workspace_id: ws_id, name: "t".into() },
762 )
763 .await;
764 let tab_id = tab_evs
765 .iter()
766 .find_map(|e| match e {
767 Event::TabCreated { tab_id, .. } => Some(tab_id.clone()),
768 _ => None,
769 })
770 .unwrap();
771 let blk_evs = dispatch_apply(
772 state,
773 Command::CreateBlock { tab_id: tab_id.clone(), meta: serde_json::Value::Null },
774 )
775 .await;
776 let block_id = blk_evs
777 .iter()
778 .find_map(|e| match e {
779 Event::BlockCreated { block_id, .. } => Some(block_id.clone()),
780 _ => None,
781 })
782 .unwrap();
783 (tab_id, block_id)
784 }
785
786 fn sign_auth(state: &AppState, agent_id: &str) -> UiAutomationAuth {
791 let key = state.mstore.agent_jekt_key_ensure(agent_id).unwrap();
792 let ts_secs = agentmux_common::time::now_secs();
793 let sig = agentmux_common::jekt_sign::sign_jekt(
794 &key, "ui-automation-identity", agent_id, "__srv__", ts_secs, "",
795 );
796 UiAutomationAuth { agent_id: agent_id.to_string(), ts_secs, sig }
797 }
798
799 #[tokio::test]
802 async fn no_argument_close_is_a_self_quit_behind_the_user_s_window() {
803 let state = test_state();
804 let (tab_id, block_id) = seed(&state).await;
805 let sibling = dispatch_apply(&state, Command::CreateBlock { tab_id: tab_id.clone(), meta: serde_json::Value::Null })
806 .await
807 .iter()
808 .find_map(|e| match e {
809 Event::BlockCreated { block_id, .. } => Some(block_id.clone()),
810 _ => None,
811 })
812 .unwrap();
813 let agent_id = format!("close-own-{}", uuid::Uuid::new_v4());
814 state.reactive_handler.register_agent(&agent_id, &block_id, Some(&tab_id)).unwrap();
815 let auth = sign_auth(&state, &agent_id);
816
817 let resp = handle_close_pane(
818 axum::extract::State(state.clone()),
819 None,
820 axum::Json(ClosePaneRequest { auth, block_id: None, reason: Some("done".into()) }),
821 )
822 .await
823 .into_response();
824 assert_eq!(resp.status(), axum::http::StatusCode::ACCEPTED);
825 let body = body_json(resp).await;
826 assert_eq!(body["status"], "pending_user_override", "no quote: the user is asked");
827 let request_id = body["request_id"].as_str().unwrap().to_string();
828 let pending = crate::sagas::pending_shutdown::status(&request_id).unwrap();
829 assert_eq!((pending.via.as_str(), pending.by.as_str()), ("ClosePane", agent_id.as_str()));
830 assert!(state.srv_state.lock().await.blocks.contains_key(&block_id), "still running during the window");
831
832 crate::sagas::pending_shutdown::expire_now(&request_id);
833 assert_eq!(crate::sagas::pending_shutdown::settled(&request_id).await.status, "shut_down");
834 let s = state.srv_state.lock().await;
835 assert!(!s.blocks.contains_key(&block_id), "its own tab is gone");
836 assert!(s.blocks.contains_key(&sibling), "a sibling tab keeps running");
837 }
838
839 #[tokio::test]
840 async fn cross_pane_close_removes_the_target_and_logs_the_verified_caller() {
841 let state = test_state();
842 let (caller_tab, caller_block) = seed(&state).await;
844 let caller_id = format!("close-caller-{}", uuid::Uuid::new_v4());
845 state.reactive_handler.register_agent(&caller_id, &caller_block, Some(&caller_tab)).unwrap();
846 let auth = sign_auth(&state, &caller_id);
847
848 let (target_tab, target_block) = seed(&state).await;
850 let target_id = format!("close-target-{}", uuid::Uuid::new_v4());
851 state.reactive_handler.register_agent(&target_id, &target_block, Some(&target_tab)).unwrap();
852
853 let resp = handle_close_pane(
854 axum::extract::State(state.clone()),
855 None,
856 axum::Json(ClosePaneRequest {
857 auth,
858 block_id: Some(target_block.clone()),
859 reason: Some("verifying the fix".to_string()),
860 }),
861 )
862 .await
863 .into_response();
864 assert_eq!(resp.status(), axum::http::StatusCode::ACCEPTED, "another agent's pane waits for its user");
865 let request_id = body_json(resp).await["request_id"].as_str().unwrap().to_string();
866 assert!(state.srv_state.lock().await.blocks.contains_key(&target_block), "still running during the window");
867 crate::sagas::pending_shutdown::expire_now(&request_id);
868 assert_eq!(crate::sagas::pending_shutdown::settled(&request_id).await.status, "shut_down");
869
870 {
872 let s = state.srv_state.lock().await;
873 assert!(!s.blocks.contains_key(&target_block), "target pane must be closed");
874 assert!(s.blocks.contains_key(&caller_block), "caller's own pane must be untouched");
875 }
876
877 let entries = state.reactive_handler.get_audit_log(50);
883 let entry = entries
884 .iter()
885 .find(|e| e.block_id == target_block)
886 .expect("expected an audit entry for the closed target block");
887 assert_eq!(entry.source_agent.as_deref(), Some(caller_id.as_str()));
888 assert_eq!(entry.target_agent, target_id);
889 assert!(entry.success);
890 let reason = entry.reason.as_deref().unwrap_or_default();
891 assert!(reason.starts_with("shut_down") && reason.contains("verifying the fix"), "{reason}");
892 }
893
894 #[tokio::test]
895 async fn keep_running_leaves_another_agent_s_pane_open() {
896 let state = test_state();
897 let (caller_tab, caller_block) = seed(&state).await;
898 let caller_id = format!("close-keep-caller-{}", uuid::Uuid::new_v4());
899 state.reactive_handler.register_agent(&caller_id, &caller_block, Some(&caller_tab)).unwrap();
900 let (_, target_block) = seed(&state).await;
901
902 let resp = handle_close_pane(
903 axum::extract::State(state.clone()),
904 None,
905 axum::Json(ClosePaneRequest {
906 auth: sign_auth(&state, &caller_id),
907 block_id: Some(target_block.clone()),
908 reason: None,
909 }),
910 )
911 .await
912 .into_response();
913 assert_eq!(resp.status(), axum::http::StatusCode::ACCEPTED);
914 let body = body_json(resp).await;
915 assert_eq!(body["status"], "pending_user_override");
916 assert_eq!(body["wait_at_least_ms"], 15_000);
917 let request_id = body["request_id"].as_str().unwrap().to_string();
918
919 assert_eq!(crate::sagas::pending_shutdown::keep(&state, &target_block, &request_id), "kept_by_user");
920 assert_eq!(crate::sagas::pending_shutdown::settled(&request_id).await.status, "kept_by_user");
921 assert!(state.srv_state.lock().await.blocks.contains_key(&target_block), "the user kept it");
922 }
923
924 async fn body_json(resp: axum::response::Response) -> serde_json::Value {
925 let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap();
926 serde_json::from_slice(&bytes).unwrap()
927 }
928
929 #[tokio::test]
932 async fn close_closes_every_tab_in_the_target_s_pane() {
933 let state = test_state();
934 let (caller_tab, caller_block) = seed(&state).await;
935 let caller_id = format!("close-stack-caller-{}", uuid::Uuid::new_v4());
936 state.reactive_handler.register_agent(&caller_id, &caller_block, Some(&caller_tab)).unwrap();
937 let auth = sign_auth(&state, &caller_id);
938
939 let (tab_id, visible) = seed(&state).await;
940 let background = dispatch_apply(
941 &state,
942 Command::CreateBlock { tab_id: tab_id.clone(), meta: serde_json::Value::Null },
943 )
944 .await
945 .iter()
946 .find_map(|e| match e {
947 Event::BlockCreated { block_id, .. } => Some(block_id.clone()),
948 _ => None,
949 })
950 .unwrap();
951 dispatch_apply(
952 &state,
953 Command::LayoutSetTree {
954 tab_id: tab_id.clone(),
955 new_tree: Some(agentmux_common::LayoutNode {
956 id: "leaf".into(),
957 data: Some(agentmux_common::LayoutNodeData {
958 block_id: visible.clone(),
959 block_stack: vec![visible.clone(), background.clone()],
960 active_block_id: visible.clone(),
961 ..Default::default()
962 }),
963 ..Default::default()
964 }),
965 correlation_id: String::new(),
966 slices: None,
967 },
968 )
969 .await;
970
971 let resp = handle_close_pane(
972 axum::extract::State(state.clone()),
973 None,
974 axum::Json(ClosePaneRequest { auth, block_id: Some(background.clone()), reason: None }),
975 )
976 .await
977 .into_response();
978 assert_eq!(resp.status(), axum::http::StatusCode::ACCEPTED);
979 let request_id = body_json(resp).await["request_id"].as_str().unwrap().to_string();
980 crate::sagas::pending_shutdown::expire_now(&request_id);
981 crate::sagas::pending_shutdown::settled(&request_id).await;
982
983 let s = state.srv_state.lock().await;
984 assert!(!s.blocks.contains_key(&visible), "visible tab closed with its pane");
985 assert!(!s.blocks.contains_key(&background), "background tab closed with its pane");
986 assert!(s.tabs[&tab_id].block_ids.is_empty());
987 assert!(s.tabs[&tab_id].rootnode.is_none(), "pane removed");
988 assert!(s.blocks.contains_key(&caller_block), "caller untouched");
989 }
990
991 #[tokio::test]
992 async fn rejects_an_invalid_signature() {
993 let state = test_state();
994 let (tab_id, block_id) = seed(&state).await;
995 let agent_id = format!("close-bad-sig-{}", uuid::Uuid::new_v4());
996 state.reactive_handler.register_agent(&agent_id, &block_id, Some(&tab_id)).unwrap();
997 let bogus_key = vec![0u8; 32];
1000 let ts_secs = agentmux_common::time::now_secs();
1001 let sig = agentmux_common::jekt_sign::sign_jekt(
1002 &bogus_key, "ui-automation-identity", &agent_id, "__srv__", ts_secs, "",
1003 );
1004 let auth = UiAutomationAuth { agent_id, ts_secs, sig };
1005
1006 let resp = handle_close_pane(
1007 axum::extract::State(state.clone()),
1008 None,
1009 axum::Json(ClosePaneRequest { auth, block_id: None, reason: None }),
1010 )
1011 .await
1012 .into_response();
1013 assert_eq!(resp.status(), axum::http::StatusCode::UNAUTHORIZED);
1014
1015 let s = state.srv_state.lock().await;
1017 assert!(s.blocks.contains_key(&block_id));
1018 }
1019}
1020
1021pub async fn open_pane(state: &AppState, cmd: CommandPaneOpenData) -> Result<PaneOpenResult, String> {
1027 let mstore = state.mstore.clone();
1028 let event_bus = state.event_bus.clone();
1029
1030 tracing::info!(view = %cmd.view, "pane.open");
1031
1032 let meta = match cmd.meta {
1035 Some(m) => m,
1036 None => pane::build_pane_meta(&cmd)?,
1037 };
1038
1039 if cmd.view == "editor"
1062 && cmd.reuse_editor_pane == Some(true)
1063 && cmd.floating != Some(true)
1064 && cmd.tree_expanded.is_none()
1065 {
1066 if let (Some(caller_block_id), Some(file)) =
1067 (cmd.split_reference_block_id.as_deref(), cmd.file.as_deref())
1068 {
1069 let connection = cmd.connection.as_deref().unwrap_or("");
1070 if let Some(result) = pane::maybe_reuse_editor_pane(state, caller_block_id, file, connection).await? {
1071 return Ok(result);
1072 }
1073 }
1074 }
1075
1076 let tab_id = if let Some(explicit) = cmd.tab_id.as_deref() {
1088 explicit.to_string()
1089 } else if let Some(derived) = cmd
1090 .split_reference_block_id
1091 .as_deref()
1092 .and_then(|id| resolve_tab_id_for_block(&mstore, id).ok())
1093 {
1094 derived
1095 } else {
1096 resolve_tab_id(&mstore, None)?
1097 };
1098
1099 if cmd.floating == Some(true) {
1104 return pane::open_pane_floating(state, &mstore, &event_bus, cmd.view, tab_id, meta).await;
1105 }
1106
1107 if let Some(target) = cmd.stack_onto_block_id.clone().filter(|t| !t.is_empty()) {
1113 let tab_id = resolve_tab_id_for_block(&mstore, &target)
1114 .map_err(|e| format!("pane.open: stack_onto_block_id: {e}"))?;
1115 let meta_val = serde_json::to_value(&meta)
1116 .map_err(|e| format!("pane.open: stack_onto_block_id: meta serialize: {e}"))?;
1117 let events = crate::server::service::dispatch_to_reducer(
1118 state,
1119 agentmux_common::ipc::Command::CreateBlockInStack {
1120 tab_id: tab_id.clone(),
1121 target_block_id: target.clone(),
1122 meta: meta_val,
1123 activate: true,
1124 },
1125 )
1126 .await;
1127 if let Some(msg) = events.iter().find_map(|e| match e {
1128 agentmux_common::ipc::Event::Error { message, .. } => Some(message.clone()),
1129 _ => None,
1130 }) {
1131 return Err(format!("pane.open: {msg}"));
1132 }
1133 let block_id = events
1134 .iter()
1135 .find_map(|e| match e {
1136 agentmux_common::ipc::Event::BlockCreated { block_id, .. } => Some(block_id.clone()),
1137 _ => None,
1138 })
1139 .ok_or_else(|| "pane.open: CreateBlockInStack emitted no BlockCreated".to_string())?;
1140 for ev in &events {
1141 if let Err(e) = crate::persist_subscriber::apply_event_to_mstore(ev, &mstore) {
1142 tracing::warn!("pane.open: stack_onto_block_id: mstore apply failed: {e}");
1143 }
1144 }
1145 crate::server::service::publish_events(state, &events);
1146 if let Err(e) =
1147 crate::server::service::layout_helpers::queue_target_stack_push(state, &tab_id, &block_id, &target).await
1148 {
1149 tracing::warn!(block_id = %block_id, "pane.open: stack_onto_block_id: stackpush queue failed: {e}");
1150 }
1151 tracing::info!(block_id = %block_id, target = %target, view = %cmd.view, "pane.open: block created as a pane tab");
1152 return Ok(PaneOpenResult {
1153 block_id,
1154 tab_id,
1155 view: cmd.view,
1156 created: true,
1157 });
1158 }
1159
1160 if cmd.skip_placement == Some(true) {
1167 let meta_val = serde_json::to_value(&meta)
1168 .map_err(|e| format!("pane.open: skip_placement: meta serialize: {e}"))?;
1169 let create_events = crate::server::service::dispatch_to_reducer(
1170 state,
1171 agentmux_common::ipc::Command::CreateBlock {
1172 tab_id: tab_id.clone(),
1173 meta: meta_val,
1174 },
1175 )
1176 .await;
1177 if let Some(msg) = create_events.iter().find_map(|e| match e {
1178 agentmux_common::ipc::Event::Error { message, .. } => Some(message.clone()),
1179 _ => None,
1180 }) {
1181 return Err(format!("pane.open: skip_placement: CreateBlock: {msg}"));
1182 }
1183 let block_id = create_events
1184 .iter()
1185 .find_map(|e| match e {
1186 agentmux_common::ipc::Event::BlockCreated { block_id, .. } => Some(block_id.clone()),
1187 _ => None,
1188 })
1189 .ok_or_else(|| "pane.open: skip_placement: CreateBlock emitted no BlockCreated".to_string())?;
1190 for ev in &create_events {
1191 if let Err(e) = crate::persist_subscriber::apply_event_to_mstore(ev, &mstore) {
1192 tracing::warn!("pane.open: skip_placement: CreateBlock mstore apply failed: {e}");
1193 }
1194 }
1195 crate::server::service::publish_events(state, &create_events);
1196 tracing::info!(block_id = %block_id, view = %cmd.view, "pane.open: block created, placement skipped");
1197 return Ok(PaneOpenResult {
1198 block_id,
1199 tab_id,
1200 view: cmd.view,
1201 created: true,
1202 });
1203 }
1204
1205 let meta_val = serde_json::to_value(&meta)
1215 .map_err(|e| format!("pane.open: meta serialize: {e}"))?;
1216 let create_events = crate::server::service::dispatch_to_reducer(
1217 state,
1218 agentmux_common::ipc::Command::CreateBlock {
1219 tab_id: tab_id.clone(),
1220 meta: meta_val,
1221 },
1222 )
1223 .await;
1224 if let Some(msg) = create_events.iter().find_map(|e| match e {
1225 agentmux_common::ipc::Event::Error { message, .. } => Some(message.clone()),
1226 _ => None,
1227 }) {
1228 return Err(format!("pane.open: CreateBlock: {msg}"));
1229 }
1230 let block_id = create_events
1231 .iter()
1232 .find_map(|e| match e {
1233 agentmux_common::ipc::Event::BlockCreated { block_id, .. } => Some(block_id.clone()),
1234 _ => None,
1235 })
1236 .ok_or_else(|| "pane.open: CreateBlock emitted no BlockCreated".to_string())?;
1237 for ev in &create_events {
1238 if let Err(e) = crate::persist_subscriber::apply_event_to_mstore(ev, &mstore) {
1239 tracing::warn!("pane.open: CreateBlock mstore apply failed: {e}");
1240 }
1241 }
1242 crate::server::service::publish_events(state, &create_events);
1243
1244 let (actiontype, targetblockid, position) = pane::resolve_placement(
1246 cmd.split_direction.as_deref(),
1247 cmd.split_reference_block_id.as_deref(),
1248 );
1249 let focused = cmd.focus.unwrap_or(true);
1250
1251 {
1255 let action = obj::LayoutActionData {
1256 actiontype,
1257 actionid: uuid::Uuid::new_v4().to_string(),
1258 blockid: block_id.clone(),
1259 nodesize: None,
1260 nodesizefraction: None,
1261 indexarr: None,
1262 focused,
1263 magnified: false,
1264 ephemeral: false,
1265 targetblockid,
1266 position,
1267 };
1268 if let Err(e) = crate::server::service::queue_layout_actions_via_reducer(
1269 state,
1270 &tab_id,
1271 vec![action],
1272 )
1273 .await
1274 {
1275 tracing::warn!("pane.open: layout action enqueue failed: {e}");
1276 }
1277 }
1278
1279 tracing::info!(
1280 block_id = %block_id,
1281 view = %cmd.view,
1282 "pane.open: block created + layout updated"
1283 );
1284
1285 {
1287 let mut updates = Vec::new();
1288 if let Ok(updated_block) = mstore.must_get::<Block>(&block_id) {
1289 updates.push(obj::MuxObjUpdate {
1290 updatetype: "update".into(),
1291 otype: "block".into(),
1292 oid: block_id.clone(),
1293 obj: Some(obj::mux_obj_to_value(&updated_block)),
1294 });
1295 }
1296 if let Ok(updated_tab) = mstore.must_get::<Tab>(&tab_id) {
1297 updates.push(obj::MuxObjUpdate {
1298 updatetype: "update".into(),
1299 otype: "tab".into(),
1300 oid: tab_id.clone(),
1301 obj: Some(obj::mux_obj_to_value(&updated_tab)),
1302 });
1303 if let Ok(updated_layout) = mstore.must_get::<obj::LayoutState>(&updated_tab.layoutstate) {
1304 updates.push(obj::MuxObjUpdate {
1305 updatetype: "update".into(),
1306 otype: "layout".into(),
1307 oid: updated_tab.layoutstate.clone(),
1308 obj: Some(obj::mux_obj_to_value(&updated_layout)),
1309 });
1310 }
1311 }
1312 event_bus.broadcast_mux_obj_updates(&updates);
1315 }
1316
1317 Ok(PaneOpenResult {
1318 block_id,
1319 tab_id,
1320 view: cmd.view,
1321 created: true,
1322 })
1323}
1324
1325pub(crate) async fn move_tab(state: &AppState, cmd: CommandPaneMoveTabData) -> Result<(), String> {
1333 let mstore = &state.mstore;
1334 let position = match cmd.position.as_str() {
1335 "before" => agentmux_common::StackMovePosition::Before,
1336 "after" => agentmux_common::StackMovePosition::After,
1337 "end" => agentmux_common::StackMovePosition::End,
1338 other => return Err(format!("pane.moveTab: invalid position '{other}' (expected before/after/end)")),
1339 };
1340 let tab_id = resolve_tab_id_for_block(mstore, &cmd.block_id)
1341 .map_err(|e| format!("pane.moveTab: {e}"))?;
1342 let events = crate::server::service::dispatch_to_reducer(
1351 state,
1352 agentmux_common::ipc::Command::LayoutStackMove {
1353 tab_id: tab_id.clone(),
1354 block_id: cmd.block_id.clone(),
1355 target_block_id: cmd.target_block_id.clone(),
1356 position,
1357 activate: cmd.activate,
1358 correlation_id: String::new(),
1359 },
1360 )
1361 .await;
1362 if let Some(msg) = events.iter().find_map(|e| match e {
1363 agentmux_common::ipc::Event::Error { message, .. } => Some(message.clone()),
1364 _ => None,
1365 }) {
1366 return Err(format!("pane.moveTab: {msg}"));
1367 }
1368 for ev in &events {
1369 if let Err(e) = crate::persist_subscriber::apply_event_to_mstore(ev, mstore) {
1370 tracing::warn!("pane.moveTab: mstore apply failed: {e}");
1371 }
1372 }
1373 crate::server::service::publish_events(state, &events);
1374 if let Err(e) = crate::server::service::layout_helpers::queue_target_stack_move(
1375 state,
1376 &tab_id,
1377 &cmd.block_id,
1378 &cmd.target_block_id,
1379 &cmd.position,
1380 cmd.activate,
1381 )
1382 .await
1383 {
1384 tracing::warn!(block_id = %cmd.block_id, "pane.moveTab: stackmove queue failed: {e}");
1385 }
1386 tracing::info!(block_id = %cmd.block_id, target = %cmd.target_block_id, position = %cmd.position, "pane.moveTab");
1387 Ok(())
1388}