1use axum::{extract::State, response::Json};
6use serde_json::json;
7
8use crate::backend::blockcontroller;
9use crate::backend::service::{self, CloseTabRtnType, WebCallType, WebReturnType};
10use crate::backend::storage::store::Store;
11use crate::backend::obj::*;
12use crate::backend::wcore;
13
14use super::AppState;
15
16pub(super) async fn handle_service(
17 State(state): State<AppState>,
18 body: axum::body::Bytes,
19) -> Json<WebReturnType> {
20 let call: WebCallType = match serde_json::from_slice(&body) {
21 Ok(c) => c,
22 Err(e) => return Json(WebReturnType::error(format!("invalid request body: {e}"))),
23 };
24 Json(run_service_call(&state, &call).await)
25}
26
27pub(crate) async fn run_service_call(state: &AppState, call: &WebCallType) -> WebReturnType {
33 let service_start = std::time::Instant::now();
34 let result = dispatch_service(state, call).await;
35 let elapsed = service_start.elapsed();
36 tracing::info!(
37 "[http-perf] {}.{}: {:.2}ms",
38 call.service,
39 call.method,
40 elapsed.as_secs_f64() * 1000.0,
41 );
42
43 if let Some(updates) = &result.updates {
52 for update in updates {
53 if let Ok(data) = serde_json::to_value(update) {
54 let oref = format!("{}:{}", update.otype, update.oid);
55 state.event_bus.broadcast_event(
56 &crate::backend::eventbus::WSEventType {
57 eventtype: "waveobj:update".to_string(),
58 oref,
59 data: Some(data),
60 },
61 );
62 }
63 }
64 }
65
66 result
67}
68
69#[derive(Debug, serde::Serialize)]
72pub(crate) struct AgentContext {
73 pub block_id: String,
74 pub block_title: String,
75 pub tab_id: String,
76 pub tab_name: String,
77 pub window_id: Option<String>,
78 pub window_name: String,
79 pub workspace_id: Option<String>,
80 pub workspace_name: String,
81}
82
83pub(crate) fn workspace_id_for_tab(store: &Store, tab_id: &str) -> Option<String> {
86 store
87 .get_all::<Workspace>()
88 .unwrap_or_default()
89 .into_iter()
90 .find(|w| {
91 w.tabids.iter().any(|t| t == tab_id) || w.pinnedtabids.iter().any(|t| t == tab_id)
92 })
93 .map(|w| w.oid)
94}
95
96pub(crate) fn agent_layout(store: &Store) -> serde_json::Value {
100 let windows = store.get_all::<Window>().unwrap_or_default();
101 let workspaces = store.get_all::<Workspace>().unwrap_or_default();
102 let tabs = store.get_all::<Tab>().unwrap_or_default();
103 let blocks = store.get_all::<Block>().unwrap_or_default();
104
105 let panes_of = |tab: &Tab| -> Vec<serde_json::Value> {
106 tab.blockids
107 .iter()
108 .filter_map(|bid| blocks.iter().find(|b| &b.oid == bid))
109 .map(|b| {
110 json!({
111 "block_id": b.oid,
112 "view": meta_get_string(&b.meta, "view", ""),
113 "title": meta_get_string(&b.meta, "frame:title", ""),
114 })
115 })
116 .collect()
117 };
118
119 let windows_json: Vec<serde_json::Value> = windows
120 .iter()
121 .map(|w| {
122 let ws = workspaces.iter().find(|x| x.oid == w.workspaceid);
123 let tabs_json: Vec<serde_json::Value> = ws
124 .map(|ws| {
125 ws.pinnedtabids
127 .iter()
128 .chain(ws.tabids.iter())
129 .filter_map(|tid| tabs.iter().find(|t| &t.oid == tid))
130 .map(|t| {
131 json!({
132 "tab_id": t.oid,
133 "name": t.name,
134 "active": ws.activetabid == t.oid,
135 "panes": panes_of(t),
136 })
137 })
138 .collect()
139 })
140 .unwrap_or_default();
141 json!({
142 "window_id": w.oid,
143 "name": meta_get_string(&w.meta, "window:displayname", ""),
144 "workspace_id": w.workspaceid,
145 "workspace_name": ws.map(|x| x.name.clone()).unwrap_or_default(),
146 "tabs": tabs_json,
147 })
148 })
149 .collect();
150
151 json!({ "windows": windows_json })
152}
153
154pub(crate) fn agent_windows(store: &Store) -> serde_json::Value {
156 let workspaces = store.get_all::<Workspace>().unwrap_or_default();
157 let windows: Vec<serde_json::Value> = store
158 .get_all::<Window>()
159 .unwrap_or_default()
160 .iter()
161 .map(|w| {
162 let ws_name = workspaces
163 .iter()
164 .find(|x| x.oid == w.workspaceid)
165 .map(|x| x.name.clone())
166 .unwrap_or_default();
167 json!({
168 "window_id": w.oid,
169 "name": meta_get_string(&w.meta, "window:displayname", ""),
170 "workspace_id": w.workspaceid,
171 "workspace_name": ws_name,
172 })
173 })
174 .collect();
175 json!({ "windows": windows })
176}
177
178pub(crate) fn agent_workspaces(store: &Store) -> serde_json::Value {
180 let workspaces: Vec<serde_json::Value> = store
181 .get_all::<Workspace>()
182 .unwrap_or_default()
183 .iter()
184 .map(|w| {
185 json!({
186 "workspace_id": w.oid,
187 "name": w.name,
188 "tab_count": w.tabids.len() + w.pinnedtabids.len(),
189 "active_tab_id": w.activetabid,
190 })
191 })
192 .collect();
193 json!({ "workspaces": workspaces })
194}
195
196pub(crate) fn agent_tabs(store: &Store, workspace_id: Option<&str>) -> serde_json::Value {
199 let workspaces = store.get_all::<Workspace>().unwrap_or_default();
200 let scope: Option<Vec<String>> = workspace_id.map(|wid| {
201 workspaces
202 .iter()
203 .find(|w| w.oid == wid)
204 .map(|w| w.pinnedtabids.iter().chain(w.tabids.iter()).cloned().collect())
205 .unwrap_or_default()
206 });
207 let tabs: Vec<serde_json::Value> = store
208 .get_all::<Tab>()
209 .unwrap_or_default()
210 .iter()
211 .filter(|t| scope.as_ref().map(|ids| ids.contains(&t.oid)).unwrap_or(true))
212 .map(|t| {
213 json!({
214 "tab_id": t.oid,
215 "name": t.name,
216 "pane_count": t.blockids.len(),
217 })
218 })
219 .collect();
220 json!({ "tabs": tabs })
221}
222
223pub(crate) fn resolve_agent_context(store: &Store, block_id: &str) -> Result<AgentContext, String> {
231 let block = store
232 .get::<Block>(block_id)
233 .map_err(|e| e.to_string())?
234 .ok_or_else(|| format!("block not found: {block_id}"))?;
235 let block_title = meta_get_string(&block.meta, "frame:title", "");
236 let tab_id = block.parentoref.strip_prefix("tab:").unwrap_or("").to_string();
237 let tab = store
238 .get::<Tab>(&tab_id)
239 .map_err(|e| e.to_string())?
240 .ok_or_else(|| format!("tab not found for block {block_id}"))?;
241
242 let mut workspace_id = None;
243 let mut workspace_name = String::new();
244 let mut window_id = None;
245 let mut window_name = String::new();
246
247 if let Ok(workspaces) = store.get_all::<Workspace>() {
248 if let Some(ws) = workspaces
249 .into_iter()
250 .find(|w| w.tabids.contains(&tab_id) || w.pinnedtabids.contains(&tab_id))
251 {
252 workspace_name = ws.name.clone();
253 workspace_id = Some(ws.oid.clone());
254 if let Ok(windows) = store.get_all::<Window>() {
255 if let Some(win) = windows.into_iter().find(|w| w.workspaceid == ws.oid) {
256 window_name = meta_get_string(&win.meta, "window:displayname", "");
257 window_id = Some(win.oid);
258 }
259 }
260 }
261 }
262
263 Ok(AgentContext {
264 block_id: block_id.to_string(),
265 block_title,
266 tab_id,
267 tab_name: tab.name,
268 window_id,
269 window_name,
270 workspace_id,
271 workspace_name,
272 })
273}
274
275async fn dispatch_service(state: &AppState, call: &WebCallType) -> WebReturnType {
276 match call.service.as_str() {
277 "object" => handle_object_service(state, call).await,
278 "client" => handle_client_service(state, call).await,
279 "window" => handle_window_service(state, call).await,
280 "workspace" => handle_workspace_service(state, call).await,
281 _ => handle_misc_service(state, call).await,
282 }
283}
284
285async fn handle_object_service(state: &AppState, call: &WebCallType) -> WebReturnType {
286 let store = &state.wstore;
287 let args = &call.args;
288 match call.method.as_str() {
289 "GetObject" => {
290 let oref_str: String = match service::get_arg(args, 0) {
291 Ok(v) => v,
292 Err(e) => return WebReturnType::error(e),
293 };
294 match get_object_by_oref(store, &oref_str) {
295 Ok(data) => WebReturnType::success(data),
296 Err(e) => WebReturnType::error(e),
297 }
298 }
299 "GetObjects" => {
300 let orefs: Vec<String> = match service::get_arg(args, 0) {
301 Ok(v) => v,
302 Err(e) => return WebReturnType::error(e),
303 };
304 let mut results = Vec::new();
305 for oref_str in &orefs {
306 match get_object_by_oref(store, oref_str) {
307 Ok(data) => results.push(data),
308 Err(_) => results.push(serde_json::Value::Null),
309 }
310 }
311 WebReturnType::success(serde_json::json!(results))
312 }
313 "CreateBlock" => {
314 let block_def: BlockDef = match service::get_arg(args, 0) {
315 Ok(v) => v,
316 Err(e) => return WebReturnType::error(e),
317 };
318 let explicit_tab_id: Option<String> = match service::get_optional_arg::<String>(args, 2) {
332 Ok(opt) => opt.map(|s| s.trim().to_string()).filter(|s| !s.is_empty()),
333 Err(e) => return WebReturnType::error(format!("invalid tabId arg: {}", e)),
334 };
335 let tab_id = match explicit_tab_id {
336 Some(id) => id,
337 None => match call
338 .uicontext
339 .as_ref()
340 .map(|ctx| ctx.active_tab_id.clone())
341 {
342 Some(id) if !id.is_empty() => id,
343 _ => return WebReturnType::error("missing uicontext.activetabid"),
344 },
345 };
346 let meta_value =
351 serde_json::to_value(&block_def.meta).unwrap_or(serde_json::Value::Null);
352 let events = dispatch_to_reducer(
353 state,
354 agentmux_common::ipc::Command::CreateBlock {
355 tab_id: tab_id.clone(),
356 meta: meta_value,
357 },
358 )
359 .await;
360 if let Some(err_msg) = events.iter().find_map(|e| match e {
362 agentmux_common::ipc::Event::Error { message, .. } => Some(message.clone()),
363 _ => None,
364 }) {
365 return WebReturnType::error(err_msg);
366 }
367 let block_id = events.iter().find_map(|e| match e {
368 agentmux_common::ipc::Event::BlockCreated { block_id, .. } => {
369 Some(block_id.clone())
370 }
371 _ => None,
372 });
373 let mut apply_err: Option<String> = None;
374 for ev in &events {
375 if let Err(e) = crate::persist_subscriber::apply_event_to_wstore(ev, store) {
376 apply_err = Some(e.to_string());
377 break;
378 }
379 }
380 if let Some(err) = apply_err {
381 if let Some(bid) = block_id.as_ref() {
382 compensate_via_reducer(
383 state,
384 agentmux_common::ipc::Command::DeleteBlock {
385 tab_id: tab_id.clone(),
386 block_id: bid.clone(),
387 },
388 store,
389 )
390 .await;
391 }
392 return WebReturnType::error(format!("CreateBlock: SQLite write failed: {}", err));
393 }
394 publish_events(state, &events);
395 match block_id {
396 Some(bid) => {
397 let mut updates = vec![];
398 if let Ok(block) = store.must_get::<Block>(&bid) {
399 updates.push(WaveObjUpdate {
400 updatetype: "update".into(),
401 otype: OTYPE_BLOCK.to_string(),
402 oid: bid.clone(),
403 obj: Some(wave_obj_to_value(&block)),
404 });
405 }
406 if let Ok(tab) = store.must_get::<Tab>(&tab_id) {
407 updates.push(WaveObjUpdate {
408 updatetype: "update".into(),
409 otype: OTYPE_TAB.to_string(),
410 oid: tab_id.clone(),
411 obj: Some(wave_obj_to_value(&tab)),
412 });
413 }
414 WebReturnType::success_data_updates(serde_json::json!(bid), updates)
415 }
416 None => WebReturnType::error(
417 "CreateBlock: reducer did not emit BlockCreated".to_string(),
418 ),
419 }
420 }
421 "DeleteBlock" => {
428 let block_id: String = match service::get_arg(args, 0) {
429 Ok(v) => v,
430 Err(e) => return WebReturnType::error(e),
431 };
432 let tab_id = {
437 let s = state.srv_state.lock().await;
438 match s.blocks.get(&block_id) {
439 Some(rec) => rec.tab_id.clone(),
440 None => return WebReturnType::error(format!("DeleteBlock: block not found: {}", block_id)),
441 }
442 };
443 if let Err(reason) = crate::sagas::delete_block::run(state, tab_id, block_id).await {
444 return WebReturnType::error(reason);
445 }
446 WebReturnType::success_empty()
447 }
448 "UpdateObject" => {
449 let wave_obj_value: serde_json::Value = match service::get_arg(args, 0) {
450 Ok(v) => v,
451 Err(e) => return WebReturnType::error(e),
452 };
453 let layout_slice: Option<(String, String, String)> = if wave_obj_value
471 .get("otype")
472 .and_then(|v| v.as_str())
473 == Some(OTYPE_LAYOUT)
474 {
475 wave_obj_value
476 .get("oid")
477 .and_then(|v| v.as_str())
478 .and_then(|layout_oid| find_tab_for_layout(store, layout_oid))
479 .map(|tab_id| {
480 let new_focused = wave_obj_value
481 .get("focusednodeid")
482 .and_then(|v| v.as_str())
483 .unwrap_or("")
484 .to_string();
485 let new_magnified = wave_obj_value
486 .get("magnifiednodeid")
487 .and_then(|v| v.as_str())
488 .unwrap_or("")
489 .to_string();
490 (tab_id, new_focused, new_magnified)
491 })
492 } else {
493 None
494 };
495 match update_object(store, wave_obj_value) {
496 Ok((otype, oid, obj_val)) => {
497 if let Some((tab_id, new_focused, new_magnified)) = layout_slice {
502 let focus_events = dispatch_to_reducer(
503 state,
504 agentmux_common::ipc::Command::SetFocusedNode {
505 tab_id: tab_id.clone(),
506 node_id: new_focused,
507 },
508 )
509 .await;
510 publish_events(state, &focus_events);
511 let mag_events = dispatch_to_reducer(
512 state,
513 agentmux_common::ipc::Command::SetMagnifiedNode {
514 tab_id,
515 node_id: new_magnified,
516 },
517 )
518 .await;
519 publish_events(state, &mag_events);
520 }
521 let update = WaveObjUpdate {
522 updatetype: "update".into(),
523 otype,
524 oid,
525 obj: Some(obj_val),
526 };
527 WebReturnType::success_with_updates(vec![update])
528 }
529 Err(e) => WebReturnType::error(e),
530 }
531 }
532 "UpdateObjectMeta" => {
538 let oref_str: String = match service::get_arg(args, 0) {
539 Ok(v) => v,
540 Err(e) => return WebReturnType::error(e),
541 };
542 let meta_update: MetaMapType = match service::get_arg(args, 1) {
543 Ok(v) => v,
544 Err(e) => return WebReturnType::error(e),
545 };
546 let oref = match crate::backend::ORef::parse(&oref_str) {
547 Ok(v) => v,
548 Err(e) => return WebReturnType::error(e.to_string()),
549 };
550 let meta_value = serde_json::to_value(&meta_update).unwrap_or(serde_json::Value::Null);
551 let cmd = match oref.otype.as_str() {
552 t if t == OTYPE_WORKSPACE => agentmux_common::ipc::Command::UpdateWorkspaceMeta {
553 workspace_id: oref.oid.clone(),
554 meta_patch: meta_value,
555 },
556 t if t == OTYPE_TAB => agentmux_common::ipc::Command::UpdateTabMeta {
557 tab_id: oref.oid.clone(),
558 meta_patch: meta_value,
559 },
560 t if t == OTYPE_BLOCK => agentmux_common::ipc::Command::UpdateBlockMeta {
561 block_id: oref.oid.clone(),
562 meta_patch: meta_value,
563 },
564 t if t == OTYPE_WINDOW => agentmux_common::ipc::Command::UpdateWindowMeta {
565 window_id: oref.oid.clone(),
566 meta_patch: meta_value,
567 },
568 other => {
569 return match update_object_meta(store, &oref_str, &meta_update) {
579 Ok(()) => WebReturnType::success_empty(),
580 Err(e) => WebReturnType::error(format!(
581 "UpdateObjectMeta: unsupported otype {} via reducer; wcore fallback failed: {}",
582 other, e
583 )),
584 };
585 }
586 };
587 let events = dispatch_to_reducer(state, cmd).await;
588 if let Some(err_msg) = events.iter().find_map(|e| match e {
589 agentmux_common::ipc::Event::Error { message, .. } => Some(message.clone()),
590 _ => None,
591 }) {
592 return WebReturnType::error(err_msg);
593 }
594 for ev in &events {
595 if let Err(e) = crate::persist_subscriber::apply_event_to_wstore(ev, store) {
596 return WebReturnType::error(format!(
597 "UpdateObjectMeta: SQLite write failed: {}",
598 e
599 ));
600 }
601 }
602 publish_events(state, &events);
603 if oref.otype == OTYPE_BLOCK && meta_update.contains_key("term:zoom") {
611 if let Ok(block) = store.must_get::<Block>(&oref.oid) {
612 let agent_id = block
613 .meta
614 .get("agentId")
615 .and_then(|v| v.as_str())
616 .unwrap_or("")
617 .to_string();
618 if !agent_id.is_empty() {
619 let zoom = meta_update.get("term:zoom").and_then(|v| v.as_f64());
620 schedule_agent_zoom_mirror(store.clone(), agent_id, zoom);
621 }
622 }
623 }
624 if oref.otype == OTYPE_BLOCK {
626 if let Ok(block) = store.must_get::<Block>(&oref.oid) {
627 return WebReturnType::success_with_updates(vec![WaveObjUpdate {
628 updatetype: "update".into(),
629 otype: OTYPE_BLOCK.to_string(),
630 oid: oref.oid.clone(),
631 obj: Some(wave_obj_to_value(&block)),
632 }]);
633 }
634 }
635 if oref.otype == OTYPE_TAB {
636 if let Ok(tab) = store.must_get::<Tab>(&oref.oid) {
637 return WebReturnType::success_with_updates(vec![WaveObjUpdate {
638 updatetype: "update".into(),
639 otype: OTYPE_TAB.to_string(),
640 oid: oref.oid.clone(),
641 obj: Some(wave_obj_to_value(&tab)),
642 }]);
643 }
644 }
645 WebReturnType::success_empty()
646 }
647 "UpdateTabName" => {
649 let tab_id: String = match service::get_arg(args, 0) {
650 Ok(v) => v,
651 Err(e) => return WebReturnType::error(e),
652 };
653 let name: String = match service::get_arg(args, 1) {
654 Ok(v) => v,
655 Err(e) => return WebReturnType::error(e),
656 };
657 let events = dispatch_to_reducer(
658 state,
659 agentmux_common::ipc::Command::RenameTab {
660 tab_id: tab_id.clone(),
661 name,
662 },
663 )
664 .await;
665 if let Some(err_msg) = events.iter().find_map(|e| match e {
666 agentmux_common::ipc::Event::Error { message, .. } => Some(message.clone()),
667 _ => None,
668 }) {
669 return WebReturnType::error(err_msg);
670 }
671 for ev in &events {
672 if let Err(e) = crate::persist_subscriber::apply_event_to_wstore(ev, store) {
673 return WebReturnType::error(format!(
674 "UpdateTabName: SQLite write failed: {}",
675 e
676 ));
677 }
678 }
679 publish_events(state, &events);
680 if let Ok(updated_tab) = store.must_get::<Tab>(&tab_id) {
681 let update = WaveObjUpdate {
682 updatetype: "update".into(),
683 otype: OTYPE_TAB.to_string(),
684 oid: tab_id.clone(),
685 obj: Some(wave_obj_to_value(&updated_tab)),
686 };
687 return WebReturnType::success_with_updates(vec![update]);
688 }
689 WebReturnType::success_empty()
690 }
691 _ => WebReturnType::error(format!("unknown object method: {}", call.method)),
692 }
693}
694
695async fn handle_client_service(state: &AppState, call: &WebCallType) -> WebReturnType {
696 let store = &state.wstore;
697 let args = &call.args;
698 match call.method.as_str() {
699 "GetClientData" => match wcore::get_client(store) {
700 Ok(client) => {
701 WebReturnType::success(serde_json::to_value(&client).unwrap_or_default())
702 }
703 Err(e) => WebReturnType::error(e.to_string()),
704 },
705 "GetTab" => {
706 let tab_id: String = match service::get_arg(args, 0) {
707 Ok(v) => v,
708 Err(e) => return WebReturnType::error(e),
709 };
710 match store.must_get::<Tab>(&tab_id) {
711 Ok(tab) => WebReturnType::success(serde_json::to_value(&tab).unwrap_or_default()),
712 Err(e) => WebReturnType::error(e.to_string()),
713 }
714 }
715 "FocusWindow" => {
716 let window_id: String = match service::get_arg(args, 0) {
717 Ok(v) => v,
718 Err(e) => return WebReturnType::error(e),
719 };
720 match wcore::focus_window(store, &window_id) {
721 Ok(()) => WebReturnType::success_empty(),
722 Err(e) => WebReturnType::error(e.to_string()),
723 }
724 }
725 "AgreeTos" => match wcore::get_client(store) {
726 Ok(mut client) => {
727 client.tosagreed = chrono::Utc::now().timestamp_millis();
728 match store.update(&mut client) {
729 Ok(_) => WebReturnType::success_empty(),
730 Err(e) => WebReturnType::error(e.to_string()),
731 }
732 }
733 Err(e) => WebReturnType::error(e.to_string()),
734 },
735 "GetAllConnStatus" => {
736 WebReturnType::success_empty()
739 }
740 "TelemetryUpdate" => {
741 WebReturnType::success_empty()
743 }
744 _ => WebReturnType::error(format!("unknown client method: {}", call.method)),
745 }
746}
747
748async fn handle_window_service(state: &AppState, call: &WebCallType) -> WebReturnType {
749 let store = &state.wstore;
750 let args = &call.args;
751 match call.method.as_str() {
752 "GetWindow" => {
753 let window_id: String = match service::get_arg(args, 0) {
754 Ok(v) => v,
755 Err(e) => return WebReturnType::error(e),
756 };
757 match store.must_get::<Window>(&window_id) {
758 Ok(win) => WebReturnType::success(serde_json::to_value(&win).unwrap_or_default()),
759 Err(e) => WebReturnType::error(e.to_string()),
760 }
761 }
762 "CreateWindow" => {
772 let requested_ws_id: String = service::get_arg(args, 1).unwrap_or_default();
773 let (ws_id, fresh_workspace_events): (String, Vec<agentmux_common::ipc::Event>) =
775 if requested_ws_id.is_empty() {
776 let ws_events = dispatch_to_reducer(
778 state,
779 agentmux_common::ipc::Command::CreateWorkspace {
780 name: String::new(),
781 },
782 )
783 .await;
784 if let Some(err_msg) = ws_events.iter().find_map(|e| match e {
785 agentmux_common::ipc::Event::Error { message, .. } => {
786 Some(message.clone())
787 }
788 _ => None,
789 }) {
790 return WebReturnType::error(err_msg);
791 }
792 for ev in &ws_events {
793 if let Err(e) =
794 crate::persist_subscriber::apply_event_to_wstore(ev, store)
795 {
796 return WebReturnType::error(format!(
797 "CreateWindow: SQLite write failed: {}",
798 e
799 ));
800 }
801 }
802 let new_ws_id = ws_events
803 .iter()
804 .find_map(|e| match e {
805 agentmux_common::ipc::Event::WorkspaceCreated {
806 workspace_id, ..
807 } => Some(workspace_id.clone()),
808 _ => None,
809 })
810 .unwrap_or_default();
811 let tab_events = dispatch_to_reducer(
813 state,
814 agentmux_common::ipc::Command::CreateTab {
815 workspace_id: new_ws_id.clone(),
816 name: String::new(),
817 },
818 )
819 .await;
820 if let Some(err_msg) = tab_events.iter().find_map(|e| match e {
821 agentmux_common::ipc::Event::Error { message, .. } => {
822 Some(message.clone())
823 }
824 _ => None,
825 }) {
826 let comp = dispatch_to_reducer(
828 state,
829 agentmux_common::ipc::Command::DeleteWorkspace {
830 workspace_id: new_ws_id.clone(),
831 force: false,
835 },
836 )
837 .await;
838 for ev in &comp {
839 let _ = crate::persist_subscriber::apply_event_to_wstore(ev, store);
840 }
841 publish_events(state, &comp);
842 return WebReturnType::error(err_msg);
843 }
844 for ev in &tab_events {
845 if let Err(e) =
846 crate::persist_subscriber::apply_event_to_wstore(ev, store)
847 {
848 return WebReturnType::error(format!(
849 "CreateWindow: SQLite write failed: {}",
850 e
851 ));
852 }
853 }
854 let mut block_seed_events: Vec<agentmux_common::ipc::Event> = Vec::new();
877 if let Some(new_tab_id) = tab_events.iter().find_map(|e| match e {
878 agentmux_common::ipc::Event::TabCreated { tab_id, .. } => {
879 Some(tab_id.clone())
880 }
881 _ => None,
882 }) {
883 let mut seeded_ids: Vec<String> = Vec::new();
885 for view in ["agent", "sysinfo", "swarm"] {
886 let evs = dispatch_to_reducer(
887 state,
888 agentmux_common::ipc::Command::CreateBlock {
889 tab_id: new_tab_id.clone(),
890 meta: serde_json::json!({ "view": view }),
891 },
892 )
893 .await;
894 if let Some(err_msg) = evs.iter().find_map(|e| match e {
895 agentmux_common::ipc::Event::Error { message, .. } => {
896 Some(message.clone())
897 }
898 _ => None,
899 }) {
900 tracing::warn!(
901 tab_id = %new_tab_id,
902 view = %view,
903 error = %err_msg,
904 "CreateWindow: seed block create failed — opening blank tab"
905 );
906 break;
907 }
908 for ev in &evs {
909 if let Err(e) =
910 crate::persist_subscriber::apply_event_to_wstore(ev, store)
911 {
912 tracing::warn!(
913 tab_id = %new_tab_id,
914 error = %e,
915 "CreateWindow: seed block SQLite write failed"
916 );
917 }
918 }
919 if let Some(block_id) = evs.iter().find_map(|e| match e {
920 agentmux_common::ipc::Event::BlockCreated { block_id, .. } => {
921 Some(block_id.clone())
922 }
923 _ => None,
924 }) {
925 seeded_ids.push(block_id);
926 }
927 block_seed_events.extend(evs);
928 }
929
930 if seeded_ids.len() == 3 {
931 if let Err(e) = crate::backend::wcore::write_default_three_pane_layout(
932 store,
933 &new_tab_id,
934 &seeded_ids[0],
935 &seeded_ids[1],
936 &seeded_ids[2],
937 ) {
938 tracing::warn!(
939 tab_id = %new_tab_id,
940 error = %e,
941 "CreateWindow: default layout write failed — opening blank tab"
942 );
943 }
944 }
945 }
946 let mut combined = ws_events;
947 combined.extend(tab_events);
948 combined.extend(block_seed_events);
949 (new_ws_id, combined)
950 } else {
951 let exists_in_sqlite = match store.get::<Workspace>(&requested_ws_id) {
954 Ok(opt) => opt.is_some(),
955 Err(e) => {
956 return WebReturnType::error(format!(
957 "CreateWindow: workspace lookup failed: {}",
958 e
959 ));
960 }
961 };
962 if !exists_in_sqlite {
963 return WebReturnType::error(format!(
964 "CreateWindow: workspace not found: {}",
965 requested_ws_id
966 ));
967 }
968 (requested_ws_id, Vec::new())
969 };
970
971 let window_id = uuid::Uuid::new_v4().to_string();
973 let win_events = dispatch_to_reducer(
974 state,
975 agentmux_common::ipc::Command::CreateWindow {
976 window_id: window_id.clone(),
977 workspace_id: ws_id.clone(),
978 },
979 )
980 .await;
981 if let Some(err_msg) = win_events.iter().find_map(|e| match e {
982 agentmux_common::ipc::Event::Error { message, .. } => Some(message.clone()),
983 _ => None,
984 }) {
985 if !fresh_workspace_events.is_empty() {
987 let comp = dispatch_to_reducer(
988 state,
989 agentmux_common::ipc::Command::DeleteWorkspace {
990 workspace_id: ws_id.clone(),
991 force: false,
993 },
994 )
995 .await;
996 for ev in &comp {
997 let _ = crate::persist_subscriber::apply_event_to_wstore(ev, store);
998 }
999 publish_events(state, &comp);
1000 }
1001 return WebReturnType::error(err_msg);
1002 }
1003 for ev in &win_events {
1004 if let Err(e) = crate::persist_subscriber::apply_event_to_wstore(ev, store) {
1005 return WebReturnType::error(format!(
1006 "CreateWindow: SQLite write failed: {}",
1007 e
1008 ));
1009 }
1010 }
1011 if let Ok(mut win) = store.must_get::<Window>(&window_id) {
1015 if !win.isnew {
1016 win.isnew = true;
1017 let _ = store.update(&mut win);
1018 }
1019 }
1020 let mut all_events = fresh_workspace_events;
1022 all_events.extend(win_events);
1023 publish_events(state, &all_events);
1024 match store.must_get::<Window>(&window_id) {
1026 Ok(win) => WebReturnType::success(serde_json::to_value(&win).unwrap_or_default()),
1027 Err(e) => WebReturnType::error(format!(
1028 "CreateWindow: window read-back failed: {}",
1029 e
1030 )),
1031 }
1032 }
1033 "CloseWindow" => {
1045 let window_id: String = match service::get_arg(args, 0) {
1046 Ok(v) => v,
1047 Err(e) => return WebReturnType::error(e),
1048 };
1049 let ws_id: Option<String> = match store.get::<Window>(&window_id) {
1052 Ok(Some(w)) => Some(w.workspaceid.clone()),
1053 Ok(None) => None,
1054 Err(e) => {
1055 return WebReturnType::error(format!(
1056 "CloseWindow: window lookup failed: {}",
1057 e
1058 ));
1059 }
1060 };
1061 let close_events = dispatch_to_reducer(
1063 state,
1064 agentmux_common::ipc::Command::CloseWindowInternal {
1065 window_id: window_id.clone(),
1066 },
1067 )
1068 .await;
1069 if let Some(err_msg) = close_events.iter().find_map(|e| match e {
1073 agentmux_common::ipc::Event::Error { message, .. } => Some(message.clone()),
1074 _ => None,
1075 }) {
1076 return WebReturnType::error(err_msg);
1077 }
1078 for ev in &close_events {
1079 if let Err(e) = crate::persist_subscriber::apply_event_to_wstore(ev, store) {
1080 return WebReturnType::error(format!(
1081 "CloseWindow: SQLite write failed: {}",
1082 e
1083 ));
1084 }
1085 }
1086 publish_events(state, &close_events);
1087 if let Some(ws_id) = ws_id {
1101 let any_other_window = {
1102 let s = state.srv_state.lock().await;
1103 s.windows.values().any(|w| w.workspace_id == ws_id)
1104 };
1105 if !any_other_window {
1106 if let Err(e) =
1107 crate::sagas::delete_workspace::run(state, ws_id.clone()).await
1108 {
1109 tracing::warn!(
1110 workspace_id = %ws_id,
1111 "CloseWindow: delete_workspace saga failed: {}",
1112 e,
1113 );
1114 }
1115 }
1116 }
1117 WebReturnType::success_empty()
1121 }
1122 "SwitchWorkspace" => {
1127 let window_id: String = match service::get_arg(args, 0) {
1128 Ok(v) => v,
1129 Err(e) => return WebReturnType::error(e),
1130 };
1131 let ws_id: String = match service::get_arg(args, 1) {
1132 Ok(v) => v,
1133 Err(e) => return WebReturnType::error(e),
1134 };
1135 let events = dispatch_to_reducer(
1136 state,
1137 agentmux_common::ipc::Command::SwitchWorkspace {
1138 window_id: window_id.clone(),
1139 workspace_id: ws_id.clone(),
1140 },
1141 )
1142 .await;
1143 if let Some(err_msg) = events.iter().find_map(|e| match e {
1144 agentmux_common::ipc::Event::Error { message, .. } => Some(message.clone()),
1145 _ => None,
1146 }) {
1147 return WebReturnType::error(err_msg);
1148 }
1149 for ev in &events {
1150 if let Err(e) = crate::persist_subscriber::apply_event_to_wstore(ev, store) {
1151 return WebReturnType::error(format!(
1152 "SwitchWorkspace: SQLite write failed: {}",
1153 e
1154 ));
1155 }
1156 }
1157 publish_events(state, &events);
1158 WebReturnType::success_empty()
1159 }
1160 "SetWindowPosAndSize" => {
1161 let window_id: String = match service::get_arg(args, 0) {
1162 Ok(v) => v,
1163 Err(e) => return WebReturnType::error(e),
1164 };
1165 let pos: Option<Point> = service::get_optional_arg(args, 1).unwrap_or(None);
1166 let size: Option<WinSize> = service::get_optional_arg(args, 2).unwrap_or(None);
1167 match store.must_get::<Window>(&window_id) {
1168 Ok(mut win) => {
1169 if let Some(p) = pos {
1170 win.pos = p;
1171 }
1172 if let Some(s) = size {
1173 win.winsize = s;
1174 }
1175 match store.update(&mut win) {
1176 Ok(_) => WebReturnType::success_empty(),
1177 Err(e) => WebReturnType::error(e.to_string()),
1178 }
1179 }
1180 Err(e) => WebReturnType::error(e.to_string()),
1181 }
1182 }
1183 _ => WebReturnType::error(format!("unknown window method: {}", call.method)),
1184 }
1185}
1186
1187async fn handle_workspace_service(state: &AppState, call: &WebCallType) -> WebReturnType {
1188 let store = &state.wstore;
1189 let args = &call.args;
1190 match call.method.as_str() {
1212 "CreateWorkspace" => {
1213 let name: String = service::get_arg(args, 0).unwrap_or_default();
1214 let events = dispatch_to_reducer(
1215 state,
1216 agentmux_common::ipc::Command::CreateWorkspace { name: name.clone() },
1217 )
1218 .await;
1219 let workspace_id = events.iter().find_map(|e| match e {
1220 agentmux_common::ipc::Event::WorkspaceCreated { workspace_id, .. } => {
1221 Some(workspace_id.clone())
1222 }
1223 _ => None,
1224 });
1225 let mut apply_err: Option<String> = None;
1231 for ev in &events {
1232 if let Err(e) = crate::persist_subscriber::apply_event_to_wstore(ev, store) {
1233 apply_err = Some(e.to_string());
1234 break;
1235 }
1236 }
1237 if let Some(err) = apply_err {
1238 if let Some(id) = workspace_id.as_ref() {
1239 compensate_via_reducer(
1240 state,
1241 agentmux_common::ipc::Command::DeleteWorkspace {
1242 workspace_id: id.clone(),
1243 force: false,
1247 },
1248 store,
1249 )
1250 .await;
1251 }
1252 return WebReturnType::error(format!(
1253 "CreateWorkspace: SQLite write failed: {}",
1254 err
1255 ));
1256 }
1257 publish_events(state, &events);
1258 match workspace_id {
1259 Some(id) => match wcore::get_workspace(store, &id) {
1260 Ok(ws) => {
1261 WebReturnType::success(serde_json::to_value(&ws).unwrap_or_default())
1262 }
1263 Err(e) => WebReturnType::error(format!(
1264 "CreateWorkspace: post-write read failed: {}",
1265 e
1266 )),
1267 },
1268 None => WebReturnType::error(
1269 "CreateWorkspace: reducer did not emit WorkspaceCreated".to_string(),
1270 ),
1271 }
1272 }
1273 "GetWorkspace" => {
1274 let ws_id: String = match service::get_arg(args, 0) {
1275 Ok(v) => v,
1276 Err(e) => return WebReturnType::error(e),
1277 };
1278 match wcore::get_workspace(store, &ws_id) {
1283 Ok(ws) => WebReturnType::success(serde_json::to_value(&ws).unwrap_or_default()),
1284 Err(e) => WebReturnType::error(e.to_string()),
1285 }
1286 }
1287 "DeleteWorkspace" => {
1288 let ws_id: String = match service::get_arg(args, 0) {
1289 Ok(v) => v,
1290 Err(e) => return WebReturnType::error(e),
1291 };
1292 let exists_in_wstore = match wstore_workspace_exists(store, &ws_id) {
1316 Ok(v) => v,
1317 Err(e) => {
1318 return WebReturnType::error(format!(
1319 "DeleteWorkspace: SQLite read failed: {}",
1320 e
1321 ))
1322 }
1323 };
1324 if !exists_in_wstore {
1325 let exists_in_state = state
1326 .srv_state
1327 .lock()
1328 .await
1329 .workspaces
1330 .contains_key(&ws_id);
1331 if !exists_in_state {
1332 return WebReturnType::error(format!(
1333 "DeleteWorkspace: workspace not found: {}",
1334 ws_id
1335 ));
1336 }
1337 }
1338 match crate::sagas::delete_workspace::run(state, ws_id.clone()).await {
1339 Ok(_) => WebReturnType::success_empty(),
1340 Err(e) => WebReturnType::error(format!("DeleteWorkspace failed: {}", e)),
1341 }
1342 }
1343 "ListWorkspaces" => match wcore::list_workspaces(store) {
1344 Ok(list) => WebReturnType::success(serde_json::to_value(&list).unwrap_or_default()),
1345 Err(e) => WebReturnType::error(e.to_string()),
1346 },
1347 "CreateTab" => {
1354 let ws_id: String = match service::get_arg(args, 0) {
1355 Ok(v) => v,
1356 Err(e) => return WebReturnType::error(e),
1357 };
1358 let tab_name: String = service::get_arg(args, 1).unwrap_or_default();
1359 let activate: bool = service::get_arg(args, 2).unwrap_or(true);
1360 let resolved_name = if tab_name.is_empty() {
1368 match store.get::<Workspace>(&ws_id) {
1369 Ok(Some(ws)) => {
1370 format!("tab{}", ws.tabids.len() + ws.pinnedtabids.len() + 1)
1371 }
1372 _ => "tab1".to_string(),
1373 }
1374 } else {
1375 tab_name.clone()
1376 };
1377 let events = dispatch_to_reducer(
1378 state,
1379 agentmux_common::ipc::Command::CreateTab {
1380 workspace_id: ws_id.clone(),
1381 name: resolved_name,
1382 },
1383 )
1384 .await;
1385 if let Some(err_msg) = events.iter().find_map(|e| match e {
1392 agentmux_common::ipc::Event::Error { message, .. } => Some(message.clone()),
1393 _ => None,
1394 }) {
1395 return WebReturnType::error(err_msg);
1396 }
1397 let tab_id = events.iter().find_map(|e| match e {
1398 agentmux_common::ipc::Event::TabCreated { tab_id, .. } => Some(tab_id.clone()),
1399 _ => None,
1400 });
1401 let mut apply_err: Option<String> = None;
1404 for ev in &events {
1405 if let Err(e) = crate::persist_subscriber::apply_event_to_wstore(ev, store) {
1406 apply_err = Some(e.to_string());
1407 break;
1408 }
1409 }
1410 if let Some(err) = apply_err {
1411 if let Some(tid) = tab_id.as_ref() {
1412 compensate_via_reducer(
1413 state,
1414 agentmux_common::ipc::Command::DeleteTab {
1415 workspace_id: ws_id.clone(),
1416 tab_id: tid.clone(),
1417 force: true,
1422 },
1423 store,
1424 )
1425 .await;
1426 }
1427 return WebReturnType::error(format!("CreateTab: SQLite write failed: {}", err));
1428 }
1429 publish_events(state, &events);
1430 let auto_activated = events
1433 .iter()
1434 .any(|e| matches!(e, agentmux_common::ipc::Event::ActiveTabChanged { .. }));
1435 if activate && !auto_activated {
1436 if let Some(tid) = tab_id.as_ref() {
1437 let active_events = dispatch_to_reducer(
1438 state,
1439 agentmux_common::ipc::Command::SetActiveTab {
1440 workspace_id: ws_id.clone(),
1441 tab_id: tid.clone(),
1442 },
1443 )
1444 .await;
1445 let mut active_err: Option<String> = None;
1446 for ev in &active_events {
1447 if let Err(e) =
1448 crate::persist_subscriber::apply_event_to_wstore(ev, store)
1449 {
1450 active_err = Some(e.to_string());
1451 break;
1452 }
1453 }
1454 if active_err.is_none() {
1455 publish_events(state, &active_events);
1456 }
1457 if let Some(err) = active_err {
1461 tracing::warn!(
1462 "CreateTab: post-create activate failed: {}",
1463 err
1464 );
1465 }
1466 }
1467 }
1468 match tab_id {
1469 Some(id) => {
1470 let mut updates = vec![];
1471 if let Ok(tab) = store.must_get::<Tab>(&id) {
1472 updates.push(WaveObjUpdate {
1473 updatetype: "update".into(),
1474 otype: OTYPE_TAB.to_string(),
1475 oid: id.clone(),
1476 obj: Some(wave_obj_to_value(&tab)),
1477 });
1478 }
1479 if let Ok(ws) = store.must_get::<Workspace>(&ws_id) {
1480 updates.push(WaveObjUpdate {
1481 updatetype: "update".into(),
1482 otype: OTYPE_WORKSPACE.to_string(),
1483 oid: ws_id.clone(),
1484 obj: Some(wave_obj_to_value(&ws)),
1485 });
1486 }
1487 WebReturnType::success_data_updates(
1488 serde_json::to_value(&id).unwrap_or_default(),
1489 updates,
1490 )
1491 }
1492 None => WebReturnType::error(
1493 "CreateTab: reducer did not emit TabCreated".to_string(),
1494 ),
1495 }
1496 }
1497 "SetActiveTab" => {
1502 let ws_id: String = match service::get_arg(args, 0) {
1503 Ok(v) => v,
1504 Err(e) => return WebReturnType::error(e),
1505 };
1506 let tab_id: String = match service::get_arg(args, 1) {
1507 Ok(v) => v,
1508 Err(e) => return WebReturnType::error(e),
1509 };
1510 let healed = wcore::heal_layout(store, &tab_id).unwrap_or(false);
1518 if healed {
1519 if let Ok(tab) = store.must_get::<Tab>(&tab_id) {
1520 if !tab.layoutstate.is_empty() {
1521 if let Ok(Some(layout)) = store.get::<LayoutState>(&tab.layoutstate) {
1522 let focus_events = dispatch_to_reducer(
1525 state,
1526 agentmux_common::ipc::Command::SetFocusedNode {
1527 tab_id: tab_id.clone(),
1528 node_id: layout.focusednodeid.clone(),
1529 },
1530 )
1531 .await;
1532 publish_events(state, &focus_events);
1533 let mag_events = dispatch_to_reducer(
1534 state,
1535 agentmux_common::ipc::Command::SetMagnifiedNode {
1536 tab_id: tab_id.clone(),
1537 node_id: layout.magnifiednodeid.clone(),
1538 },
1539 )
1540 .await;
1541 publish_events(state, &mag_events);
1542 }
1543 }
1544 }
1545 }
1546
1547 let events = dispatch_to_reducer(
1553 state,
1554 agentmux_common::ipc::Command::SetActiveTab {
1555 workspace_id: ws_id.clone(),
1556 tab_id: tab_id.clone(),
1557 },
1558 )
1559 .await;
1560 if let Some(err_msg) = events.iter().find_map(|e| match e {
1563 agentmux_common::ipc::Event::Error { message, .. } => Some(message.clone()),
1564 _ => None,
1565 }) {
1566 return WebReturnType::error(err_msg);
1567 }
1568 let mut apply_err: Option<String> = None;
1575 for ev in &events {
1576 if let Err(e) = crate::persist_subscriber::apply_event_to_wstore(ev, store) {
1577 apply_err = Some(e.to_string());
1578 break;
1579 }
1580 }
1581 if let Some(err) = apply_err {
1582 return WebReturnType::error(format!("SetActiveTab: SQLite write failed: {}", err));
1583 }
1584 publish_events(state, &events);
1585 if let Ok(ws) = store.must_get::<Workspace>(&ws_id) {
1586 let update = WaveObjUpdate {
1587 updatetype: "update".into(),
1588 otype: OTYPE_WORKSPACE.to_string(),
1589 oid: ws_id.clone(),
1590 obj: Some(wave_obj_to_value(&ws)),
1591 };
1592 WebReturnType::success_with_updates(vec![update])
1593 } else {
1594 WebReturnType::success_empty()
1595 }
1596 }
1597 "CloseTab" => {
1607 let ws_id: String = match service::get_arg(args, 0) {
1608 Ok(v) => v,
1609 Err(e) => return WebReturnType::error(e),
1610 };
1611 let tab_id: String = match service::get_arg(args, 1) {
1612 Ok(v) => v,
1613 Err(e) => return WebReturnType::error(e),
1614 };
1615 if let Err(reason) =
1616 crate::sagas::delete_tab::run(state, ws_id.clone(), tab_id.clone()).await
1617 {
1618 return WebReturnType::error(reason);
1619 }
1620 let rtn = CloseTabRtnType {
1621 closewindow: false,
1622 newactivetabid: String::new(),
1623 };
1624 let mut updates = vec![WaveObjUpdate {
1625 updatetype: "delete".into(),
1626 otype: OTYPE_TAB.to_string(),
1627 oid: tab_id.clone(),
1628 obj: None,
1629 }];
1630 if let Ok(ws) = store.must_get::<Workspace>(&ws_id) {
1631 updates.push(WaveObjUpdate {
1632 updatetype: "update".into(),
1633 otype: OTYPE_WORKSPACE.to_string(),
1634 oid: ws_id.clone(),
1635 obj: Some(wave_obj_to_value(&ws)),
1636 });
1637 }
1638 WebReturnType::success_data_updates(
1639 serde_json::to_value(&rtn).unwrap_or_default(),
1640 updates,
1641 )
1642 }
1643 "UpdateWorkspace" => {
1648 let ws_id: String = match service::get_arg(args, 0) {
1649 Ok(v) => v,
1650 Err(e) => return WebReturnType::error(e),
1651 };
1652 let name: Option<String> = service::get_optional_arg(args, 1).unwrap_or(None);
1653 let Some(name) = name else {
1654 return WebReturnType::success_empty();
1655 };
1656 let events = dispatch_to_reducer(
1657 state,
1658 agentmux_common::ipc::Command::RenameWorkspace {
1659 workspace_id: ws_id.clone(),
1660 name,
1661 },
1662 )
1663 .await;
1664 if let Some(err_msg) = events.iter().find_map(|e| match e {
1665 agentmux_common::ipc::Event::Error { message, .. } => Some(message.clone()),
1666 _ => None,
1667 }) {
1668 return WebReturnType::error(err_msg);
1669 }
1670 for ev in &events {
1671 if let Err(e) = crate::persist_subscriber::apply_event_to_wstore(ev, store) {
1672 return WebReturnType::error(format!(
1673 "UpdateWorkspace: SQLite write failed: {}",
1674 e
1675 ));
1676 }
1677 }
1678 publish_events(state, &events);
1679 WebReturnType::success_empty()
1680 }
1681 "UpdateTabIds" => {
1692 let ws_id: String = match service::get_arg(args, 0) {
1693 Ok(v) => v,
1694 Err(e) => return WebReturnType::error(e),
1695 };
1696 let tab_ids: Vec<String> = match service::get_arg(args, 1) {
1697 Ok(v) => v,
1698 Err(e) => return WebReturnType::error(e),
1699 };
1700 let events = dispatch_to_reducer(
1702 state,
1703 agentmux_common::ipc::Command::ReorderTabsBulk {
1704 workspace_id: ws_id.clone(),
1705 tab_ids,
1706 },
1707 )
1708 .await;
1709 if let Some(err_msg) = events.iter().find_map(|e| match e {
1710 agentmux_common::ipc::Event::Error { message, .. } => Some(message.clone()),
1711 _ => None,
1712 }) {
1713 return WebReturnType::error(err_msg);
1714 }
1715 for ev in &events {
1716 if let Err(e) = crate::persist_subscriber::apply_event_to_wstore(ev, store) {
1717 return WebReturnType::error(format!(
1718 "UpdateTabIds: SQLite write failed: {}",
1719 e
1720 ));
1721 }
1722 }
1723 publish_events(state, &events);
1724 if let Ok(updated_ws) = store.must_get::<Workspace>(&ws_id) {
1725 let update = WaveObjUpdate {
1726 updatetype: "update".into(),
1727 otype: OTYPE_WORKSPACE.to_string(),
1728 oid: ws_id.clone(),
1729 obj: Some(wave_obj_to_value(&updated_ws)),
1730 };
1731 return WebReturnType::success_with_updates(vec![update]);
1732 }
1733 WebReturnType::success_empty()
1734 }
1735 "MoveBlockToTab" => {
1741 let ws_id: String = match service::get_arg(args, 0) {
1742 Ok(v) => v,
1743 Err(e) => return WebReturnType::error(e),
1744 };
1745 let block_id: String = match service::get_arg(args, 1) {
1746 Ok(v) => v,
1747 Err(e) => return WebReturnType::error(e),
1748 };
1749 let source_tab_id: String = match service::get_arg(args, 2) {
1750 Ok(v) => v,
1751 Err(e) => return WebReturnType::error(e),
1752 };
1753 let dest_tab_id: String = match service::get_arg(args, 3) {
1754 Ok(v) => v,
1755 Err(e) => return WebReturnType::error(e),
1756 };
1757 let auto_close: bool = service::get_arg(args, 4).unwrap_or(true);
1758 tracing::info!(ws_id = %ws_id, block_id = %block_id, source_tab = %source_tab_id, dest_tab = %dest_tab_id, "[dnd:svc] MoveBlockToTab via reducer");
1759 if source_tab_id == dest_tab_id {
1768 return WebReturnType::success_empty();
1769 }
1770 let events = dispatch_to_reducer(
1776 state,
1777 agentmux_common::ipc::Command::MoveBlock {
1778 block_id: block_id.clone(),
1779 src_tab_id: source_tab_id.clone(),
1780 dst_tab_id: dest_tab_id.clone(),
1781 dst_index: u32::MAX,
1782 },
1783 )
1784 .await;
1785 if let Some(err_msg) = events.iter().find_map(|e| match e {
1786 agentmux_common::ipc::Event::Error { message, .. } => Some(message.clone()),
1787 _ => None,
1788 }) {
1789 return WebReturnType::error(err_msg);
1790 }
1791 for ev in &events {
1792 if let Err(e) = crate::persist_subscriber::apply_event_to_wstore(ev, store) {
1793 return WebReturnType::error(format!(
1794 "MoveBlockToTab: SQLite write failed: {}",
1795 e
1796 ));
1797 }
1798 }
1799 publish_events(state, &events);
1800 if auto_close {
1802 let should_close = match store.must_get::<Tab>(&source_tab_id) {
1803 Ok(t) => t.blockids.is_empty(),
1804 Err(_) => false,
1805 };
1806 if should_close {
1807 let total_tabs = match store.must_get::<Workspace>(&ws_id) {
1808 Ok(ws) => ws.tabids.len() + ws.pinnedtabids.len(),
1809 Err(_) => 0,
1810 };
1811 if total_tabs > 1 {
1812 let close_events = dispatch_to_reducer(
1813 state,
1814 agentmux_common::ipc::Command::DeleteTab {
1815 workspace_id: ws_id.clone(),
1816 tab_id: source_tab_id.clone(),
1817 force: false,
1822 },
1823 )
1824 .await;
1825 for ev in &close_events {
1826 let _ = crate::persist_subscriber::apply_event_to_wstore(ev, store);
1827 }
1828 publish_events(state, &close_events);
1829 }
1830 }
1831 }
1832 let mut updates = vec![];
1833 if let Ok(src) = store.must_get::<Tab>(&source_tab_id) {
1834 updates.push(WaveObjUpdate {
1835 updatetype: "update".into(),
1836 otype: OTYPE_TAB.to_string(),
1837 oid: source_tab_id.clone(),
1838 obj: Some(wave_obj_to_value(&src)),
1839 });
1840 }
1841 if let Ok(dst) = store.must_get::<Tab>(&dest_tab_id) {
1842 updates.push(WaveObjUpdate {
1843 updatetype: "update".into(),
1844 otype: OTYPE_TAB.to_string(),
1845 oid: dest_tab_id.clone(),
1846 obj: Some(wave_obj_to_value(&dst)),
1847 });
1848 }
1849 if let Ok(ws) = store.must_get::<Workspace>(&ws_id) {
1850 updates.push(WaveObjUpdate {
1851 updatetype: "update".into(),
1852 otype: OTYPE_WORKSPACE.to_string(),
1853 oid: ws_id.clone(),
1854 obj: Some(wave_obj_to_value(&ws)),
1855 });
1856 }
1857 WebReturnType::success_with_updates(updates)
1858 }
1859 "PromoteBlockToTab" => {
1864 let ws_id: String = match service::get_arg(args, 0) {
1865 Ok(v) => v,
1866 Err(e) => return WebReturnType::error(e),
1867 };
1868 let block_id: String = match service::get_arg(args, 1) {
1869 Ok(v) => v,
1870 Err(e) => return WebReturnType::error(e),
1871 };
1872 let source_tab_id: String = match service::get_arg(args, 2) {
1873 Ok(v) => v,
1874 Err(e) => return WebReturnType::error(e),
1875 };
1876 let auto_close: bool = service::get_arg(args, 3).unwrap_or(true);
1877 tracing::info!(ws_id = %ws_id, block_id = %block_id, source_tab = %source_tab_id, "[dnd:svc] PromoteBlockToTab via saga");
1878 let saga_result = crate::sagas::promote_block_to_tab::run(
1879 state,
1880 block_id.clone(),
1881 source_tab_id.clone(),
1882 ws_id.clone(),
1883 )
1884 .await;
1885 let new_tab_oid = match saga_result {
1886 Ok(v) => v
1887 .get("new_tab_id")
1888 .and_then(|v| v.as_str())
1889 .unwrap_or_default()
1890 .to_string(),
1891 Err(reason) => return WebReturnType::error(reason),
1892 };
1893
1894 if let Err(e) = setup_torn_off_block_layout(store, &new_tab_oid, &block_id) {
1898 tracing::warn!(new_tab = %new_tab_oid, "PromoteBlockToTab: layout setup failed: {}", e);
1899 }
1900 if let Err(e) = queue_source_layout_delete(store, &source_tab_id, &block_id) {
1902 tracing::warn!(source_tab = %source_tab_id, "PromoteBlockToTab: source layout delete-action enqueue failed: {}", e);
1903 }
1904 let active_events = dispatch_to_reducer(
1906 state,
1907 agentmux_common::ipc::Command::SetActiveTab {
1908 workspace_id: ws_id.clone(),
1909 tab_id: new_tab_oid.clone(),
1910 },
1911 )
1912 .await;
1913 for ev in &active_events {
1914 if let Err(e) = crate::persist_subscriber::apply_event_to_wstore(ev, store) {
1915 tracing::warn!("PromoteBlockToTab: SetActiveTab apply failed: {}", e);
1916 }
1917 }
1918 publish_events(state, &active_events);
1919
1920 if auto_close {
1922 let should_close = match store.must_get::<Tab>(&source_tab_id) {
1923 Ok(t) => t.blockids.is_empty(),
1924 Err(_) => false,
1925 };
1926 if should_close {
1927 let total_tabs = match store.must_get::<Workspace>(&ws_id) {
1928 Ok(ws) => ws.tabids.len() + ws.pinnedtabids.len(),
1929 Err(_) => 0,
1930 };
1931 if total_tabs > 1 {
1932 let close_events = dispatch_to_reducer(
1933 state,
1934 agentmux_common::ipc::Command::DeleteTab {
1935 workspace_id: ws_id.clone(),
1936 tab_id: source_tab_id.clone(),
1937 force: false,
1942 },
1943 )
1944 .await;
1945 for ev in &close_events {
1946 let _ = crate::persist_subscriber::apply_event_to_wstore(ev, store);
1947 }
1948 publish_events(state, &close_events);
1949 }
1950 }
1951 }
1952
1953 let mut updates = vec![];
1954 if let Ok(new_tab) = store.must_get::<Tab>(&new_tab_oid) {
1955 updates.push(WaveObjUpdate {
1956 updatetype: "update".into(),
1957 otype: OTYPE_TAB.to_string(),
1958 oid: new_tab_oid.clone(),
1959 obj: Some(wave_obj_to_value(&new_tab)),
1960 });
1961 }
1962 if let Ok(src) = store.must_get::<Tab>(&source_tab_id) {
1963 updates.push(WaveObjUpdate {
1964 updatetype: "update".into(),
1965 otype: OTYPE_TAB.to_string(),
1966 oid: source_tab_id.clone(),
1967 obj: Some(wave_obj_to_value(&src)),
1968 });
1969 }
1970 if let Ok(ws) = store.must_get::<Workspace>(&ws_id) {
1971 updates.push(WaveObjUpdate {
1972 updatetype: "update".into(),
1973 otype: OTYPE_WORKSPACE.to_string(),
1974 oid: ws_id.clone(),
1975 obj: Some(wave_obj_to_value(&ws)),
1976 });
1977 }
1978 WebReturnType::success_data_updates(
1979 serde_json::to_value(&new_tab_oid).unwrap_or_default(),
1980 updates,
1981 )
1982 }
1983 "ReorderTab" => {
1990 let ws_id: String = match service::get_arg(args, 0) {
1991 Ok(v) => v,
1992 Err(e) => return WebReturnType::error(e),
1993 };
1994 let tab_id: String = match service::get_arg(args, 1) {
1995 Ok(v) => v,
1996 Err(e) => return WebReturnType::error(e),
1997 };
1998 let new_index: usize = match service::get_arg(args, 2) {
1999 Ok(v) => v,
2000 Err(e) => return WebReturnType::error(e),
2001 };
2002 tracing::info!(ws_id = %ws_id, tab_id = %tab_id, new_index = %new_index, "[dnd:svc] ReorderTab");
2003 let new_index_u32 = u32::try_from(new_index).unwrap_or(u32::MAX);
2009 let events = dispatch_to_reducer(
2010 state,
2011 agentmux_common::ipc::Command::ReorderTab {
2012 workspace_id: ws_id.clone(),
2013 tab_id: tab_id.clone(),
2014 new_index: new_index_u32,
2015 },
2016 )
2017 .await;
2018 if let Some(err_msg) = events.iter().find_map(|e| match e {
2020 agentmux_common::ipc::Event::Error { message, .. } => Some(message.clone()),
2021 _ => None,
2022 }) {
2023 return WebReturnType::error(err_msg);
2024 }
2025 let mut apply_err: Option<String> = None;
2026 for ev in &events {
2027 if let Err(e) = crate::persist_subscriber::apply_event_to_wstore(ev, store) {
2028 apply_err = Some(e.to_string());
2029 break;
2030 }
2031 }
2032 if let Some(err) = apply_err {
2033 return WebReturnType::error(format!("ReorderTab: SQLite write failed: {}", err));
2034 }
2035 publish_events(state, &events);
2036 if let Ok(ws) = store.must_get::<Workspace>(&ws_id) {
2037 let update = WaveObjUpdate {
2038 updatetype: "update".into(),
2039 otype: OTYPE_WORKSPACE.to_string(),
2040 oid: ws_id.clone(),
2041 obj: Some(wave_obj_to_value(&ws)),
2042 };
2043 WebReturnType::success_with_updates(vec![update])
2044 } else {
2045 WebReturnType::success_empty()
2046 }
2047 }
2048 "MoveTabToWorkspace" => {
2055 let tab_id: String = match service::get_arg(args, 0) {
2056 Ok(v) => v,
2057 Err(e) => return WebReturnType::error(e),
2058 };
2059 let source_ws_id: String = match service::get_arg(args, 1) {
2060 Ok(v) => v,
2061 Err(e) => return WebReturnType::error(e),
2062 };
2063 let dest_ws_id: String = match service::get_arg(args, 2) {
2064 Ok(v) => v,
2065 Err(e) => return WebReturnType::error(e),
2066 };
2067 let insert_index: Option<u32> = service::get_arg::<usize>(args, 3)
2068 .ok()
2069 .map(|v| v.try_into().unwrap_or(u32::MAX));
2070 tracing::info!(tab_id = %tab_id, source_ws = %source_ws_id, dest_ws = %dest_ws_id, insert_index = ?insert_index, "[dnd:svc] MoveTabToWorkspace via reducer");
2071 if source_ws_id == dest_ws_id {
2077 return WebReturnType::success_empty();
2078 }
2079 match store.get::<Workspace>(&source_ws_id) {
2090 Ok(Some(src_ws)) => {
2091 let total_tabs = src_ws.tabids.len() + src_ws.pinnedtabids.len();
2092 if total_tabs <= 1 {
2093 return WebReturnType::error(
2094 "cannot move last tab out of workspace".to_string(),
2095 );
2096 }
2097 }
2098 Ok(None) => {
2099 return WebReturnType::error(format!(
2100 "MoveTabToWorkspace: source workspace not found: {}",
2101 source_ws_id
2102 ));
2103 }
2104 Err(e) => {
2105 return WebReturnType::error(format!(
2106 "MoveTabToWorkspace: workspace read failed: {}",
2107 e
2108 ));
2109 }
2110 }
2111 let dst_index = insert_index.unwrap_or(u32::MAX);
2112 let events = dispatch_to_reducer(
2113 state,
2114 agentmux_common::ipc::Command::MoveTab {
2115 tab_id: tab_id.clone(),
2116 src_workspace_id: source_ws_id.clone(),
2117 dst_workspace_id: dest_ws_id.clone(),
2118 dst_index,
2119 },
2120 )
2121 .await;
2122 if let Some(err_msg) = events.iter().find_map(|e| match e {
2123 agentmux_common::ipc::Event::Error { message, .. } => Some(message.clone()),
2124 _ => None,
2125 }) {
2126 return WebReturnType::error(err_msg);
2127 }
2128 for ev in &events {
2129 if let Err(e) = crate::persist_subscriber::apply_event_to_wstore(ev, store) {
2130 return WebReturnType::error(format!(
2131 "MoveTabToWorkspace: SQLite write failed: {}",
2132 e
2133 ));
2134 }
2135 }
2136 publish_events(state, &events);
2137 let mut updates = Vec::new();
2138 if let Ok(src_ws) = store.must_get::<Workspace>(&source_ws_id) {
2139 updates.push(WaveObjUpdate {
2140 updatetype: "update".into(),
2141 otype: OTYPE_WORKSPACE.to_string(),
2142 oid: source_ws_id.clone(),
2143 obj: Some(wave_obj_to_value(&src_ws)),
2144 });
2145 }
2146 if let Ok(dst_ws) = store.must_get::<Workspace>(&dest_ws_id) {
2147 updates.push(WaveObjUpdate {
2148 updatetype: "update".into(),
2149 otype: OTYPE_WORKSPACE.to_string(),
2150 oid: dest_ws_id.clone(),
2151 obj: Some(wave_obj_to_value(&dst_ws)),
2152 });
2153 }
2154 WebReturnType::success_with_updates(updates)
2155 }
2156 "RestoreTornOffTab" => {
2162 let tab_id: String = match service::get_arg(args, 0) {
2163 Ok(v) => v,
2164 Err(e) => return WebReturnType::error(e),
2165 };
2166 let source_ws_id: String = match service::get_arg(args, 1) {
2167 Ok(v) => v,
2168 Err(e) => return WebReturnType::error(e),
2169 };
2170 let dest_ws_id: String = match service::get_arg(args, 2) {
2171 Ok(v) => v,
2172 Err(e) => return WebReturnType::error(e),
2173 };
2174 let insert_index: Option<u32> = service::get_arg::<usize>(args, 3)
2175 .ok()
2176 .map(|v| v.try_into().unwrap_or(u32::MAX));
2177 tracing::info!(tab_id = %tab_id, source_ws = %source_ws_id, dest_ws = %dest_ws_id, insert_index = ?insert_index, "[dnd:svc] RestoreTornOffTab via saga");
2178 let saga_result = crate::sagas::restore_torn_off_tab::run(
2179 state,
2180 tab_id,
2181 source_ws_id.clone(),
2182 dest_ws_id.clone(),
2183 insert_index,
2184 )
2185 .await;
2186 match saga_result {
2187 Ok(_) => {
2188 let mut updates = Vec::new();
2189 match store.get::<Workspace>(&source_ws_id) {
2190 Ok(Some(src_ws)) => {
2191 updates.push(WaveObjUpdate {
2192 updatetype: "update".into(),
2193 otype: OTYPE_WORKSPACE.to_string(),
2194 oid: source_ws_id.clone(),
2195 obj: Some(wave_obj_to_value(&src_ws)),
2196 });
2197 }
2198 Ok(None) => {
2199 updates.push(WaveObjUpdate {
2200 updatetype: "delete".into(),
2201 otype: OTYPE_WORKSPACE.to_string(),
2202 oid: source_ws_id.clone(),
2203 obj: None,
2204 });
2205 }
2206 Err(_) => {}
2207 }
2208 if let Ok(dst_ws) = store.must_get::<Workspace>(&dest_ws_id) {
2209 updates.push(WaveObjUpdate {
2210 updatetype: "update".into(),
2211 otype: OTYPE_WORKSPACE.to_string(),
2212 oid: dest_ws_id.clone(),
2213 obj: Some(wave_obj_to_value(&dst_ws)),
2214 });
2215 }
2216 WebReturnType::success_with_updates(updates)
2217 }
2218 Err(reason) => WebReturnType::error(reason),
2219 }
2220 }
2221 "TearOffBlock" => {
2233 let block_id: String = match service::get_arg(args, 0) {
2234 Ok(v) => v,
2235 Err(e) => return WebReturnType::error(e),
2236 };
2237 let source_tab_id: String = match service::get_arg(args, 1) {
2238 Ok(v) => v,
2239 Err(e) => return WebReturnType::error(e),
2240 };
2241 let source_ws_id: String = match service::get_arg(args, 2) {
2242 Ok(v) => v,
2243 Err(e) => return WebReturnType::error(e),
2244 };
2245 let auto_close: bool = service::get_arg(args, 3).unwrap_or(true);
2246 tracing::info!(block_id = %block_id, source_tab = %source_tab_id, source_ws = %source_ws_id, "[dnd:svc] TearOffBlock via saga");
2247 let saga_result = crate::sagas::tear_off_block::run(
2248 state,
2249 block_id.clone(),
2250 source_tab_id.clone(),
2251 source_ws_id.clone(),
2252 )
2253 .await;
2254 let (new_ws_oid, new_tab_oid) = match saga_result {
2255 Ok(value) => {
2256 let new_ws_oid = value
2257 .get("new_workspace_id")
2258 .and_then(|v| v.as_str())
2259 .unwrap_or_default()
2260 .to_string();
2261 let new_tab_oid = value
2262 .get("new_tab_id")
2263 .and_then(|v| v.as_str())
2264 .unwrap_or_default()
2265 .to_string();
2266 (new_ws_oid, new_tab_oid)
2267 }
2268 Err(reason) => return WebReturnType::error(reason),
2269 };
2270
2271 if let Err(e) = setup_torn_off_block_layout(store, &new_tab_oid, &block_id) {
2276 tracing::warn!(new_tab = %new_tab_oid, "TearOffBlock: layout setup failed: {} (block in tab but layout malformed)", e);
2277 }
2278 if let Err(e) = queue_source_layout_delete(store, &source_tab_id, &block_id) {
2281 tracing::warn!(source_tab = %source_tab_id, "TearOffBlock: source layout delete-action enqueue failed: {}", e);
2282 }
2283
2284 if auto_close {
2289 let should_close = match store.must_get::<Tab>(&source_tab_id) {
2290 Ok(t) => t.blockids.is_empty(),
2291 Err(_) => false,
2292 };
2293 if should_close {
2294 let total_tabs = match store.must_get::<Workspace>(&source_ws_id) {
2295 Ok(ws) => ws.tabids.len() + ws.pinnedtabids.len(),
2296 Err(_) => 0,
2297 };
2298 if total_tabs > 1 {
2299 tracing::info!(source_tab = %source_tab_id, "[dnd:svc] auto-closing empty source tab after TearOffBlock");
2300 let close_events = dispatch_to_reducer(
2301 state,
2302 agentmux_common::ipc::Command::DeleteTab {
2303 workspace_id: source_ws_id.clone(),
2304 tab_id: source_tab_id.clone(),
2305 force: false,
2308 },
2309 )
2310 .await;
2311 for ev in &close_events {
2312 let _ = crate::persist_subscriber::apply_event_to_wstore(ev, store);
2313 }
2314 publish_events(state, &close_events);
2315 }
2316 }
2317 }
2318
2319 let mut updates = Vec::new();
2320 if let Ok(src_tab) = store.must_get::<Tab>(&source_tab_id) {
2321 updates.push(WaveObjUpdate {
2322 updatetype: "update".into(),
2323 otype: OTYPE_TAB.to_string(),
2324 oid: source_tab_id.clone(),
2325 obj: Some(wave_obj_to_value(&src_tab)),
2326 });
2327 }
2328 if let Ok(src_ws) = store.must_get::<Workspace>(&source_ws_id) {
2329 updates.push(WaveObjUpdate {
2330 updatetype: "update".into(),
2331 otype: OTYPE_WORKSPACE.to_string(),
2332 oid: source_ws_id.clone(),
2333 obj: Some(wave_obj_to_value(&src_ws)),
2334 });
2335 }
2336 if let Ok(new_ws) = store.must_get::<Workspace>(&new_ws_oid) {
2337 updates.push(WaveObjUpdate {
2338 updatetype: "update".into(),
2339 otype: OTYPE_WORKSPACE.to_string(),
2340 oid: new_ws_oid.clone(),
2341 obj: Some(wave_obj_to_value(&new_ws)),
2342 });
2343 }
2344 WebReturnType::success_data_updates(
2345 serde_json::to_value(&new_ws_oid).unwrap_or_default(),
2346 updates,
2347 )
2348 }
2349 "RedockFloatingPane" => {
2358 let block_id: String = match service::get_arg(args, 0) {
2359 Ok(v) => v,
2360 Err(e) => return WebReturnType::error(e),
2361 };
2362 let source_tab_id: String = match service::get_arg(args, 1) {
2363 Ok(v) => v,
2364 Err(e) => return WebReturnType::error(e),
2365 };
2366 let source_ws_id: String = match service::get_arg(args, 2) {
2367 Ok(v) => v,
2368 Err(e) => return WebReturnType::error(e),
2369 };
2370 let target_tab_id: String = match service::get_arg(args, 3) {
2371 Ok(v) => v,
2372 Err(e) => return WebReturnType::error(e),
2373 };
2374 let target_ws_id: String = match service::get_arg(args, 4) {
2375 Ok(v) => v,
2376 Err(e) => return WebReturnType::error(e),
2377 };
2378 tracing::info!(
2379 block_id = %block_id,
2380 source_tab = %source_tab_id,
2381 source_ws = %source_ws_id,
2382 target_tab = %target_tab_id,
2383 target_ws = %target_ws_id,
2384 "[dnd:svc] RedockFloatingPane via saga"
2385 );
2386 let saga_result = crate::sagas::redock_floating_pane::run(
2387 state,
2388 block_id.clone(),
2389 source_tab_id.clone(),
2390 source_ws_id.clone(),
2391 target_tab_id.clone(),
2392 target_ws_id.clone(),
2393 None,
2394 )
2395 .await;
2396 if let Err(reason) = saga_result {
2397 return WebReturnType::error(reason);
2398 }
2399
2400 if let Err(e) = queue_target_layout_insert(store, &target_tab_id, &block_id) {
2413 tracing::error!(
2414 target_tab = %target_tab_id,
2415 "RedockFloatingPane: target layout insert failed — aborting broadcast: {}",
2416 e
2417 );
2418 return WebReturnType::error(format!(
2419 "redock layout insert failed: {e}"
2420 ));
2421 }
2422 if let Err(e) = queue_source_layout_delete(store, &source_tab_id, &block_id) {
2423 tracing::error!(
2424 source_tab = %source_tab_id,
2425 "RedockFloatingPane: source layout delete failed — aborting broadcast: {}",
2426 e
2427 );
2428 return WebReturnType::error(format!(
2429 "redock layout delete failed: {e}"
2430 ));
2431 }
2432
2433 let mut updates = Vec::new();
2434 if let Ok(src_tab) = store.must_get::<Tab>(&source_tab_id) {
2443 if let Ok(src_layout) = store.must_get::<LayoutState>(&src_tab.layoutstate) {
2444 updates.push(WaveObjUpdate {
2445 updatetype: "update".into(),
2446 otype: OTYPE_LAYOUT.to_string(),
2447 oid: src_tab.layoutstate.clone(),
2448 obj: Some(wave_obj_to_value(&src_layout)),
2449 });
2450 }
2451 updates.push(WaveObjUpdate {
2452 updatetype: "update".into(),
2453 otype: OTYPE_TAB.to_string(),
2454 oid: source_tab_id.clone(),
2455 obj: Some(wave_obj_to_value(&src_tab)),
2456 });
2457 }
2458 if let Ok(dst_tab) = store.must_get::<Tab>(&target_tab_id) {
2459 if let Ok(dst_layout) = store.must_get::<LayoutState>(&dst_tab.layoutstate) {
2460 updates.push(WaveObjUpdate {
2461 updatetype: "update".into(),
2462 otype: OTYPE_LAYOUT.to_string(),
2463 oid: dst_tab.layoutstate.clone(),
2464 obj: Some(wave_obj_to_value(&dst_layout)),
2465 });
2466 }
2467 updates.push(WaveObjUpdate {
2468 updatetype: "update".into(),
2469 otype: OTYPE_TAB.to_string(),
2470 oid: target_tab_id.clone(),
2471 obj: Some(wave_obj_to_value(&dst_tab)),
2472 });
2473 }
2474 for update in &updates {
2482 let oref = format!("{}:{}", update.otype, update.oid);
2483 if let Ok(data) = serde_json::to_value(update) {
2484 state.event_bus.broadcast_event(
2485 &crate::backend::eventbus::WSEventType {
2486 eventtype: "waveobj:update".to_string(),
2487 oref,
2488 data: Some(data),
2489 },
2490 );
2491 }
2492 }
2493
2494 WebReturnType::success_data_updates(
2495 serde_json::json!({
2496 "redocked": true,
2497 "block_id": block_id,
2498 "target_tab_id": target_tab_id,
2499 }),
2500 updates,
2501 )
2502 }
2503
2504 "TearOffTab" => {
2510 let tab_id: String = match service::get_arg(args, 0) {
2511 Ok(v) => v,
2512 Err(e) => return WebReturnType::error(e),
2513 };
2514 let source_ws_id: String = match service::get_arg(args, 1) {
2515 Ok(v) => v,
2516 Err(e) => return WebReturnType::error(e),
2517 };
2518 tracing::info!(tab_id = %tab_id, source_ws = %source_ws_id, "[dnd:svc] TearOffTab via saga");
2519 match crate::sagas::tear_off_tab::run(state, tab_id, source_ws_id.clone()).await {
2520 Ok(saga_result) => {
2521 let new_ws_oid = saga_result
2522 .get("new_workspace_id")
2523 .and_then(|v| v.as_str())
2524 .unwrap_or_default()
2525 .to_string();
2526 let mut updates = Vec::new();
2527 if let Ok(src_ws) = store.must_get::<Workspace>(&source_ws_id) {
2528 updates.push(WaveObjUpdate {
2529 updatetype: "update".into(),
2530 otype: OTYPE_WORKSPACE.to_string(),
2531 oid: source_ws_id.clone(),
2532 obj: Some(wave_obj_to_value(&src_ws)),
2533 });
2534 }
2535 if let Ok(new_ws) = store.must_get::<Workspace>(&new_ws_oid) {
2536 updates.push(WaveObjUpdate {
2537 updatetype: "update".into(),
2538 otype: OTYPE_WORKSPACE.to_string(),
2539 oid: new_ws_oid.clone(),
2540 obj: Some(wave_obj_to_value(&new_ws)),
2541 });
2542 }
2543 WebReturnType::success_data_updates(
2544 serde_json::to_value(&new_ws_oid).unwrap_or_default(),
2545 updates,
2546 )
2547 }
2548 Err(reason) => WebReturnType::error(reason),
2549 }
2550 }
2551 _ => WebReturnType::error(format!("unknown workspace method: {}", call.method)),
2552 }
2553}
2554
2555async fn handle_misc_service(state: &AppState, call: &WebCallType) -> WebReturnType {
2556 let _store = &state.wstore;
2557 let args = &call.args;
2558 match (call.service.as_str(), call.method.as_str()) {
2559 ("userinput", "SendUserInputResponse") => {
2561 WebReturnType::success_empty()
2563 }
2564
2565 ("block", "GetControllerStatus") => {
2567 let block_id: String = match service::get_arg(args, 0) {
2568 Ok(v) => v,
2569 Err(e) => return WebReturnType::error(e),
2570 };
2571 match crate::backend::blockcontroller::get_block_controller_status(&block_id) {
2572 Some(status) => WebReturnType::success(
2573 serde_json::to_value(&status).unwrap_or(serde_json::Value::Null),
2574 ),
2575 None => {
2576 let default_status = crate::backend::blockcontroller::BlockControllerRuntimeStatus {
2577 blockid: block_id,
2578 ..Default::default()
2579 };
2580 WebReturnType::success(
2581 serde_json::to_value(&default_status).unwrap_or(serde_json::Value::Null),
2582 )
2583 }
2584 }
2585 }
2586 ("block", "SendCommand") | ("block", "SaveTerminalState") => {
2587 WebReturnType::success_empty()
2588 }
2589
2590 ("subagent", "ListActive") => {
2592 let subagents = state.subagent_watcher.list_active();
2593 WebReturnType::success(serde_json::to_value(&subagents).unwrap_or_default())
2594 }
2595 ("subagent", "GetHistory") => {
2596 let agent_id: String = match service::get_arg(args, 0) {
2597 Ok(v) => v,
2598 Err(e) => return WebReturnType::error(e),
2599 };
2600 let limit: usize = service::get_arg(args, 1).unwrap_or(100);
2601 let history = state.subagent_watcher.get_history(&agent_id, limit);
2602 WebReturnType::success(serde_json::to_value(&history).unwrap_or_default())
2603 }
2604 ("history", "List") => {
2606 let provider: Option<String> = service::get_optional_arg(args, 0).unwrap_or(None);
2607 let project: Option<String> = service::get_optional_arg(args, 1).unwrap_or(None);
2608 let offset: usize = service::get_arg(args, 2).unwrap_or(0);
2609 let limit: usize = service::get_arg(args, 3).unwrap_or(50);
2610 let sort_by: String = service::get_arg(args, 4).unwrap_or_else(|_| "modified_at".to_string());
2611 let sort_dir: String = service::get_arg(args, 5).unwrap_or_else(|_| "desc".to_string());
2612 let result = state.history_service.list(
2613 provider.as_deref(),
2614 project.as_deref(),
2615 offset,
2616 limit,
2617 &sort_by,
2618 &sort_dir,
2619 );
2620 WebReturnType::success(result)
2621 }
2622 ("history", "Get") => {
2623 let session_id: String = match service::get_arg(args, 0) {
2624 Ok(v) => v,
2625 Err(e) => return WebReturnType::error(e),
2626 };
2627 let result = state.history_service.get(&session_id);
2628 WebReturnType::success(result)
2629 }
2630 ("history", "Refresh") => {
2631 let result = state.history_service.refresh();
2632 WebReturnType::success(result)
2633 }
2634 ("history", "Delete") => {
2635 let session_id: String = match service::get_arg(args, 0) {
2636 Ok(v) => v,
2637 Err(e) => return WebReturnType::error(e),
2638 };
2639 let result = state.history_service.delete(&session_id);
2640 WebReturnType::success(result)
2641 }
2642 ("history", "Clear") => {
2643 let provider: Option<String> = service::get_optional_arg(args, 0).unwrap_or(None);
2644 let project: Option<String> = service::get_optional_arg(args, 1).unwrap_or(None);
2645 let result = state
2646 .history_service
2647 .clear(provider.as_deref(), project.as_deref());
2648 WebReturnType::success(result)
2649 }
2650
2651 ("subagent", "WatchAgent") => {
2652 let agent_id: String = match service::get_arg(args, 0) {
2653 Ok(v) => v,
2654 Err(e) => return WebReturnType::error(e),
2655 };
2656 let config_dir: String = match service::get_arg(args, 1) {
2657 Ok(v) => v,
2658 Err(e) => return WebReturnType::error(e),
2659 };
2660 let block_id: String = service::get_optional_arg(args, 2)
2665 .unwrap_or(None)
2666 .unwrap_or_default();
2667 state.subagent_watcher.watch_agent(&agent_id, &block_id, std::path::PathBuf::from(config_dir));
2668 WebReturnType::success_empty()
2669 }
2670
2671 ("agent", "define") => {
2673 let data: crate::backend::rpc_types::CommandAgentDefineData =
2674 match service::get_arg(args, 0) {
2675 Ok(v) => v,
2676 Err(e) => return WebReturnType::error(e),
2677 };
2678 match super::app_api::agent_define_core(state.wstore.clone(), state.broker.clone(), data).await {
2679 Ok(result) => WebReturnType::success(serde_json::to_value(&result).unwrap_or_default()),
2680 Err(e) => WebReturnType::error(e),
2681 }
2682 }
2683
2684 _ => WebReturnType::error(format!(
2685 "unknown service method: {}.{}",
2686 call.service, call.method
2687 )),
2688 }
2689}
2690
2691
2692fn find_tab_for_layout(store: &Store, layout_oid: &str) -> Option<String> {
2701 let tabs = store.get_all::<Tab>().ok()?;
2702 tabs.into_iter()
2703 .find(|t| t.layoutstate == layout_oid)
2704 .map(|t| t.oid)
2705}
2706
2707fn get_object_by_oref(store: &Store, oref_str: &str) -> Result<serde_json::Value, String> {
2709 let oref = crate::backend::ORef::parse(oref_str).map_err(|e| e.to_string())?;
2710
2711 match oref.otype.as_str() {
2713 OTYPE_CLIENT | OTYPE_WINDOW | OTYPE_WORKSPACE | OTYPE_TAB | OTYPE_LAYOUT | OTYPE_BLOCK => {}
2714 _ => return Err(format!("unknown otype: {}", oref.otype)),
2715 }
2716
2717 store
2721 .get_raw(&oref.otype, &oref.oid)
2722 .map_err(|e| e.to_string())?
2723 .ok_or_else(|| format!("not found: {}", oref_str))
2724}
2725
2726fn update_object(
2731 store: &Store,
2732 mut value: serde_json::Value,
2733) -> Result<(String, String, serde_json::Value), String> {
2734 let otype = value
2735 .get("otype")
2736 .and_then(|v| v.as_str())
2737 .ok_or_else(|| "UpdateObject: missing otype field".to_string())?
2738 .to_string();
2739 let oid = value
2740 .get("oid")
2741 .and_then(|v| v.as_str())
2742 .ok_or_else(|| "UpdateObject: missing oid field".to_string())?
2743 .to_string();
2744
2745 match otype.as_str() {
2747 OTYPE_CLIENT | OTYPE_WINDOW | OTYPE_WORKSPACE | OTYPE_TAB | OTYPE_LAYOUT | OTYPE_BLOCK => {}
2748 _ => return Err(format!("UpdateObject: unsupported otype: {}", otype)),
2749 }
2750
2751 let new_version = store
2755 .update_raw(&otype, &oid, &value)
2756 .map_err(|e| format!("UpdateObject: {}", e))?;
2757
2758 if let Some(obj) = value.as_object_mut() {
2760 obj.insert("version".to_string(), serde_json::json!(new_version));
2761 }
2762
2763 Ok((otype, oid, value))
2764}
2765
2766pub(crate) fn update_object_meta(
2768 store: &Store,
2769 oref_str: &str,
2770 meta_update: &MetaMapType,
2771) -> Result<(), String> {
2772 let oref = crate::backend::ORef::parse(oref_str).map_err(|e| e.to_string())?;
2773 match oref.otype.as_str() {
2774 OTYPE_CLIENT => {
2775 let mut obj = store.must_get::<Client>(&oref.oid).map_err(|e| e.to_string())?;
2776 obj.meta = merge_meta(&obj.meta, meta_update, true);
2777 store.update(&mut obj).map_err(|e| e.to_string())?;
2778 }
2779 OTYPE_WINDOW => {
2780 let mut obj = store.must_get::<Window>(&oref.oid).map_err(|e| e.to_string())?;
2781 obj.meta = merge_meta(&obj.meta, meta_update, true);
2782 store.update(&mut obj).map_err(|e| e.to_string())?;
2783 }
2784 OTYPE_WORKSPACE => {
2785 let mut obj = store
2786 .must_get::<Workspace>(&oref.oid)
2787 .map_err(|e| e.to_string())?;
2788 obj.meta = merge_meta(&obj.meta, meta_update, true);
2789 store.update(&mut obj).map_err(|e| e.to_string())?;
2790 }
2791 OTYPE_TAB => {
2792 let mut obj = store.must_get::<Tab>(&oref.oid).map_err(|e| e.to_string())?;
2793 obj.meta = merge_meta(&obj.meta, meta_update, true);
2794 store.update(&mut obj).map_err(|e| e.to_string())?;
2795 }
2796 OTYPE_BLOCK => {
2797 let mut obj = store.must_get::<Block>(&oref.oid).map_err(|e| e.to_string())?;
2798 obj.meta = merge_meta(&obj.meta, meta_update, true);
2799 store.update(&mut obj).map_err(|e| e.to_string())?;
2800 }
2801 _ => return Err(format!("cannot update meta for otype: {}", oref.otype)),
2802 }
2803 Ok(())
2804}
2805
2806
2807static ZOOM_MIRROR_GEN: std::sync::OnceLock<std::sync::Mutex<std::collections::HashMap<String, u64>>> =
2819 std::sync::OnceLock::new();
2820
2821fn zoom_mirror_gen() -> &'static std::sync::Mutex<std::collections::HashMap<String, u64>> {
2822 ZOOM_MIRROR_GEN.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()))
2823}
2824
2825fn schedule_agent_zoom_mirror(
2829 store: std::sync::Arc<crate::backend::storage::store::Store>,
2830 agent_id: String,
2831 zoom: Option<f64>,
2832) {
2833 let generation = {
2834 let mut gens = zoom_mirror_gen().lock().unwrap();
2835 let g = gens.entry(agent_id.clone()).or_insert(0);
2836 *g += 1;
2837 *g
2838 };
2839 tokio::spawn(async move {
2840 tokio::time::sleep(std::time::Duration::from_millis(300)).await;
2841 if zoom_mirror_gen().lock().unwrap().get(&agent_id).copied() != Some(generation) {
2843 return;
2844 }
2845 let now = chrono::Utc::now().timestamp_millis();
2846 let result = match zoom {
2847 Some(z) => store.agent_content_set(
2848 &crate::backend::storage::store::AgentContent {
2849 agent_id: agent_id.clone(),
2850 content_type: "ui:zoom".to_string(),
2851 content: format!("{}", z),
2852 updated_at: now,
2853 },
2854 ),
2855 None => store.agent_content_delete(&agent_id, "ui:zoom").map(|_| ()),
2856 };
2857 if let Err(e) = result {
2858 tracing::warn!(agent_id = %agent_id, error = %e, "[zoom] agent zoom mirror write failed");
2859 }
2860 });
2861}
2862
2863pub(crate) async fn dispatch_to_reducer(
2864 state: &AppState,
2865 cmd: agentmux_common::ipc::Command,
2866) -> Vec<agentmux_common::ipc::Event> {
2867 let now = chrono::Utc::now().to_rfc3339();
2868 let mut s = state.srv_state.lock().await;
2869 let ctx = crate::reducer::Ctx {
2870 now_rfc3339: now,
2871 conn_id: 0,
2873 registered_pid: None,
2874 };
2875 crate::reducer::update(&mut s, cmd, &ctx)
2876}
2877
2878pub(crate) fn publish_events(state: &AppState, events: &[agentmux_common::ipc::Event]) {
2881 for event in events {
2882 let _ = state.srv_events_tx.send(event.clone());
2883 }
2884}
2885
2886async fn compensate_via_reducer(
2894 state: &AppState,
2895 cmd: agentmux_common::ipc::Command,
2896 store: &Store,
2897) {
2898 let events = dispatch_to_reducer(state, cmd).await;
2899 for ev in &events {
2900 if let Err(e) = crate::persist_subscriber::apply_event_to_wstore(ev, store) {
2901 tracing::warn!(
2902 "compensation: SQLite cleanup failed for event {:?}: {}",
2903 std::mem::discriminant(ev),
2904 e
2905 );
2906 }
2907 }
2908}
2909
2910pub(crate) fn setup_torn_off_block_layout(
2922 store: &Store,
2923 new_tab_id: &str,
2924 block_id: &str,
2925) -> Result<(), Box<dyn std::error::Error>> {
2926 let new_tab = store.must_get::<Tab>(new_tab_id)?;
2927 let mut layout = store.must_get::<LayoutState>(&new_tab.layoutstate)?;
2928 let node_id = uuid::Uuid::new_v4().to_string();
2929 layout.rootnode = Some(LayoutNode {
2931 id: node_id.clone(),
2932 flex_direction: FlexDirection::Row,
2933 size: 1.0,
2934 children: Vec::new(),
2935 data: Some(LayoutNodeData {
2936 block_id: block_id.to_string(),
2937 ..Default::default()
2938 }),
2939 ..Default::default()
2940 });
2941 layout.leaforder = Some(vec![LeafOrderEntry {
2942 nodeid: node_id,
2943 blockid: block_id.to_string(),
2944 }]);
2945 store.update(&mut layout)?;
2946 Ok(())
2947}
2948
2949fn queue_target_layout_insert(
2966 store: &Store,
2967 target_tab_id: &str,
2968 block_id: &str,
2969) -> Result<(), Box<dyn std::error::Error>> {
2970 let target_tab = store.must_get::<Tab>(target_tab_id)?;
2971 let mut target_layout = store.must_get::<LayoutState>(&target_tab.layoutstate)?;
2972 let mut actions = target_layout.pendingbackendactions.take().unwrap_or_default();
2973 actions.push(LayoutActionData {
2974 actiontype: "insert".to_string(),
2977 actionid: uuid::Uuid::new_v4().to_string(),
2978 blockid: block_id.to_string(),
2979 nodesize: None,
2980 indexarr: None,
2981 focused: true,
2982 magnified: false,
2983 ephemeral: false,
2984 targetblockid: String::new(),
2985 position: String::new(),
2986 });
2987 target_layout.pendingbackendactions = Some(actions);
2988 store.update(&mut target_layout)?;
2989 Ok(())
2990}
2991
2992fn queue_source_layout_delete(
2998 store: &Store,
2999 source_tab_id: &str,
3000 block_id: &str,
3001) -> Result<(), Box<dyn std::error::Error>> {
3002 let source_tab = store.must_get::<Tab>(source_tab_id)?;
3003 let mut source_layout = store.must_get::<LayoutState>(&source_tab.layoutstate)?;
3004 let mut actions = source_layout.pendingbackendactions.take().unwrap_or_default();
3005 actions.push(LayoutActionData {
3006 actiontype: "delete".to_string(),
3007 actionid: uuid::Uuid::new_v4().to_string(),
3008 blockid: block_id.to_string(),
3009 nodesize: None,
3010 indexarr: None,
3011 focused: false,
3012 magnified: false,
3013 ephemeral: false,
3014 targetblockid: String::new(),
3015 position: String::new(),
3016 });
3017 source_layout.pendingbackendactions = Some(actions);
3018 store.update(&mut source_layout)?;
3019 Ok(())
3020}
3021
3022fn wstore_workspace_exists(
3031 store: &Store,
3032 workspace_id: &str,
3033) -> Result<bool, crate::backend::storage::StoreError> {
3034 Ok(store.get::<Workspace>(workspace_id)?.is_some())
3035}
3036
3037#[cfg(test)]
3045mod agent_context_tests {
3046 use super::{resolve_agent_context, workspace_id_for_tab};
3047 use crate::backend::obj::Tab;
3048 use crate::backend::storage::store::Store;
3049 use crate::backend::wcore;
3050
3051 #[test]
3052 fn workspace_id_for_tab_finds_owner_and_misses_cleanly() {
3053 let store = Store::open_in_memory().unwrap();
3054 wcore::ensure_initial_data(&store).unwrap();
3055 let tab = store
3056 .get_all::<Tab>()
3057 .unwrap()
3058 .into_iter()
3059 .next()
3060 .expect("seeded tab");
3061 assert!(workspace_id_for_tab(&store, &tab.oid).is_some(), "owner found");
3062 assert!(workspace_id_for_tab(&store, "nope").is_none(), "miss is None");
3063 }
3064
3065 #[test]
3069 fn resolves_block_to_tab_workspace_and_window() {
3070 let store = Store::open_in_memory().unwrap();
3071 wcore::ensure_initial_data(&store).unwrap();
3072
3073 let tab = store
3074 .get_all::<Tab>()
3075 .unwrap()
3076 .into_iter()
3077 .next()
3078 .expect("seeded tab");
3079 let block_id = tab.blockids.first().expect("seeded agent block").clone();
3080
3081 let ctx = resolve_agent_context(&store, &block_id).expect("resolves context");
3082 assert_eq!(ctx.tab_id, tab.oid);
3083 assert_eq!(ctx.workspace_name, "Starter workspace");
3084 assert!(ctx.workspace_id.is_some(), "workspace should resolve");
3085 assert!(ctx.window_id.is_some(), "window should resolve via reverse lookup");
3086 }
3087
3088 #[test]
3089 fn unknown_block_errors() {
3090 let store = Store::open_in_memory().unwrap();
3091 wcore::ensure_initial_data(&store).unwrap();
3092 let err = resolve_agent_context(&store, "does-not-exist").unwrap_err();
3093 assert!(err.contains("block not found"), "unexpected error: {err}");
3094 }
3095}
3096
3097#[cfg(test)]
3098mod create_window_seed_tests {
3099 use super::handle_window_service;
3100 use crate::backend::service::WebCallType;
3101 use crate::server::tests::test_state;
3102
3103 fn create_window_call(workspace_id: &str) -> WebCallType {
3104 WebCallType {
3105 service: "window".to_string(),
3106 method: "CreateWindow".to_string(),
3107 uicontext: None,
3108 args: vec![
3111 serde_json::Value::Null,
3112 serde_json::Value::String(workspace_id.to_string()),
3113 ],
3114 }
3115 }
3116
3117 #[tokio::test]
3128 async fn new_window_seed_blocks_are_in_reducer_state_and_tear_off_succeeds() {
3129 let state = test_state();
3130 let blocks_before = state.srv_state.lock().await.blocks.len();
3131
3132 let ret = handle_window_service(&state, &create_window_call("")).await;
3133 assert!(ret.success, "CreateWindow failed: {:?}", ret.error);
3134
3135 let win = ret.data.expect("CreateWindow returns the Window");
3136 let workspace_id = win
3137 .get("workspaceid")
3138 .and_then(|v| v.as_str())
3139 .expect("window has a workspaceid")
3140 .to_string();
3141
3142 let (tab_id, block_id) = {
3145 let s = state.srv_state.lock().await;
3146 assert_eq!(
3147 s.blocks.len(),
3148 blocks_before + 3,
3149 "the 3 seed blocks must be tracked in srv_state, not only SQLite"
3150 );
3151 let ws = s
3152 .workspaces
3153 .get(&workspace_id)
3154 .expect("new workspace is in the reducer");
3155 let tab_id = ws.tab_ids.first().expect("new workspace has a tab").clone();
3156 let tab = s.tabs.get(&tab_id).expect("new tab is in the reducer");
3157 assert_eq!(
3158 tab.block_ids.len(),
3159 3,
3160 "the new window's tab must hold its 3 seed blocks in the reducer"
3161 );
3162 (tab_id, tab.block_ids[0].clone())
3163 };
3164
3165 let result =
3168 crate::sagas::tear_off_block::run(&state, block_id, tab_id, workspace_id).await;
3169 assert!(
3170 result.is_ok(),
3171 "tear-off from a freshly-created window must succeed, got: {:?}",
3172 result.err()
3173 );
3174 }
3175}
3176
3177#[cfg(test)]
3178mod agent_zoom_mirror_tests {
3179 use super::schedule_agent_zoom_mirror;
3180 use crate::backend::storage::store::{AgentContent, AgentDefinition, Store};
3181 use std::sync::Arc;
3182
3183 fn seed_agent_def(store: &Store, id: &str) {
3187 let mut def: AgentDefinition = serde_json::from_value(serde_json::json!({
3188 "id": id,
3189 "name": id,
3190 "icon": "sparkles",
3191 "provider": "claude",
3192 "description": "",
3193 "created_at": 1,
3194 }))
3195 .expect("build minimal AgentDefinition");
3196 store.agent_def_insert(&mut def).expect("insert agent def");
3197 }
3198
3199 #[tokio::test]
3202 async fn debounced_mirror_persists_only_final_value() {
3203 let store = Arc::new(Store::open_in_memory().unwrap());
3204 let agent = "agent-zoom-burst";
3205 seed_agent_def(&store, agent);
3206
3207 schedule_agent_zoom_mirror(store.clone(), agent.into(), Some(1.1));
3208 schedule_agent_zoom_mirror(store.clone(), agent.into(), Some(1.2));
3209 schedule_agent_zoom_mirror(store.clone(), agent.into(), Some(1.4));
3210
3211 assert!(store.agent_content_get(agent, "ui:zoom").unwrap().is_none());
3213
3214 tokio::time::sleep(std::time::Duration::from_millis(400)).await;
3215
3216 let saved = store
3217 .agent_content_get(agent, "ui:zoom")
3218 .unwrap()
3219 .expect("zoom persisted after debounce");
3220 assert_eq!(saved.content, "1.4", "only the final zoom of the burst persists");
3221 }
3222
3223 #[tokio::test]
3226 async fn reset_to_default_deletes_saved_zoom() {
3227 let store = Arc::new(Store::open_in_memory().unwrap());
3228 let agent = "agent-zoom-reset";
3229 seed_agent_def(&store, agent);
3230 store
3231 .agent_content_set(&AgentContent {
3232 agent_id: agent.into(),
3233 content_type: "ui:zoom".into(),
3234 content: "1.5".into(),
3235 updated_at: 1,
3236 })
3237 .unwrap();
3238
3239 schedule_agent_zoom_mirror(store.clone(), agent.into(), None);
3240 tokio::time::sleep(std::time::Duration::from_millis(400)).await;
3241
3242 assert!(
3243 store.agent_content_get(agent, "ui:zoom").unwrap().is_none(),
3244 "reset-to-default removes the persisted zoom"
3245 );
3246 }
3247}