Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/apps/desktop/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
};
Expand Down
6 changes: 6 additions & 0 deletions src/crates/assembly/core/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
93 changes: 92 additions & 1 deletion src/crates/assembly/core/src/service/workspace/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -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?;
Expand Down Expand Up @@ -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<_>>(),
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();
Expand Down
26 changes: 20 additions & 6 deletions src/crates/services/services-core/src/workspace_persistence.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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!(
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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();
Expand Down
Loading