1use std::collections::HashMap;
30
31use agentmux_common::ipc::{Command, Event};
32use agentmux_common::LayoutNode;
33use serde_json::{json, Value};
34
35use crate::backend::obj::{Block, Client, LayoutState, Tab, Workspace};
36use crate::backend::service::{WebCallType, WebReturnType};
37use crate::backend::storage::store::Store;
38use crate::server::AppState;
39
40use super::reducer_helpers::{dispatch_to_reducer, seed_layout_via_reducer};
41
42const SNAPSHOT_META_KEY: &str = "session:last_topology";
43
44pub(crate) fn placeholder(idx: usize) -> String {
45 format!("__snap_block_{idx}__")
46}
47
48fn placeholder_idx(s: &str) -> Option<usize> {
49 s.strip_prefix("__snap_block_")?.strip_suffix("__")?.parse().ok()
50}
51
52pub(crate) fn snapshot_workspace(store: &Store, workspace_id: &str) -> Option<Value> {
58 let workspace = store.get::<Workspace>(workspace_id).ok().flatten()?;
59 let mut tabs = Vec::new();
60 let mut active_tab_index: Option<usize> = None;
69 for tab_id in workspace.pinnedtabids.iter().chain(workspace.tabids.iter()) {
76 let Some(tab) = store.get::<Tab>(tab_id).ok().flatten() else {
77 continue;
78 };
79 let mut idx_by_block_id: HashMap<String, usize> = HashMap::new();
89 let mut blocks = Vec::new();
90 for block_id in &tab.blockids {
91 let Some(block) = store.get::<Block>(block_id).ok().flatten() else {
92 continue;
93 };
94 idx_by_block_id.insert(block_id.clone(), blocks.len());
95 blocks.push(json!({ "meta": block.meta }));
96 }
97 if blocks.is_empty() {
98 continue;
99 }
100 let (rootnode, focusednodeid, magnifiednodeid) = if tab.layoutstate.is_empty() {
101 (None, String::new(), String::new())
102 } else {
103 match store.get::<LayoutState>(&tab.layoutstate) {
104 Ok(Some(layout)) => {
105 let rootnode = layout.rootnode.map(|mut tree| {
106 placeholderize(&mut tree, &idx_by_block_id);
107 tree
108 });
109 (rootnode, layout.focusednodeid, layout.magnifiednodeid)
110 }
111 _ => (None, String::new(), String::new()),
112 }
113 };
114 if tab_id == &workspace.activetabid {
115 active_tab_index = Some(tabs.len());
116 }
117 tabs.push(json!({
118 "name": tab.name,
119 "blocks": blocks,
120 "rootnode": rootnode,
121 "focusednodeid": focusednodeid,
122 "magnifiednodeid": magnifiednodeid,
123 }));
124 }
125 if tabs.is_empty() {
126 return None;
127 }
128 Some(json!({ "tabs": tabs, "active_tab_index": active_tab_index }))
129}
130
131fn placeholderize(node: &mut LayoutNode, idx_by_block_id: &HashMap<String, usize>) {
134 if let Some(data) = node.data.as_mut() {
135 if let Some(&idx) = idx_by_block_id.get(&data.block_id) {
136 data.block_id = placeholder(idx);
137 }
138 for id in data.block_stack.iter_mut() {
139 if let Some(&idx) = idx_by_block_id.get(id.as_str()) {
140 *id = placeholder(idx);
141 }
142 }
143 if let Some(&idx) = idx_by_block_id.get(&data.active_block_id) {
144 data.active_block_id = placeholder(idx);
145 }
146 }
147 for child in node.children.iter_mut() {
148 placeholderize(child, idx_by_block_id);
149 }
150}
151
152fn resolve_placeholders(node: &mut LayoutNode, new_ids: &[String]) {
160 if let Some(data) = node.data.as_mut() {
161 if let Some(idx) = placeholder_idx(&data.block_id) {
162 if let Some(real) = new_ids.get(idx) {
163 data.block_id = real.clone();
164 }
165 }
166 for id in data.block_stack.iter_mut() {
167 if let Some(idx) = placeholder_idx(id) {
168 if let Some(real) = new_ids.get(idx) {
169 *id = real.clone();
170 }
171 }
172 }
173 if let Some(idx) = placeholder_idx(&data.active_block_id) {
174 if let Some(real) = new_ids.get(idx) {
175 data.active_block_id = real.clone();
176 }
177 }
178 }
179 for child in node.children.iter_mut() {
180 resolve_placeholders(child, new_ids);
181 }
182}
183
184pub(crate) fn save_last_session_snapshot(store: &Store, snapshot: Value) -> bool {
206 let result = store.with_tx(|tx| {
207 let clients = tx.get_all::<Client>()?;
208 let Some(mut client) = clients.into_iter().next() else {
209 return Ok(false);
214 };
215 client.meta.insert(SNAPSHOT_META_KEY.to_string(), snapshot);
216 tx.update(&mut client)?;
217 Ok(true)
218 });
219 match result {
220 Ok(saved) => saved,
221 Err(e) => {
222 tracing::warn!(
223 error = %e,
224 "session_restore: failed to persist last-session snapshot"
225 );
226 false
227 }
228 }
229}
230
231fn load_last_session_snapshot(store: &Store) -> Option<Value> {
232 let clients = store.get_all::<Client>().ok()?;
233 let client = clients.into_iter().next()?;
234 client.meta.get(SNAPSHOT_META_KEY).cloned()
235}
236
237pub(crate) fn clear_last_session_snapshot(store: &Store) {
246 let result = store.with_tx(|tx| {
250 let clients = tx.get_all::<Client>()?;
251 let Some(mut client) = clients.into_iter().next() else {
252 return Ok(());
253 };
254 if client.meta.remove(SNAPSHOT_META_KEY).is_none() {
255 return Ok(()); }
257 tx.update(&mut client)?;
258 Ok(())
259 });
260 if let Err(e) = result {
261 tracing::warn!(
262 error = %e,
263 "session_restore: failed to clear last-session snapshot after restore"
264 );
265 }
266}
267
268fn find_error(events: &[Event]) -> Option<String> {
269 events.iter().find_map(|e| match e {
270 Event::Error { message, .. } => Some(message.clone()),
271 _ => None,
272 })
273}
274
275async fn apply_and_publish(
276 state: &AppState,
277 events: &[Event],
278) -> Result<(), String> {
279 for ev in events {
280 if let Err(e) = crate::persist_subscriber::apply_event_to_mstore(ev, &state.mstore) {
281 return Err(format!("SQLite write failed: {}", e));
282 }
283 }
284 Ok(())
285}
286
287pub(crate) async fn handle_save_session_snapshot(
335 state: &AppState,
336 _call: &WebCallType,
337) -> WebReturnType {
338 let store = &state.mstore;
339
340 let Some(ws_id) = current_workspace_id(store) else {
341 tracing::debug!("session_restore: shutdown flush found no workspace to snapshot");
346 return WebReturnType::success_empty();
347 };
348
349 match snapshot_workspace(store, &ws_id) {
360 Some(snapshot) => {
361 if save_last_session_snapshot(store, snapshot) {
362 tracing::info!(
363 workspace_id = %ws_id,
364 "session_restore: snapshot flushed for OS shutdown"
365 );
366 WebReturnType::success_empty()
367 } else {
368 WebReturnType::error("shutdown flush: snapshot built but the store write failed")
372 }
373 }
374 None => {
375 tracing::warn!(
376 workspace_id = %ws_id,
377 "session_restore: shutdown flush could not build a snapshot"
378 );
379 WebReturnType::error("shutdown flush: could not build a snapshot for the current workspace")
380 }
381 }
382}
383
384fn current_workspace_id(store: &Store) -> Option<String> {
404 let client = store.get_all::<Client>().ok()?.into_iter().next()?;
405 client.windowids.iter().find_map(|window_id| {
406 let window = store
407 .get::<crate::backend::obj::Window>(window_id)
408 .ok()
409 .flatten()?;
410 store
415 .get::<Workspace>(&window.workspaceid)
416 .ok()
417 .flatten()?;
418 Some(window.workspaceid)
419 })
420}
421
422pub(crate) struct ReplayTab {
425 pub name: String,
426 pub blocks: Vec<Value>,
427 pub rootnode: Option<LayoutNode>,
428 pub focused: String,
429 pub magnified: String,
430}
431
432pub(crate) struct ReplayedTab {
435 pub tab_id: String,
436 pub block_ids: Vec<String>,
437}
438
439pub(crate) async fn replay_tabs(
450 state: &AppState,
451 ws_id: &str,
452 tabs: &[ReplayTab],
453 log_ctx: &str,
454) -> (Vec<Event>, Vec<Option<ReplayedTab>>) {
455 let mut all_events = Vec::new();
456 let mut replayed = Vec::with_capacity(tabs.len());
457 let mut fresh_jars = std::collections::HashMap::new();
460 let jars_available = crate::server::browser_identity::jars_available(state);
461 let profiles = crate::server::browser_identity::profile_ids();
462 for tab in tabs {
463 let tab_events = dispatch_to_reducer(
464 state,
465 Command::CreateTab { workspace_id: ws_id.to_string(), name: tab.name.clone() },
466 )
467 .await;
468 if let Some(err) = find_error(&tab_events) {
469 tracing::warn!(error = %err, "{log_ctx}: CreateTab failed — skipping this tab");
470 replayed.push(None);
471 continue;
472 }
473 if apply_and_publish(state, &tab_events).await.is_err() {
474 tracing::warn!("{log_ctx}: CreateTab SQLite write failed — skipping this tab");
475 replayed.push(None);
476 continue;
477 }
478 let Some(tab_id) = tab_events.iter().find_map(|e| match e {
479 Event::TabCreated { tab_id, .. } => Some(tab_id.clone()),
480 _ => None,
481 }) else {
482 replayed.push(None);
483 continue;
484 };
485 all_events.extend(tab_events);
486
487 let mut new_block_ids: Vec<String> = Vec::new();
488 for meta in &tab.blocks {
489 let mut meta = meta.clone();
492 if let Some(obj) = meta.as_object_mut() {
493 for key in crate::server::browser_owner::srv_only_keys() {
494 obj.remove(key);
495 }
496 }
497 if !crate::server::browser_identity::refresh_for_replay(&mut meta, &mut fresh_jars, jars_available, profiles.as_ref()) {
498 continue;
499 }
500 let blk_events = dispatch_to_reducer(
501 state,
502 Command::CreateBlock { tab_id: tab_id.clone(), meta },
503 )
504 .await;
505 if let Some(err) = find_error(&blk_events) {
506 tracing::warn!(error = %err, "{log_ctx}: CreateBlock failed — skipping this block");
507 continue;
508 }
509 if apply_and_publish(state, &blk_events).await.is_err() {
510 tracing::warn!("{log_ctx}: CreateBlock SQLite write failed — skipping this block");
511 continue;
512 }
513 if let Some(block_id) = blk_events.iter().find_map(|e| match e {
514 Event::BlockCreated { block_id, .. } => Some(block_id.clone()),
515 _ => None,
516 }) {
517 new_block_ids.push(block_id);
518 }
519 all_events.extend(blk_events);
520 }
521
522 if new_block_ids.is_empty() {
523 replayed.push(None);
524 continue;
525 }
526
527 if let Some(mut tree) = tab.rootnode.clone() {
528 resolve_placeholders(&mut tree, &new_block_ids);
529 if let Err(e) =
530 seed_layout_via_reducer(state, &tab_id, tree, tab.focused.clone(), Vec::new(), tab.magnified.clone())
531 .await
532 {
533 tracing::warn!(tab_id = %tab_id, error = %e, "{log_ctx}: layout write failed — tab restored blank");
534 }
535 }
536 replayed.push(Some(ReplayedTab { tab_id, block_ids: new_block_ids }));
537 }
538 (all_events, replayed)
539}
540
541pub(crate) async fn restore_last_session(
542 state: &AppState,
543) -> Result<Option<(String, Vec<Event>)>, String> {
544 let Some(snapshot) = load_last_session_snapshot(&state.mstore) else {
545 return Ok(None);
546 };
547 let tabs_json = snapshot
548 .get("tabs")
549 .and_then(|v| v.as_array())
550 .cloned()
551 .unwrap_or_default();
552 if tabs_json.is_empty() {
553 return Ok(None);
554 }
555
556 let ws_events = dispatch_to_reducer(
557 state,
558 Command::CreateWorkspace { name: String::new() },
559 )
560 .await;
561 if let Some(err) = find_error(&ws_events) {
562 return Err(format!("restore_last_session: CreateWorkspace failed: {}", err));
563 }
564 apply_and_publish(state, &ws_events).await?;
565 let Some(ws_id) = ws_events.iter().find_map(|e| match e {
566 Event::WorkspaceCreated { workspace_id, .. } => Some(workspace_id.clone()),
567 _ => None,
568 }) else {
569 return Err("restore_last_session: CreateWorkspace produced no WorkspaceCreated event".into());
570 };
571
572 let replay: Vec<ReplayTab> = tabs_json
573 .iter()
574 .map(|tab_json| ReplayTab {
575 name: tab_json.get("name").and_then(|v| v.as_str()).unwrap_or_default().to_string(),
576 blocks: tab_json
577 .get("blocks")
578 .and_then(|v| v.as_array())
579 .map(|blocks| blocks.iter().map(|b| b.get("meta").cloned().unwrap_or(Value::Null)).collect())
580 .unwrap_or_default(),
581 rootnode: tab_json
582 .get("rootnode")
583 .filter(|v| !v.is_null())
584 .and_then(|v| serde_json::from_value::<LayoutNode>(v.clone()).ok()),
585 focused: tab_json.get("focusednodeid").and_then(|v| v.as_str()).unwrap_or_default().to_string(),
586 magnified: tab_json.get("magnifiednodeid").and_then(|v| v.as_str()).unwrap_or_default().to_string(),
592 })
593 .collect();
594 let (tab_events, replayed) = replay_tabs(state, &ws_id, &replay, "restore_last_session").await;
595 let mut all_events = ws_events;
596 all_events.extend(tab_events);
597 let restored_any_tab = replayed.iter().any(Option::is_some);
598 let new_tab_ids_by_snapshot_index: HashMap<usize, String> = replayed
604 .iter()
605 .enumerate()
606 .filter_map(|(i, r)| r.as_ref().map(|r| (i, r.tab_id.clone())))
607 .collect();
608
609 if !restored_any_tab {
610 let comp = dispatch_to_reducer(
614 state,
615 Command::DeleteWorkspace { workspace_id: ws_id.clone(), force: false },
616 )
617 .await;
618 let _ = apply_and_publish(state, &comp).await;
619 super::reducer_helpers::publish_events(state, &comp);
620 return Ok(None);
621 }
622
623 if let Some(active_snapshot_idx) = snapshot.get("active_tab_index").and_then(|v| v.as_u64()) {
631 if let Some(new_active_tab_id) = new_tab_ids_by_snapshot_index.get(&(active_snapshot_idx as usize)) {
632 let active_events = dispatch_to_reducer(
633 state,
634 Command::SetActiveTab {
635 workspace_id: ws_id.clone(),
636 tab_id: new_active_tab_id.clone(),
637 },
638 )
639 .await;
640 if let Some(err) = find_error(&active_events) {
641 tracing::warn!(error = %err, "restore_last_session: SetActiveTab failed — leaving default active tab");
642 } else if apply_and_publish(state, &active_events).await.is_err() {
643 tracing::warn!("restore_last_session: SetActiveTab SQLite write failed — leaving default active tab");
644 } else {
645 all_events.extend(active_events);
646 }
647 }
648 }
649
650 Ok(Some((ws_id, all_events)))
651}
652
653#[cfg(test)]
654mod tests {
655 use super::*;
656 use crate::server::tests::test_state;
657
658 async fn dispatch_apply(state: &AppState, cmd: Command) -> Vec<Event> {
659 let events = dispatch_to_reducer(state, cmd).await;
660 for ev in &events {
661 crate::persist_subscriber::apply_event_to_mstore(ev, &state.mstore).unwrap();
662 }
663 events
664 }
665
666 #[tokio::test]
667 async fn no_snapshot_means_nothing_to_load() {
668 let state = test_state();
669 assert!(load_last_session_snapshot(&state.mstore).is_none());
670 }
671
672 #[tokio::test]
673 async fn save_then_load_round_trips() {
674 let state = test_state();
675 let snap = json!({ "tabs": [{"name": "t", "blocks": [{"meta": {"view": "agent"}}]}] });
676 save_last_session_snapshot(&state.mstore, snap.clone());
677 assert_eq!(load_last_session_snapshot(&state.mstore), Some(snap));
678 }
679
680 #[tokio::test]
681 async fn save_overwrites_prior_snapshot() {
682 let state = test_state();
683 save_last_session_snapshot(&state.mstore, json!({"tabs": [{"name": "old"}]}));
684 save_last_session_snapshot(&state.mstore, json!({"tabs": [{"name": "new"}]}));
685 let loaded = load_last_session_snapshot(&state.mstore).unwrap();
686 assert_eq!(loaded["tabs"][0]["name"], "new");
687 }
688
689 #[tokio::test]
690 async fn snapshot_workspace_captures_tab_and_block_meta() {
691 let state = test_state();
692 let ws_evs = dispatch_apply(&state, Command::CreateWorkspace { name: "w".into() }).await;
693 let ws_id = ws_evs
694 .iter()
695 .find_map(|e| match e {
696 Event::WorkspaceCreated { workspace_id, .. } => Some(workspace_id.clone()),
697 _ => None,
698 })
699 .unwrap();
700 let tab_evs = dispatch_apply(
701 &state,
702 Command::CreateTab { workspace_id: ws_id.clone(), name: "mytab".into() },
703 )
704 .await;
705 let tab_id = tab_evs
706 .iter()
707 .find_map(|e| match e {
708 Event::TabCreated { tab_id, .. } => Some(tab_id.clone()),
709 _ => None,
710 })
711 .unwrap();
712 dispatch_apply(
713 &state,
714 Command::CreateBlock {
715 tab_id: tab_id.clone(),
716 meta: json!({ "view": "agent", "agent:name": "test-agent" }),
717 },
718 )
719 .await;
720
721 let snap = snapshot_workspace(&state.mstore, &ws_id).expect("snapshot should be produced");
722 let tabs = snap["tabs"].as_array().unwrap();
723 assert_eq!(tabs.len(), 1);
724 assert_eq!(tabs[0]["name"], "mytab");
725 let blocks = tabs[0]["blocks"].as_array().unwrap();
726 assert_eq!(blocks.len(), 1);
727 assert_eq!(blocks[0]["meta"]["agent:name"], "test-agent");
728 }
729
730 #[tokio::test]
731 async fn snapshot_of_unknown_workspace_is_none() {
732 let state = test_state();
733 assert!(snapshot_workspace(&state.mstore, "no-such-workspace").is_none());
734 }
735
736 #[tokio::test]
737 async fn restore_with_no_snapshot_returns_none() {
738 let state = test_state();
739 let result = restore_last_session(&state).await.unwrap();
740 assert!(result.is_none());
741 }
742
743 #[tokio::test]
744 async fn round_trip_snapshot_then_restore_recreates_tabs_and_blocks() {
745 let state = test_state();
746 let ws_evs = dispatch_apply(&state, Command::CreateWorkspace { name: "w".into() }).await;
747 let ws_id = ws_evs
748 .iter()
749 .find_map(|e| match e {
750 Event::WorkspaceCreated { workspace_id, .. } => Some(workspace_id.clone()),
751 _ => None,
752 })
753 .unwrap();
754 let tab_evs = dispatch_apply(
755 &state,
756 Command::CreateTab { workspace_id: ws_id.clone(), name: "mytab".into() },
757 )
758 .await;
759 let tab_id = tab_evs
760 .iter()
761 .find_map(|e| match e {
762 Event::TabCreated { tab_id, .. } => Some(tab_id.clone()),
763 _ => None,
764 })
765 .unwrap();
766 let blk1 = dispatch_apply(
767 &state,
768 Command::CreateBlock { tab_id: tab_id.clone(), meta: json!({"view": "agent"}) },
769 )
770 .await
771 .iter()
772 .find_map(|e| match e {
773 Event::BlockCreated { block_id, .. } => Some(block_id.clone()),
774 _ => None,
775 })
776 .unwrap();
777 let blk2 = dispatch_apply(
778 &state,
779 Command::CreateBlock { tab_id: tab_id.clone(), meta: json!({"view": "sysinfo"}) },
780 )
781 .await
782 .iter()
783 .find_map(|e| match e {
784 Event::BlockCreated { block_id, .. } => Some(block_id.clone()),
785 _ => None,
786 })
787 .unwrap();
788 let tree = agentmux_common::LayoutNode {
790 id: "root".into(),
791 flex_direction: agentmux_common::FlexDirection::Row,
792 size: 10.0,
793 children: vec![
794 agentmux_common::LayoutNode {
795 id: "left".into(),
796 size: 5.0,
797 data: Some(agentmux_common::LayoutNodeData { block_id: blk1.clone(), ..Default::default() }),
798 ..Default::default()
799 },
800 agentmux_common::LayoutNode {
801 id: "right".into(),
802 size: 5.0,
803 data: Some(agentmux_common::LayoutNodeData { block_id: blk2.clone(), ..Default::default() }),
804 ..Default::default()
805 },
806 ],
807 data: None,
808 extra: Default::default(),
809 };
810 seed_layout_via_reducer(&state, &tab_id, tree, "left".into(), Vec::new(), String::new())
811 .await
812 .unwrap();
813
814 let snap = snapshot_workspace(&state.mstore, &ws_id).expect("snapshot should be produced");
816 save_last_session_snapshot(&state.mstore, snap);
817
818 let (new_ws_id, _events) = restore_last_session(&state)
820 .await
821 .unwrap()
822 .expect("restore should recreate the tab");
823 assert_ne!(new_ws_id, ws_id, "restore creates a NEW workspace, not the deleted one");
824
825 let new_workspace = state.mstore.get::<Workspace>(&new_ws_id).unwrap().unwrap();
826 assert_eq!(new_workspace.tabids.len(), 1);
827 let new_tab = state.mstore.get::<Tab>(&new_workspace.tabids[0]).unwrap().unwrap();
828 assert_eq!(new_tab.name, "mytab");
829 assert_eq!(new_tab.blockids.len(), 2);
830
831 let new_blk1 = state.mstore.get::<Block>(&new_tab.blockids[0]).unwrap().unwrap();
832 assert_eq!(new_blk1.meta.get("view").and_then(|v| v.as_str()), Some("agent"));
833 let new_blk2 = state.mstore.get::<Block>(&new_tab.blockids[1]).unwrap().unwrap();
834 assert_eq!(new_blk2.meta.get("view").and_then(|v| v.as_str()), Some("sysinfo"));
835
836 let new_layout = state
839 .mstore
840 .get::<LayoutState>(&new_tab.layoutstate)
841 .unwrap()
842 .unwrap();
843 let rootnode = new_layout.rootnode.expect("layout tree restored");
844 let leaf_block_ids: Vec<String> = rootnode
845 .children
846 .iter()
847 .filter_map(|c| c.data.as_ref().map(|d| d.block_id.clone()))
848 .collect();
849 assert_eq!(leaf_block_ids, vec![new_tab.blockids[0].clone(), new_tab.blockids[1].clone()]);
850 assert!(!leaf_block_ids.iter().any(|id| id.starts_with("__snap_block_")));
851 }
852
853 #[tokio::test]
857 async fn snapshot_and_restore_preserves_magnifiednodeid() {
858 let state = test_state();
859 let ws_evs = dispatch_apply(&state, Command::CreateWorkspace { name: "w".into() }).await;
860 let ws_id = ws_evs
861 .iter()
862 .find_map(|e| match e {
863 Event::WorkspaceCreated { workspace_id, .. } => Some(workspace_id.clone()),
864 _ => None,
865 })
866 .unwrap();
867 let tab_evs = dispatch_apply(
868 &state,
869 Command::CreateTab { workspace_id: ws_id.clone(), name: "mytab".into() },
870 )
871 .await;
872 let tab_id = tab_evs
873 .iter()
874 .find_map(|e| match e {
875 Event::TabCreated { tab_id, .. } => Some(tab_id.clone()),
876 _ => None,
877 })
878 .unwrap();
879 let blk1 = dispatch_apply(
880 &state,
881 Command::CreateBlock { tab_id: tab_id.clone(), meta: json!({"view": "agent"}) },
882 )
883 .await
884 .iter()
885 .find_map(|e| match e {
886 Event::BlockCreated { block_id, .. } => Some(block_id.clone()),
887 _ => None,
888 })
889 .unwrap();
890 let blk2 = dispatch_apply(
891 &state,
892 Command::CreateBlock { tab_id: tab_id.clone(), meta: json!({"view": "sysinfo"}) },
893 )
894 .await
895 .iter()
896 .find_map(|e| match e {
897 Event::BlockCreated { block_id, .. } => Some(block_id.clone()),
898 _ => None,
899 })
900 .unwrap();
901 let tree = agentmux_common::LayoutNode {
902 id: "root".into(),
903 flex_direction: agentmux_common::FlexDirection::Row,
904 size: 10.0,
905 children: vec![
906 agentmux_common::LayoutNode {
907 id: "left".into(),
908 size: 5.0,
909 data: Some(agentmux_common::LayoutNodeData { block_id: blk1.clone(), ..Default::default() }),
910 ..Default::default()
911 },
912 agentmux_common::LayoutNode {
913 id: "right".into(),
914 size: 5.0,
915 data: Some(agentmux_common::LayoutNodeData { block_id: blk2.clone(), ..Default::default() }),
916 ..Default::default()
917 },
918 ],
919 data: None,
920 extra: Default::default(),
921 };
922 seed_layout_via_reducer(&state, &tab_id, tree, "left".into(), Vec::new(), "left".into())
924 .await
925 .unwrap();
926
927 let snap = snapshot_workspace(&state.mstore, &ws_id).expect("snapshot should be produced");
928 assert_eq!(snap["tabs"][0]["magnifiednodeid"], "left", "magnifiednodeid captured in snapshot");
929 save_last_session_snapshot(&state.mstore, snap);
930
931 let (new_ws_id, _events) = restore_last_session(&state).await.unwrap().unwrap();
932 let new_workspace = state.mstore.get::<Workspace>(&new_ws_id).unwrap().unwrap();
933 let new_tab = state.mstore.get::<Tab>(&new_workspace.tabids[0]).unwrap().unwrap();
934 let new_layout = state.mstore.get::<LayoutState>(&new_tab.layoutstate).unwrap().unwrap();
935 assert_eq!(
936 new_layout.magnifiednodeid, "left",
937 "magnifiednodeid restored — LayoutNode.id values aren't placeholder-remapped, \
938 only block ids are, so this survives verbatim"
939 );
940 }
941
942 #[tokio::test]
946 async fn snapshot_and_restore_preserves_active_tab_when_not_the_first() {
947 let state = test_state();
948 let ws_evs = dispatch_apply(&state, Command::CreateWorkspace { name: "w".into() }).await;
949 let ws_id = ws_evs
950 .iter()
951 .find_map(|e| match e {
952 Event::WorkspaceCreated { workspace_id, .. } => Some(workspace_id.clone()),
953 _ => None,
954 })
955 .unwrap();
956
957 let tab1_evs = dispatch_apply(
961 &state,
962 Command::CreateTab { workspace_id: ws_id.clone(), name: "tab-one".into() },
963 )
964 .await;
965 let tab1_id = tab1_evs
966 .iter()
967 .find_map(|e| match e {
968 Event::TabCreated { tab_id, .. } => Some(tab_id.clone()),
969 _ => None,
970 })
971 .unwrap();
972 dispatch_apply(
973 &state,
974 Command::CreateBlock { tab_id: tab1_id.clone(), meta: json!({"view": "agent"}) },
975 )
976 .await;
977
978 let tab2_evs = dispatch_apply(
979 &state,
980 Command::CreateTab { workspace_id: ws_id.clone(), name: "tab-two".into() },
981 )
982 .await;
983 let tab2_id = tab2_evs
984 .iter()
985 .find_map(|e| match e {
986 Event::TabCreated { tab_id, .. } => Some(tab_id.clone()),
987 _ => None,
988 })
989 .unwrap();
990 dispatch_apply(
991 &state,
992 Command::CreateBlock { tab_id: tab2_id.clone(), meta: json!({"view": "sysinfo"}) },
993 )
994 .await;
995
996 dispatch_apply(
997 &state,
998 Command::SetActiveTab { workspace_id: ws_id.clone(), tab_id: tab2_id.clone() },
999 )
1000 .await;
1001 let workspace_before = state.mstore.get::<Workspace>(&ws_id).unwrap().unwrap();
1002 assert_eq!(workspace_before.activetabid, tab2_id, "precondition: tab-two is active");
1003
1004 let snap = snapshot_workspace(&state.mstore, &ws_id).expect("snapshot should be produced");
1005 assert_eq!(snap["active_tab_index"], 1, "active tab is the SECOND captured tab");
1006 save_last_session_snapshot(&state.mstore, snap);
1007
1008 let (new_ws_id, _events) = restore_last_session(&state).await.unwrap().unwrap();
1009 let new_workspace = state.mstore.get::<Workspace>(&new_ws_id).unwrap().unwrap();
1010 assert_eq!(new_workspace.tabids.len(), 2);
1011 let new_tab2 = state.mstore.get::<Tab>(&new_workspace.tabids[1]).unwrap().unwrap();
1012 assert_eq!(new_tab2.name, "tab-two");
1013 assert_eq!(
1014 new_workspace.activetabid, new_tab2.oid,
1015 "the restored workspace's active tab is the NEW tab-two, not whichever \
1016 CreateTab's first-tab-wins default would otherwise leave active"
1017 );
1018 }
1019
1020 #[tokio::test]
1021 async fn restore_is_idempotent_source_snapshot_survives() {
1022 let state = test_state();
1026 let ws_evs = dispatch_apply(&state, Command::CreateWorkspace { name: "w".into() }).await;
1027 let ws_id = ws_evs
1028 .iter()
1029 .find_map(|e| match e {
1030 Event::WorkspaceCreated { workspace_id, .. } => Some(workspace_id.clone()),
1031 _ => None,
1032 })
1033 .unwrap();
1034 let tab_evs = dispatch_apply(
1035 &state,
1036 Command::CreateTab { workspace_id: ws_id.clone(), name: "t".into() },
1037 )
1038 .await;
1039 let tab_id = tab_evs
1040 .iter()
1041 .find_map(|e| match e {
1042 Event::TabCreated { tab_id, .. } => Some(tab_id.clone()),
1043 _ => None,
1044 })
1045 .unwrap();
1046 dispatch_apply(
1047 &state,
1048 Command::CreateBlock { tab_id: tab_id.clone(), meta: json!({"view": "agent"}) },
1049 )
1050 .await;
1051 let snap = snapshot_workspace(&state.mstore, &ws_id).unwrap();
1052 save_last_session_snapshot(&state.mstore, snap);
1053
1054 let (first_ws, _) = restore_last_session(&state).await.unwrap().unwrap();
1055 let (second_ws, _) = restore_last_session(&state).await.unwrap().unwrap();
1056 assert_ne!(first_ws, second_ws, "each restore call creates its own fresh workspace");
1057 assert!(state.mstore.get::<Workspace>(&first_ws).unwrap().is_some());
1058 assert!(state.mstore.get::<Workspace>(&second_ws).unwrap().is_some());
1059 }
1060
1061
1062 fn shutdown_call() -> WebCallType {
1066 WebCallType {
1067 service: "window".to_string(),
1068 method: "SaveSessionSnapshot".to_string(),
1069 args: vec![],
1070 uicontext: None,
1071 }
1072 }
1073
1074 fn bootstrapped_workspace(state: &AppState) -> String {
1080 current_workspace_id(&state.mstore).expect("test_state seeds a live session")
1081 }
1082
1083 #[tokio::test]
1097 async fn shutdown_flush_writes_a_snapshot_without_a_close() {
1098 let state = test_state();
1099 let ws_id = bootstrapped_workspace(&state);
1100 let ws = state.mstore.get::<Workspace>(&ws_id).unwrap().unwrap();
1101 let open_tabs = ws.pinnedtabids.len() + ws.tabids.len();
1106 assert!(open_tabs > 0, "precondition: the session has a tab open");
1107 assert!(
1108 load_last_session_snapshot(&state.mstore).is_none(),
1109 "precondition: nothing saved before the flush"
1110 );
1111
1112 handle_save_session_snapshot(&state, &shutdown_call()).await;
1113
1114 let snap = load_last_session_snapshot(&state.mstore)
1115 .expect("shutdown flush must persist a snapshot with no CloseWindow");
1116 let tabs = snap["tabs"].as_array().expect("snapshot carries a tabs array");
1117 assert_eq!(
1118 tabs.len(),
1119 open_tabs,
1120 "every open tab must be captured, not just the active one"
1121 );
1122 }
1123
1124 #[tokio::test]
1128 async fn shutdown_flush_leaves_the_workspace_intact() {
1129 let state = test_state();
1130 let ws_id = bootstrapped_workspace(&state);
1131 let before = state.mstore.get::<Workspace>(&ws_id).unwrap().unwrap();
1132
1133 handle_save_session_snapshot(&state, &shutdown_call()).await;
1134
1135 let after = state
1136 .mstore
1137 .get::<Workspace>(&ws_id)
1138 .unwrap()
1139 .expect("shutdown flush must not delete the workspace");
1140 assert_eq!(
1141 (before.pinnedtabids, before.tabids),
1142 (after.pinnedtabids, after.tabids),
1143 "tabs must survive the flush untouched"
1144 );
1145 }
1146
1147 #[tokio::test]
1152 async fn shutdown_flush_is_idempotent() {
1153 let state = test_state();
1154 let _ = bootstrapped_workspace(&state);
1155
1156 handle_save_session_snapshot(&state, &shutdown_call()).await;
1157 let first = load_last_session_snapshot(&state.mstore).unwrap();
1158 handle_save_session_snapshot(&state, &shutdown_call()).await;
1159 let second = load_last_session_snapshot(&state.mstore).unwrap();
1160
1161 assert_eq!(first, second, "a second flush must not corrupt the snapshot");
1162 }
1163
1164 #[tokio::test]
1181 async fn shutdown_flush_survives_a_missing_workspace() {
1182 let state = test_state();
1183 let ws_id = bootstrapped_workspace(&state);
1184 state.mstore.delete::<Workspace>(&ws_id).unwrap();
1185
1186 let ret = handle_save_session_snapshot(&state, &shutdown_call()).await;
1187
1188 assert!(
1189 ret.error.is_none(),
1190 "no live workspace among any window is ordinary, not a failure"
1191 );
1192 assert!(
1193 load_last_session_snapshot(&state.mstore).is_none(),
1194 "nothing capturable means nothing written — not a partial snapshot"
1195 );
1196 }
1197
1198 #[tokio::test]
1209 async fn shutdown_flush_reports_failure_when_the_workspace_has_no_capturable_tabs() {
1210 let state = test_state();
1211 let ws_id = bootstrapped_workspace(&state);
1212 let workspace = state.mstore.get::<Workspace>(&ws_id).unwrap().unwrap();
1213 for tab_id in workspace.pinnedtabids.iter().chain(workspace.tabids.iter()) {
1214 let mut tab = state.mstore.get::<Tab>(tab_id).unwrap().unwrap();
1215 for block_id in tab.blockids.drain(..) {
1216 let _ = state.mstore.delete::<Block>(&block_id);
1217 }
1218 state.mstore.update(&mut tab).unwrap();
1219 }
1220
1221 let ret = handle_save_session_snapshot(&state, &shutdown_call()).await;
1222
1223 assert!(
1224 ret.error.is_some(),
1225 "a live workspace with nothing capturable in it must report failure, not silent success"
1226 );
1227 assert!(
1228 load_last_session_snapshot(&state.mstore).is_none(),
1229 "nothing capturable means nothing written — not a partial snapshot"
1230 );
1231 }
1232
1233 #[tokio::test]
1242 async fn shutdown_flush_with_no_window_at_all_is_not_an_error() {
1243 let state = test_state();
1244 let _ = bootstrapped_workspace(&state);
1245 let window_id = state
1246 .mstore
1247 .get_all::<Client>()
1248 .unwrap()
1249 .into_iter()
1250 .next()
1251 .unwrap()
1252 .windowids
1253 .first()
1254 .cloned()
1255 .expect("test_state seeds exactly one window");
1256 state
1257 .mstore
1258 .delete::<crate::backend::obj::Window>(&window_id)
1259 .unwrap();
1260
1261 let ret = handle_save_session_snapshot(&state, &shutdown_call()).await;
1262
1263 assert!(
1264 ret.error.is_none(),
1265 "no window to resolve a workspace from is ordinary, not a failure"
1266 );
1267 assert!(
1268 load_last_session_snapshot(&state.mstore).is_none(),
1269 "nothing was captured, so nothing should be written"
1270 );
1271 }
1272
1273 #[tokio::test]
1279 async fn shutdown_flush_skips_a_stale_leading_window_id() {
1280 let state = test_state();
1281 let ws_id = bootstrapped_workspace(&state);
1282
1283 let mut client = state
1286 .mstore
1287 .get_all::<Client>()
1288 .unwrap()
1289 .into_iter()
1290 .next()
1291 .unwrap();
1292 client
1293 .windowids
1294 .insert(0, "stale-window-id-no-row".to_string());
1295 state.mstore.update(&mut client).unwrap();
1296
1297 let ret = handle_save_session_snapshot(&state, &shutdown_call()).await;
1298
1299 assert!(
1300 ret.error.is_none(),
1301 "a stale leading window id must not fail the whole flush"
1302 );
1303 let snapshot = load_last_session_snapshot(&state.mstore)
1304 .expect("the live workspace behind the second window id must still be captured");
1305 let expected = snapshot_workspace(&state.mstore, &ws_id)
1306 .expect("the real workspace is independently snapshot-able");
1307 assert_eq!(
1308 snapshot, expected,
1309 "must resolve to and capture the real workspace, not silently skip it"
1310 );
1311 }
1312
1313 #[tokio::test]
1322 async fn shutdown_flush_skips_a_window_whose_workspace_is_gone() {
1323 let state = test_state();
1324 let live_ws_id = bootstrapped_workspace(&state);
1325
1326 let mut client = state
1327 .mstore
1328 .get_all::<Client>()
1329 .unwrap()
1330 .into_iter()
1331 .next()
1332 .unwrap();
1333 let live_window_id = client
1334 .windowids
1335 .first()
1336 .cloned()
1337 .expect("test_state seeds exactly one window");
1338
1339 let mut dead_window = crate::backend::obj::Window {
1344 oid: "dead-window-with-deleted-workspace".to_string(),
1345 workspaceid: "workspace-that-was-deleted".to_string(),
1346 ..Default::default()
1347 };
1348 state.mstore.insert(&mut dead_window).unwrap();
1349
1350 client.windowids.insert(0, dead_window.oid.clone());
1352 state.mstore.update(&mut client).unwrap();
1353
1354 let ret = handle_save_session_snapshot(&state, &shutdown_call()).await;
1355
1356 assert!(
1357 ret.error.is_none(),
1358 "a dead-workspace window must not fail the whole flush when a live one follows"
1359 );
1360 let snapshot = load_last_session_snapshot(&state.mstore)
1361 .expect("the live workspace behind the second window must still be captured");
1362 let expected = snapshot_workspace(&state.mstore, &live_ws_id)
1363 .expect("the real workspace is independently snapshot-able");
1364 assert_eq!(
1365 snapshot, expected,
1366 "must resolve to and capture the live workspace, not the dead one it's paired with"
1367 );
1368 assert_ne!(live_window_id, dead_window.oid);
1371 }
1372}