diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index f0739d81b0..04b34756e3 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -615,6 +615,10 @@ pub async fn run() { Ok(state) => state, Err(e) => { log::error!("Failed to initialize AppState: {}", e); + show_fatal_startup_error(&format!( + "OpenBitFun could not initialize its application state and cannot continue.\n\n{e}\n\nSee {} for details.", + logging::early_startup_log_path(&session_log_dir).display() + )); return; } }; diff --git a/src/crates/assembly/core/AGENTS.md b/src/crates/assembly/core/AGENTS.md index f9962a83b3..cd8d389a63 100644 --- a/src/crates/assembly/core/AGENTS.md +++ b/src/crates/assembly/core/AGENTS.md @@ -305,6 +305,12 @@ MiniApp Agent appdata workspace registration and pre-ID session compatibility: cargo test --locked -p openbitfun-core --no-default-features --features agent-runtime,git,tools-miniapp --lib miniapp::agent_workspace::tests ``` +Workspace history upgrade compatibility and MiniApp record preservation: + +```bash +cargo test --locked -p openbitfun-core --no-default-features --features agent-runtime,git --lib legacy_miniapp_history +``` + Skill hook activation, session cleanup, and tool preflight/permission ordering: ```bash diff --git a/src/crates/assembly/core/src/service/workspace/service.rs b/src/crates/assembly/core/src/service/workspace/service.rs index d8f5d6c04d..a1aa603fdf 100644 --- a/src/crates/assembly/core/src/service/workspace/service.rs +++ b/src/crates/assembly/core/src/service/workspace/service.rs @@ -1904,10 +1904,19 @@ impl WorkspaceService { .await; validate_workspace_persistence_data(&data, &self.path_manager.miniapps_dir())?; + // Older current-format catalogs tracked MiniApp activity as recent + // projects. Normalize only that UI history; keep every workspace + // record and its opened/current/session references intact. Remote + // paths belong to another host and must not match our local root. + data.recent_workspaces.retain(|id| { + let workspace = &data.workspaces[id]; + workspace.workspace_kind == WorkspaceKind::Remote + || !self.is_miniapp_owned_path(&workspace.root_path) + }); + let mut manager = self.manager.write().await; *manager.get_workspaces_mut() = data.workspaces; manager.set_opened_workspace_ids(data.opened_workspace_ids); - manager.set_recent_workspaces(data.recent_workspaces); manager.set_recent_assistant_workspaces(data.recent_assistant_workspaces); manager.set_primary_assistant_key(data.primary_assistant_key); @@ -1919,6 +1928,10 @@ impl WorkspaceService { } } + // Selecting the current workspace touches recency, so restore the + // filtered history afterwards, including when a MiniApp is current. + manager.set_recent_workspaces(data.recent_workspaces); + let workspaces_to_restore = Self::collect_startup_restored_workspaces(&manager); drop(manager); self.save_workspace_data().await?; @@ -2921,6 +2934,84 @@ mod tests { ); } + #[tokio::test] + async fn legacy_miniapp_history_loads_without_losing_workspace_references() { + let mut env = TestEnvironment::new(); + // Use the production canonicalizer for macOS /var aliases and Windows + // verbatim-path prefixes so the fixture has one consistent local root. + env.path_manager = Arc::new(PathManager::with_user_root_for_tests( + canonicalize_local_workspace_root(&env.root) + .unwrap() + .0 + .join("user-root"), + )); + let service = build_test_workspace_service(env.path_manager.clone()).await; + let project = service + .open_workspace(env.create_workspace_dir("project")) + .await + .unwrap(); + let root = env.path_manager.miniapp_dir("legacy-deck").join("appdata"); + std::fs::create_dir_all(&root).unwrap(); + let miniapp = service.open_workspace(root).await.unwrap(); + let mut payload: serde_json::Value = service + .persistence + .load_json("workspace_data") + .await + .unwrap() + .unwrap(); + // Reproduce the old writer, which included app-owned workspaces here. + // A remote record with the same path is still a user project, even + // while its connection is unavailable. + let mut remote = payload["workspaces"][&miniapp.id].clone(); + remote["id"] = "offline-remote".into(); + remote["workspaceKind"] = "remote".into(); + payload["workspaces"]["offline-remote"] = remote; + payload["recent_workspaces"] = + serde_json::json!([miniapp.id, project.id, "offline-remote"]); + service + .persistence + .save_json("workspace_data", &payload, StorageOptions::default()) + .await + .unwrap(); + + let restored = build_test_workspace_service(env.path_manager.clone()).await; + restored.load_workspace_history_only().await.unwrap(); + assert_eq!( + restored.get_current_workspace().await.unwrap().id, + miniapp.id + ); + assert!(restored + .get_opened_workspaces() + .await + .iter() + .any(|ws| ws.id == miniapp.id)); + assert_eq!( + restored + .get_recent_workspaces() + .await + .iter() + .map(|ws| &ws.id) + .collect::>(), + vec![&project.id, &"offline-remote".to_string()] + ); + let saved: WorkspacePersistenceData = restored + .persistence + .load_json("workspace_data") + .await + .unwrap() + .unwrap(); + assert!(saved.workspaces.contains_key(&miniapp.id)); + assert_eq!( + saved.workspaces["offline-remote"].workspace_kind, + WorkspaceKind::Remote + ); + assert_eq!(saved.current_workspace_id, Some(miniapp.id)); + assert_eq!( + saved.recent_workspaces, + vec![project.id, "offline-remote".to_string()] + ); + } + #[tokio::test] async fn track_workspace_activity_keeps_miniapp_workspaces_out_of_recent_history() { let env = TestEnvironment::new(); diff --git a/src/crates/services/services-core/src/workspace_persistence.rs b/src/crates/services/services-core/src/workspace_persistence.rs index 0e9a6034c1..9eafe2fe10 100644 --- a/src/crates/services/services-core/src/workspace_persistence.rs +++ b/src/crates/services/services-core/src/workspace_persistence.rs @@ -30,7 +30,7 @@ pub struct WorkspacePersistenceData { pub fn validate_workspace_persistence_data( data: &WorkspacePersistenceData, - miniapps_root: &Path, + _miniapps_root: &Path, ) -> OpenBitFunResult<()> { if data.format_version != WORKSPACE_PERSISTENCE_FORMAT_VERSION { return Err(unsupported_workspace_persistence(format!( @@ -85,11 +85,9 @@ pub fn validate_workspace_persistence_data( "recent_workspaces contains assistant workspace '{id}'" ))); } - if workspace.root_path.starts_with(miniapps_root) { - return Err(unsupported_workspace_persistence(format!( - "recent_workspaces contains MiniApp-owned workspace '{id}'" - ))); - } + // Older current-format catalogs included MiniApp directories in recent + // history. This is a presentation concern, not an invalid record: the + // runtime filters that history while preserving the workspace identity. } for id in &data.recent_assistant_workspaces { if data.workspaces[id].workspace_kind != WorkspaceKind::Assistant { @@ -199,6 +197,22 @@ mod tests { assert_eq!(data.workspaces["saved-id"].id, "saved-id"); } + #[test] + fn legacy_miniapp_recent_record_survives_round_trip() { + let mut payload = serde_json::to_value(legacy_catalog()).unwrap(); + payload["workspaces"]["saved-id"]["workspaceKind"] = "normal".into(); + payload["workspaces"]["saved-id"]["rootPath"] = "/miniapps/deck/appdata".into(); + let data: WorkspacePersistenceData = serde_json::from_value(payload).unwrap(); + validate_workspace_persistence_data(&data, Path::new("/miniapps")).unwrap(); + let round_trip: WorkspacePersistenceData = + serde_json::from_value(serde_json::to_value(&data).unwrap()).unwrap(); + assert_eq!( + serde_json::to_value(&round_trip).unwrap(), + serde_json::to_value(&data).unwrap() + ); + assert_eq!(round_trip.recent_workspaces, vec!["saved-id"]); + } + #[test] fn mismatched_record_id_is_rejected_without_modifying_input() { let mut data = legacy_catalog();