From f46d658690c016cc0f6ded32a4042278fd1c30f0 Mon Sep 17 00:00:00 2001 From: wsp Date: Fri, 9 Oct 2026 18:03:25 +0800 Subject: [PATCH 1/4] fix(migration): Remap session workspace IDs - Convert workspace references in imported session metadata and state to the destination registry IDs. - Resolve path-only assistant references uniquely while preserving unknown IDs and source data. --- .../src/workspace_sessions.rs | 253 +++++++++++++++++- 1 file changed, 247 insertions(+), 6 deletions(-) diff --git a/src/crates/services/legacy-migration-adapters/src/workspace_sessions.rs b/src/crates/services/legacy-migration-adapters/src/workspace_sessions.rs index 18b48d007f..f4a1ac25d4 100644 --- a/src/crates/services/legacy-migration-adapters/src/workspace_sessions.rs +++ b/src/crates/services/legacy-migration-adapters/src/workspace_sessions.rs @@ -968,6 +968,8 @@ fn plan_workspace_sessions(roots: &MigrationRoots) -> LegacyMigrationResult, + workspace_id_map: &BTreeMap, + workspaces: &HashMap, + metadata: &SessionMetadata, ) -> LegacyMigrationResult>> { if !state_path.is_file() { return Ok(None); @@ -1150,11 +1155,86 @@ fn relocate_assistant_session_state( } } + // Legacy state already stores IDs even when its metadata is path-only. + // Rehome those references together with the paths, preserving unknown fields. + let workspace_id = migrated_session_workspace_id( + config + .get("workspace_id") + .and_then(serde_json::Value::as_str), + config + .get("workspace_path") + .and_then(serde_json::Value::as_str) + .or(metadata.workspace_path.as_deref()), + workspace_id_map, + workspaces, + ) + .or_else(|| metadata.workspace_id.clone()); + let project_workspace_id = migrated_session_workspace_id( + config + .get("project_workspace_id") + .and_then(serde_json::Value::as_str), + config + .get("project_workspace_path") + .and_then(serde_json::Value::as_str) + .or(metadata.project_workspace_path.as_deref()), + workspace_id_map, + workspaces, + ) + .or_else(|| metadata.project_workspace_id.clone()) + .or_else(|| session_project_workspace_id(workspace_id.as_deref(), workspaces)); + for (key, id) in [ + ("workspace_id", workspace_id), + ("project_workspace_id", project_workspace_id), + ] { + if let Some(id) = id { + let value = serde_json::Value::String(id); + if config.get(key) != Some(&value) { + config.insert(key.to_string(), value); + changed = true; + } + } + } + changed .then(|| serde_json::to_vec(&state).map_err(json_error)) .transpose() } +fn migrated_session_workspace_id( + id: Option<&str>, + path: Option<&str>, + workspace_id_map: &BTreeMap, + workspaces: &HashMap, +) -> Option { + if let Some(id) = id { + return Some( + workspace_id_map + .get(id) + .cloned() + .unwrap_or_else(|| id.to_string()), + ); + } + let key = native_path_key(Path::new(path?)); + let mut matches = workspaces.values().filter(|workspace| { + workspace.workspace_kind == WorkspaceKind::Assistant + && native_path_key(&workspace.root_path) == key + }); + let workspace = matches.next()?; + matches.next().is_none().then(|| workspace.id.clone()) +} + +fn session_project_workspace_id( + workspace_id: Option<&str>, + workspaces: &HashMap, +) -> Option { + workspaces + .get(workspace_id?)? + .project_workspace_id() + .ok() + .filter(|id| workspaces.contains_key(*id)) + .map(str::to_string) +} + fn relocate_json_path( value: &mut serde_json::Value, relocations: &BTreeMap, @@ -1210,6 +1290,8 @@ fn plan_sessions( roots: &MigrationRoots, target_sessions: &HashMap>, assistant_path_relocations: &BTreeMap, + workspace_id_map: &BTreeMap, + workspaces: &HashMap, conflicts: &mut Vec, ) -> LegacyMigrationResult<(Vec, Vec)> { let session_roots = find_session_roots(&roots.legacy_home_root)?; @@ -1265,6 +1347,33 @@ fn plan_sessions( &mut session_metadata, assistant_path_relocations, ); + session_metadata.workspace_id = migrated_session_workspace_id( + session_metadata.workspace_id.as_deref(), + session_metadata + .workspace_path + .as_deref() + .or_else(|| { + session_metadata + .execution_target + .as_ref() + .map(|target| target.root_path.as_str()) + }) + .or(session_metadata.project_workspace_path.as_deref()), + workspace_id_map, + workspaces, + ); + session_metadata.project_workspace_id = migrated_session_workspace_id( + session_metadata.project_workspace_id.as_deref(), + session_metadata.project_workspace_path.as_deref(), + workspace_id_map, + workspaces, + ) + .or_else(|| { + session_project_workspace_id( + session_metadata.workspace_id.as_deref(), + workspaces, + ) + }); let runtime_relative = relocated_assistant_path .clone() .map(assistant_session_runtime_relative) @@ -1289,15 +1398,17 @@ fn plan_sessions( ) }); } - let state_bytes_override = if relocated_assistant_path.is_some() - && auxiliary_files - .iter() - .any(|(relative, _)| relative == Path::new("state.json")) + let state_bytes_override = if auxiliary_files + .iter() + .any(|(relative, _)| relative == Path::new("state.json")) { - relocate_assistant_session_state( + migrate_session_workspace_state( &roots.legacy_home_root, &session_dir.join("state.json"), assistant_path_relocations, + workspace_id_map, + workspaces, + &bundle.metadata, )? } else { None @@ -2366,6 +2477,14 @@ mod tests { #[test] fn personal_assistant_tree_and_session_paths_are_rehomed_together() { + for (metadata_has_id, state_has_id) in + [(false, true), (true, true), (false, false), (true, false)] + { + assert_personal_assistant_rehomed(metadata_has_id, state_has_id); + } + } + + fn assert_personal_assistant_rehomed(metadata_has_id: bool, state_has_id: bool) { let temp = test_tempdir("personal-assistant"); let roots = fixture_roots(temp.path()); let fixture = PathBuf::from(env!("CARGO_MANIFEST_DIR")) @@ -2421,6 +2540,10 @@ mod tests { metadata.metadata.project_workspace_path = Some(source_display.clone()); metadata.metadata.execution_target = Some(SessionExecutionTarget::local(source_display.clone())); + if metadata_has_id { + metadata.metadata.workspace_id = Some("assistant-legacy".to_string()); + metadata.metadata.project_workspace_id = Some("assistant-legacy".to_string()); + } atomic_write_json(&metadata_path, &metadata).unwrap(); let session_dir = metadata_path.parent().unwrap(); atomic_write_json( @@ -2428,6 +2551,8 @@ mod tests { &serde_json::json!({ "schema_version": 1, "config": { + "workspace_id": state_has_id.then_some("assistant-legacy"), + "project_workspace_id": state_has_id.then_some("assistant-legacy"), "workspace_path": source_display.clone(), "project_workspace_path": source_display.clone(), "execution_target": { @@ -2451,6 +2576,9 @@ mod tests { ) .unwrap(); + let original_metadata = fs::read(&metadata_path).unwrap(); + let original_state = fs::read(session_dir.join("state.json")).unwrap(); + let plan = plan_workspace_sessions(&roots).unwrap(); assert_eq!(plan.assistant_workspaces.len(), 1); assert_eq!(plan.assistant_workspaces[0].source_path, source_assistant); @@ -2461,6 +2589,7 @@ mod tests { .find(|workspace| workspace.workspace_kind == WorkspaceKind::Assistant) .unwrap(); assert_eq!(assistant.root_path, target_assistant); + assert_ne!(assistant.id, "assistant-legacy"); assert!(!plan.requires_relocation.contains(&assistant.id)); let session = plan @@ -2469,6 +2598,14 @@ mod tests { .find(|session| session.bundle.metadata.session_id == "session-1") .unwrap(); let target_key = native_path_key(&target_assistant); + assert_eq!( + session.bundle.metadata.workspace_id.as_ref(), + Some(&assistant.id) + ); + assert_eq!( + session.bundle.metadata.project_workspace_id.as_ref(), + Some(&assistant.id) + ); assert_eq!( session .bundle @@ -2518,6 +2655,12 @@ mod tests { .expect("assistant Session state should be rewritten"), ) .unwrap(); + for key in ["workspace_id", "project_workspace_id"] { + assert_eq!( + migrated_state["config"][key].as_str(), + Some(assistant.id.as_str()) + ); + } for pointer in [ "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/config/workspace_path", "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/config/project_workspace_path", @@ -2545,6 +2688,104 @@ mod tests { .and_then(serde_json::Value::as_str), Some(source_display.as_str()) ); + + use openbitfun_legacy_migration::{ + probe_legacy_source, CancellationToken, MigrationEngine, NoCrashInjection, ProbeLimits, + }; + use openbitfun_product_domains::legacy_migration::{MigrationGroupId, MigrationSelection}; + let source = probe_legacy_source(&roots, ProbeLimits::default()) + .unwrap() + .unwrap(); + let selection = MigrationSelection { + groups: BTreeSet::from([MigrationGroupId::WorkspacesSessionsAndTasks]), + }; + let engine = + MigrationEngine::new(roots.clone(), crate::adapters_for_groups(&selection)).unwrap(); + let migration_plan = engine + .plan(&source, selection, &CancellationToken::default()) + .unwrap(); + let report = engine + .execute( + &migration_plan, + &CancellationToken::default(), + &NoCrashInjection, + ) + .unwrap(); + assert_eq!( + report + .domain_results + .iter() + .find(|result| result.domain == MigrationDomainId::WorkspaceSessions) + .unwrap() + .state, + MigrationDomainState::Verified + ); + let imported_root = roots + .target_home_root + .join(&session.runtime_relative) + .join(&session.bundle.metadata.session_id); + let imported_metadata: StoredSessionMetadataFile = + serde_json::from_slice(&fs::read(imported_root.join("metadata.json")).unwrap()) + .unwrap(); + let imported_state: serde_json::Value = + serde_json::from_slice(&fs::read(imported_root.join("state.json")).unwrap()).unwrap(); + let imported_registry: WorkspacePersistenceData = + serde_json::from_slice(&fs::read(target_workspace_data_path(&roots)).unwrap()).unwrap(); + for id in [ + imported_metadata.metadata.workspace_id.as_deref(), + imported_metadata.metadata.project_workspace_id.as_deref(), + imported_state["config"]["workspace_id"].as_str(), + imported_state["config"]["project_workspace_id"].as_str(), + ] { + let workspace = &imported_registry.workspaces[id.unwrap()]; + assert_eq!(workspace.root_path, target_assistant); + assert_eq!(workspace.workspace_kind, WorkspaceKind::Assistant); + } + assert_eq!(fs::read(&metadata_path).unwrap(), original_metadata); + assert_eq!( + fs::read(session_dir.join("state.json")).unwrap(), + original_state + ); + let retry = plan_workspace_sessions(&roots).unwrap(); + let retry_session = retry + .sessions + .iter() + .find(|entry| entry.bundle.metadata.session_id == session.bundle.metadata.session_id) + .unwrap(); + assert_eq!(retry_session.action, SessionImportAction::Duplicate); + assert_eq!(retry_session.expected_hash, session.expected_hash); + } + + #[test] + fn explicit_session_workspace_ids_are_mapped_without_path_fallback() { + let id_map = BTreeMap::from([("old-id".to_string(), "new-id".to_string())]); + let fixture = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../legacy-migration/tests/fixtures/v0.2.19/user-root/data/workspace_data.json"); + let registry: LegacyWorkspacePersistenceData = + serde_json::from_slice(&fs::read(fixture).unwrap()).unwrap(); + let mut assistant = registry.workspaces.into_values().next().unwrap(); + assistant.id = "new-id".to_string(); + assistant.workspace_kind = WorkspaceKind::Assistant; + let path = assistant.root_path.to_string_lossy().into_owned(); + let mut workspaces = HashMap::from([(assistant.id.clone(), assistant.clone())]); + assert_eq!( + migrated_session_workspace_id(Some("old-id"), None, &id_map, &workspaces), + Some("new-id".to_string()) + ); + assert_eq!( + migrated_session_workspace_id(Some("unknown-id"), Some(&path), &id_map, &workspaces), + Some("unknown-id".to_string()) + ); + assert_eq!( + migrated_session_workspace_id(None, Some(&path), &id_map, &workspaces), + Some("new-id".to_string()) + ); + assistant.id = "other-id".to_string(); + workspaces.insert(assistant.id.clone(), assistant); + assert_eq!( + migrated_session_workspace_id(None, Some(&path), &id_map, &workspaces), + None + ); } #[test] From c3d81a46031d43b319060ce6a8500c120b89b9b9 Mon Sep 17 00:00:00 2001 From: wsp Date: Fri, 9 Oct 2026 18:59:30 +0800 Subject: [PATCH 2/4] fix(sessions): Normalize legacy Auto selectors Convert legacy native Auto selectors to primary during migration and history reads so migrated sessions can resolve their model immediately. Preserve external agent selectors and read-only history behavior. Add migration, metadata, and session restore regression coverage. --- .../core/src/agentic/persistence/manager.rs | 5 ++ .../src/agentic/session/session_manager.rs | 83 ++++++++++++++++++ .../src/workspace_sessions.rs | 62 +++++++++++++ .../src/session/metadata_store.rs | 86 +++++++++++++++++-- .../services-core/src/session/types.rs | 22 +++++ 5 files changed, 251 insertions(+), 7 deletions(-) diff --git a/src/crates/assembly/core/src/agentic/persistence/manager.rs b/src/crates/assembly/core/src/agentic/persistence/manager.rs index b246fe8719..7c150b37cd 100644 --- a/src/crates/assembly/core/src/agentic/persistence/manager.rs +++ b/src/crates/assembly/core/src/agentic/persistence/manager.rs @@ -2584,6 +2584,11 @@ impl PersistenceManager { if config.model_id.is_none() && !metadata.model_name.is_empty() { config.model_id = Some(metadata.model_name.clone()); } + if let Some(model_id) = config.model_id.as_mut() { + if metadata.compatible_model_selector(model_id) != model_id.as_str() { + *model_id = "primary".to_string(); + } + } let compression_state = stored_state .as_ref() diff --git a/src/crates/assembly/core/src/agentic/session/session_manager.rs b/src/crates/assembly/core/src/agentic/session/session_manager.rs index d63d616934..6a208b6311 100644 --- a/src/crates/assembly/core/src/agentic/session/session_manager.rs +++ b/src/crates/assembly/core/src/agentic/session/session_manager.rs @@ -15230,6 +15230,89 @@ mod tests { .is_empty()); } + #[tokio::test] + async fn legacy_auto_restore_session_view_uses_primary_without_writing_history() { + let workspace = TestWorkspace::new(); + let paths = workspace.path_manager(); + let persistence = Arc::new(PersistenceManager::new(paths.clone()).unwrap()); + let manager = test_manager(persistence.clone()); + for (metadata_model, state_model, agent, expected) in [ + ("auto", Some("auto"), "Standard", "primary"), + ("auto", None, "Standard", "primary"), + ("auto", Some("fast"), "Standard", "fast"), + ("fast", Some("auto"), "Standard", "primary"), + ("auto", Some("auto"), "acp:codex", "auto"), + ( + "removed-model", + Some("removed-model"), + "Standard", + "removed-model", + ), + ] { + let id = Uuid::new_v4().to_string(); + let session = Session::new_with_id( + id.clone(), + "Legacy model".into(), + agent.into(), + SessionConfig { + workspace_path: Some(workspace.path().to_string_lossy().into_owned()), + model_id: Some(metadata_model.into()), + ..Default::default() + }, + ); + persistence + .save_session(workspace.path(), &session) + .await + .unwrap(); + let directory = paths.project_sessions_dir(workspace.path()).join(&id); + let metadata_path = directory.join("metadata.json"); + let state_path = directory.join("state.json"); + if let Some(model) = state_model { + let mut state: serde_json::Value = + serde_json::from_slice(&std::fs::read(&state_path).unwrap()).unwrap(); + state["config"]["model_id"] = serde_json::json!(model); + state["config"] + .as_object_mut() + .unwrap() + .remove("model_binding_policy"); + std::fs::write(&state_path, serde_json::to_vec(&state).unwrap()).unwrap(); + } else { + std::fs::remove_file(&state_path).unwrap(); + } + let metadata_bytes = std::fs::read(&metadata_path).unwrap(); + let state_bytes = std::fs::read(&state_path).ok(); + for restored in [ + manager + .restore_session_view(workspace.path(), &id) + .await + .unwrap() + .0, + manager + .restore_session_view_tail(workspace.path(), &id, 1) + .await + .unwrap() + .0, + ] { + assert_eq!(restored.config.model_id.as_deref(), Some(expected)); + } + assert!(manager.get_session(&id).is_none()); + assert_eq!(std::fs::read(&metadata_path).unwrap(), metadata_bytes); + assert_eq!(std::fs::read(&state_path).ok(), state_bytes); + // A normal save persists the compatible selector for subsequent readers. + let restored = persistence + .load_session(workspace.path(), &id) + .await + .unwrap(); + persistence + .save_session(workspace.path(), &restored) + .await + .unwrap(); + let state: serde_json::Value = + serde_json::from_slice(&std::fs::read(&state_path).unwrap()).unwrap(); + assert_eq!(state["config"]["model_id"], expected); + } + } + #[tokio::test] async fn start_dialog_turn_with_existing_context_persists_turn_and_snapshot() { let workspace = TestWorkspace::new(); diff --git a/src/crates/services/legacy-migration-adapters/src/workspace_sessions.rs b/src/crates/services/legacy-migration-adapters/src/workspace_sessions.rs index f4a1ac25d4..370045b480 100644 --- a/src/crates/services/legacy-migration-adapters/src/workspace_sessions.rs +++ b/src/crates/services/legacy-migration-adapters/src/workspace_sessions.rs @@ -1139,6 +1139,15 @@ fn migrate_session_workspace_state( }; let mut changed = false; + if let Some(model_id) = config.get_mut("model_id") { + if model_id + .as_str() + .is_some_and(|id| metadata.compatible_model_selector(id) != id) + { + *model_id = serde_json::Value::String("primary".to_string()); + changed = true; + } + } for key in ["workspace_path", "project_workspace_path"] { if let Some(value) = config.get_mut(key) { changed |= relocate_json_path(value, relocations); @@ -1340,6 +1349,7 @@ fn plan_sessions( conflicts.push(MigrationConflict { domain: MigrationDomainId::WorkspaceSessions, code: "session_turns_recovered".into(), source_summary: session_id.clone(), target_summary: "Only readable, unambiguous Turns will be imported; inspect the original Session for omitted content.".into(), resolution: ConflictResolution::ItemSkipped }); } let mut session_metadata = metadata_file.metadata; + session_metadata.normalize_legacy_model_selector(); // Legacy metadata counters can lag persisted Turns. Rebuild the // derived count in the import copy without changing the source. session_metadata.turn_count = turns.len(); @@ -2484,6 +2494,54 @@ mod tests { } } + #[test] + fn legacy_auto_state_conversion_preserves_external_selectors_and_unknown_fields() { + let temp = test_tempdir("legacy-auto"); + let path = temp.path().join("state.json"); + for (agent, provider, selector, expected) in [ + ("Standard", None, Some("auto"), Some("primary")), + ("Standard", None, None, None), + ( + "Standard", + None, + Some("removed-model"), + Some("removed-model"), + ), + ("acp:codex", None, Some("auto"), Some("auto")), + ("Standard", Some("acp"), Some("auto"), Some("auto")), + ] { + let mut metadata = SessionMetadata::new( + "session-1".into(), + "Legacy".into(), + agent.into(), + "auto".into(), + ); + metadata.custom_metadata = provider.map(|value| serde_json::json!({"provider": value})); + let source = serde_json::json!({ + "config": {"model_id": selector, "unknown_future_field": "preserved"}, + "history": {"model": "auto"} + }); + atomic_write_json(&path, &source).unwrap(); + let original = fs::read(&path).unwrap(); + let converted = migrate_session_workspace_state( + temp.path(), + &path, + &BTreeMap::new(), + &BTreeMap::new(), + &HashMap::new(), + &metadata, + ) + .unwrap(); + assert_eq!(converted.is_some(), selector != expected); + let value: serde_json::Value = + serde_json::from_slice(converted.as_deref().unwrap_or(&original)).unwrap(); + assert_eq!(value["config"]["model_id"].as_str(), expected); + assert_eq!(value["config"]["unknown_future_field"], "preserved"); + assert_eq!(value["history"]["model"], "auto"); + assert_eq!(fs::read(&path).unwrap(), original); + } + } + fn assert_personal_assistant_rehomed(metadata_has_id: bool, state_has_id: bool) { let temp = test_tempdir("personal-assistant"); let roots = fixture_roots(temp.path()); @@ -2536,6 +2594,7 @@ mod tests { let mut metadata: StoredSessionMetadataFile = serde_json::from_slice(&fs::read(&metadata_path).unwrap()).unwrap(); let source_display = source_assistant.to_string_lossy().into_owned(); + metadata.metadata.model_name = "auto".to_string(); metadata.metadata.workspace_path = Some(source_display.clone()); metadata.metadata.project_workspace_path = Some(source_display.clone()); metadata.metadata.execution_target = @@ -2551,6 +2610,7 @@ mod tests { &serde_json::json!({ "schema_version": 1, "config": { + "model_id": "auto", "workspace_id": state_has_id.then_some("assistant-legacy"), "project_workspace_id": state_has_id.then_some("assistant-legacy"), "workspace_path": source_display.clone(), @@ -2729,6 +2789,8 @@ mod tests { .unwrap(); let imported_state: serde_json::Value = serde_json::from_slice(&fs::read(imported_root.join("state.json")).unwrap()).unwrap(); + assert_eq!(imported_metadata.metadata.model_name, "primary"); + assert_eq!(imported_state["config"]["model_id"], "primary"); let imported_registry: WorkspacePersistenceData = serde_json::from_slice(&fs::read(target_workspace_data_path(&roots)).unwrap()).unwrap(); for id in [ diff --git a/src/crates/services/services-core/src/session/metadata_store.rs b/src/crates/services/services-core/src/session/metadata_store.rs index 9bff102a2c..a066561946 100644 --- a/src/crates/services/services-core/src/session/metadata_store.rs +++ b/src/crates/services/services-core/src/session/metadata_store.rs @@ -363,7 +363,10 @@ impl SessionMetadataStore { "Session index contains stale entries, rebuilding: {}", index_path.display() ); - return self.rebuild_index_locked().await; + return self + .rebuild_index_locked() + .await + .map(compatible_metadata_list); } let disk_count = self.count_metadata_dirs().await?; @@ -374,10 +377,13 @@ impl SessionMetadataStore { disk_count, index_path.display() ); - return self.rebuild_index_locked().await; + return self + .rebuild_index_locked() + .await + .map(compatible_metadata_list); } - Ok(index.sessions) + Ok(compatible_metadata_list(index.sessions)) } /// Read a bounded selection from the shared index without stat-ing every @@ -399,6 +405,7 @@ impl SessionMetadataStore { .sessions .into_iter() .filter(|entry| selected.contains(entry.session_id.as_str())) + .map(compatible_metadata) .collect()) } @@ -429,12 +436,13 @@ impl SessionMetadataStore { index.sessions }; - let page = build_session_metadata_page(indexed_sessions, cursor, limit); + let mut page = build_session_metadata_page(indexed_sessions, cursor, limit); let has_stale_page_entry = page .sessions .iter() .any(|metadata| !self.metadata_path(&metadata.session_id).exists()); if !has_stale_page_entry { + page.sessions = compatible_metadata_list(page.sessions); return Ok(page); } @@ -443,13 +451,17 @@ impl SessionMetadataStore { index_path.display() ); let rebuilt_sessions = self.rebuild_index_locked().await?; - Ok(build_session_metadata_page(rebuilt_sessions, cursor, limit)) + let mut page = build_session_metadata_page(rebuilt_sessions, cursor, limit); + page.sessions = compatible_metadata_list(page.sessions); + Ok(page) } pub async fn list_metadata_including_internal( &self, ) -> Result, SessionMetadataStoreError> { - self.scan_metadata_dirs().await + self.scan_metadata_dirs() + .await + .map(compatible_metadata_list) } pub async fn rebuild_index(&self) -> Result, SessionMetadataStoreError> { @@ -532,7 +544,7 @@ impl SessionMetadataStore { Ok(self .read_json_optional::(&path) .await? - .map(|file| file.metadata)) + .map(|file| compatible_metadata(file.metadata))) } pub async fn delete_session_dir_and_index( @@ -589,6 +601,18 @@ impl SessionMetadataStore { } } +fn compatible_metadata(mut metadata: SessionMetadata) -> SessionMetadata { + metadata.normalize_legacy_model_selector(); + metadata +} + +fn compatible_metadata_list(mut metadata: Vec) -> Vec { + for entry in &mut metadata { + entry.normalize_legacy_model_selector(); + } + metadata +} + fn current_unix_ms() -> u64 { SystemTime::now() .duration_since(UNIX_EPOCH) @@ -601,6 +625,54 @@ mod tests { use super::*; use crate::session::{SessionStatus, StoredSessionIndexFile}; + #[tokio::test] + async fn legacy_auto_metadata_reads_are_compatible_without_rewriting_files() { + let root = tempfile::tempdir().unwrap(); + let store = SessionMetadataStore::new(root.path()); + for (id, agent, provider, selector, expected) in [ + ("legacy", "Standard", None, "auto", "primary"), + ("acp-agent", "acp:codex", None, "auto", "auto"), + ("acp-provider", "Standard", Some("acp"), "auto", "auto"), + ("pinned", "Standard", None, "removed-model", "removed-model"), + ("fast", "Standard", None, "fast", "fast"), + ] { + let mut record = metadata(id, 1); + record.agent_type = agent.to_string(); + record.model_name = selector.to_string(); + record.custom_metadata = provider.map(|value| serde_json::json!({"provider": value})); + store.save_metadata(&record).await.unwrap(); + let source = fs::read(store.metadata_path(id)).await.unwrap(); + let index = fs::read(store.index_path()).await.unwrap(); + assert_eq!( + store.load_metadata(id).await.unwrap().unwrap().model_name, + expected + ); + for records in [ + store.list_metadata().await.unwrap(), + store.list_metadata_page(None, 10).await.unwrap().sessions, + store.metadata_by_ids(&[id.to_string()]).await.unwrap(), + store.list_metadata_including_internal().await.unwrap(), + ] { + assert_eq!( + records + .iter() + .find(|entry| entry.session_id == id) + .unwrap() + .model_name, + expected + ); + } + assert_eq!(fs::read(store.metadata_path(id)).await.unwrap(), source); + assert_eq!(fs::read(store.index_path()).await.unwrap(), index); + let returned = store.load_metadata(id).await.unwrap().unwrap(); + let round_trip: StoredSessionMetadataFile = serde_json::from_slice( + &serde_json::to_vec(&StoredSessionMetadataFile::new(returned)).unwrap(), + ) + .unwrap(); + assert_eq!(round_trip.metadata.model_name, expected); + } + } + #[tokio::test] async fn catalog_watch_tracks_committed_create_rename_and_delete() { let root = tempfile::tempdir().unwrap(); diff --git a/src/crates/services/services-core/src/session/types.rs b/src/crates/services/services-core/src/session/types.rs index 56ff92a9fc..c23495a7b1 100644 --- a/src/crates/services/services-core/src/session/types.rs +++ b/src/crates/services/services-core/src/session/types.rs @@ -1142,6 +1142,28 @@ pub struct SessionLastTurn { } impl SessionMetadata { + /// Translate the retired native selector without changing external agents' IDs. + pub fn compatible_model_selector<'a>(&self, selector: &'a str) -> &'a str { + let external_provider = self + .custom_metadata + .as_ref() + .and_then(|custom| custom.get(openbitfun_core_types::SESSION_PROVIDER_METADATA_KEY)) + .and_then(serde_json::Value::as_str) + .is_some_and(|provider| !provider.is_empty()); + if selector == "auto" && !self.agent_type.starts_with("acp:") && !external_provider { + "primary" + } else { + selector + } + } + + /// Apply read compatibility in memory; reading history must not rewrite files. + pub fn normalize_legacy_model_selector(&mut self) { + if self.compatible_model_selector(&self.model_name) != self.model_name { + self.model_name = "primary".to_string(); + } + } + pub fn needs_last_turn_backfill(&self) -> bool { self.turn_count > 0 && self.last_turn.as_ref().is_none_or(|last| { From bcc07d1d3c07cbf0e84edc15742fcea993f13acd Mon Sep 17 00:00:00 2001 From: wsp Date: Fri, 9 Oct 2026 19:36:55 +0800 Subject: [PATCH 3/4] feat(migrator): Add confirmed data reset Allow users to clear OpenBitFun destination data before retrying migration. Require directory review and explicit confirmation, protect source and migrator directories, check active writers, and report partial failures. Reload migration state after reset and document the recovery workflow. --- src/apps/data-migrator/README.md | 20 +- src/apps/data-migrator/README.zh-CN.md | 19 +- src/apps/data-migrator/src/app_state.rs | 247 +++++++++++- src/apps/data-migrator/src/commands.rs | 24 ++ src/apps/data-migrator/src/lib.rs | 2 + src/apps/data-migrator/ui/app.js | 33 +- src/apps/data-migrator/ui/index.html | 14 + src/apps/data-migrator/ui/locales.json | 36 ++ .../services/legacy-migration/AGENTS.md | 3 + .../services/legacy-migration/src/lib.rs | 2 + .../services/legacy-migration/src/paths.rs | 40 ++ .../services/legacy-migration/src/reset.rs | 374 ++++++++++++++++++ 12 files changed, 806 insertions(+), 8 deletions(-) create mode 100644 src/crates/services/legacy-migration/src/reset.rs diff --git a/src/apps/data-migrator/README.md b/src/apps/data-migrator/README.md index 493d04e08b..f53820ce4f 100644 --- a/src/apps/data-migrator/README.md +++ b/src/apps/data-migrator/README.md @@ -98,9 +98,17 @@ If you have already launched OpenBitFun or run a migration, existing destination If OpenBitFun contains no new data you need to keep, you can clear its destination data and retry: -1. Fully quit BitFun, OpenBitFun, and the migrator. -2. Back up the following directories, then delete them. **This removes existing OpenBitFun settings, sessions, and other local data.** -3. Run the migrator again, then launch OpenBitFun after migration finishes. +1. Fully quit BitFun, OpenBitFun, their CLI/background writers, and other migrator instances. +2. Open Data Migrator and check the destination locations. Under **Reset OpenBitFun data**, select **Review reset directories**. +3. Save any data and migration logs/backups you need from the listed directories. **Reset permanently removes all their contents, including existing OpenBitFun settings, credentials, assistant workspaces, and sessions.** It does not create a backup. +4. Type `RESET`, then select **Permanently reset OpenBitFun data**. If a directory cannot be fully removed, close applications using it and retry before migrating. +5. Scan again and start a new migration. Launch OpenBitFun after migration finishes. + +The reset preview uses the selected destination locations. For standard locations, +it also covers platform Skills/SSH parent directories and Desktop WebView/UI data +under `com.openbitfun.desktop`. It preserves the original BitFun source, the +migrator's preferences, and ordinary project directories outside the listed roots. +Unsafe overlaps and linked reset paths are rejected. Windows destination directories: @@ -110,6 +118,12 @@ Windows destination directories: %LOCALAPPDATA%\OpenBitFun ``` +On macOS the standard roots are `~/.openbitfun` and +`~/Library/Application Support/{openbitfun,OpenBitFun}`. On Linux they are +`~/.openbitfun`, `~/.config/openbitfun`, and +`~/.local/share/{openbitfun,OpenBitFun}` (or the corresponding XDG locations). +Always review the actual paths shown by the tool before confirming. + **The issue persists after retrying** Open a report in [GitHub Issues](https://github.com/GCWing/OpenBitFun/issues) or share your feedback in the OpenBitFun user WeChat group. Include: diff --git a/src/apps/data-migrator/README.zh-CN.md b/src/apps/data-migrator/README.zh-CN.md index ca41ce37e6..665456dcef 100644 --- a/src/apps/data-migrator/README.zh-CN.md +++ b/src/apps/data-migrator/README.zh-CN.md @@ -74,9 +74,16 @@ Windows 需要 Microsoft Edge WebView2;macOS 使用系统 WebView,Linux 包 如果 OpenBitFun 中没有需要保留的新数据,可以清空目标数据后重新迁移: -1. 完全退出 BitFun、OpenBitFun 和迁移器。 -2. 备份以下目录,然后删除它们。**这会清除 OpenBitFun 的现有配置、会话及其他本地数据。** -3. 重新运行迁移器,完成后再启动 OpenBitFun。 +1. 完全退出 BitFun、OpenBitFun、CLI 及后台写入进程,以及其他迁移器实例。 +2. 打开迁移器,检查目标位置,在“重置 OpenBitFun 数据”中点击“查看待重置目录”。 +3. 另存列表目录中需要保留的数据、迁移日志及备份。**重置会永久删除这些目录的全部内容,包括 OpenBitFun 配置、凭据、助理工作区和会话,不会自动备份。** +4. 输入 `RESET`,点击“永久重置 OpenBitFun 数据”。如果部分目录未能完全删除,请关闭占用应用并重试,再进行迁移。 +5. 重新扫描并开始新迁移,完成后再启动 OpenBitFun。 + +重置预览以所选目标位置为准;使用标准位置时,还会包含系统 Skills/SSH 所属的 +OpenBitFun 目录,以及 `com.openbitfun.desktop` 下的桌面 WebView/UI 数据。 +旧 BitFun 来源、迁移器自身配置和列表之外的普通项目目录会保留。 +危险目录重叠、带符号链接或 junction 的重置路径会被拒绝。 Windows 目标目录: @@ -86,6 +93,12 @@ Windows 目标目录: %LOCALAPPDATA%\OpenBitFun ``` +macOS 标准目录为 `~/.openbitfun` 和 +`~/Library/Application Support/{openbitfun,OpenBitFun}`;Linux 为 +`~/.openbitfun`、`~/.config/openbitfun` 和 +`~/.local/share/{openbitfun,OpenBitFun}`(或对应的 XDG 目录)。 +确认前请以迁移器显示的实际路径为准。 + **重试后仍然异常** 请在 [GitHub Issues](https://github.com/GCWing/OpenBitFun/issues) 提交问题,或在 OpenBitFun 用户微信群中反馈,并提供: diff --git a/src/apps/data-migrator/src/app_state.rs b/src/apps/data-migrator/src/app_state.rs index 5f7696ba49..4c0b84d56c 100644 --- a/src/apps/data-migrator/src/app_state.rs +++ b/src/apps/data-migrator/src/app_state.rs @@ -2,7 +2,7 @@ use openbitfun_legacy_migration::{ atomic_write_json, blocking_writer_processes, export_failure_diagnostics, list_tasks, load_task, probe_legacy_source, save_task, CancellationToken, LegacyMigrationError, LegacyMigrationResult, MigrationEngine, MigrationLayout, MigrationRoots, NoCrashInjection, - ProbeLimits, SavedMigrationTask, WriterProcess, + ProbeLimits, ResetDirectory, SavedMigrationTask, TargetResetResult, WriterProcess, }; use openbitfun_legacy_migration_adapters::adapters_for_groups; use openbitfun_product_domains::legacy_migration::{ @@ -175,6 +175,16 @@ pub(crate) struct MigratorView { pub can_execute: bool, pub recovery: bool, pub error: Option, + pub reset_preview: Option, + pub reset_result: Option, + pub resetting: bool, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ResetPreviewView { + pub confirmation_id: String, + pub directories: Vec, } #[derive(Debug, Clone, PartialEq, Eq, Serialize)] @@ -201,6 +211,9 @@ struct MigratorSession { running: bool, error: Option, cancellation: CancellationToken, + reset_preview: Option, + reset_result: Option, + resetting: bool, } #[derive(Clone)] @@ -263,6 +276,9 @@ impl MigratorCoordinator { blockers: Vec::new(), running: false, cancellation: CancellationToken::default(), + reset_preview: None, + reset_result: None, + resetting: false, })), } } @@ -313,6 +329,8 @@ impl MigratorCoordinator { session.progress = None; session.recovery = true; session.error = None; + session.reset_preview = None; + session.reset_result = None; Ok(snapshot(&session)) } @@ -321,6 +339,112 @@ impl MigratorCoordinator { snapshot(&session) } + pub(crate) fn preview_reset(&self) -> Result { + let mut session = self.lock(); + if session.running { + return Err(CommandError::operation_in_progress()); + } + let protected = reset_protected_directories(&session.settings_path)?; + let directories = openbitfun_legacy_migration::plan_target_reset(&session.roots, &protected) + .map_err(|_| CommandError::new("unsafe_reset_locations", "Reset refused: a destination overlaps source data, a protected directory, or a linked path. Check the selected locations.", false))?; + session.reset_preview = Some(ResetPreviewView { + confirmation_id: uuid::Uuid::new_v4().to_string(), + directories, + }); + session.reset_result = None; + session.error = None; + Ok(snapshot(&session)) + } + + pub(crate) fn start_reset( + &self, + confirmation_id: String, + confirmation: String, + ) -> Result { + let (roots, preview, protected) = { + let mut session = self.lock(); + if session.running { + return Err(CommandError::operation_in_progress()); + } + let preview = session + .reset_preview + .clone() + .filter(|preview| preview.confirmation_id == confirmation_id) + .ok_or_else(|| { + CommandError::new( + "reset_confirmation_required", + "Review the reset directories again before continuing.", + true, + ) + })?; + if confirmation != "RESET" { + return Err(CommandError::new( + "reset_confirmation_required", + "Type RESET to confirm permanent deletion of OpenBitFun data.", + true, + )); + } + let protected = reset_protected_directories(&session.settings_path)?; + session.running = true; + session.resetting = true; + session.progress = None; + session.error = None; + session.reset_result = None; + session.reset_preview = None; + (session.roots.clone(), preview, protected) + }; + let coordinator = self.clone(); + self.spawn_worker("openbitfun-data-reset", move || { + coordinator.reset_background(roots, preview, protected); + }) + } + + fn reset_background( + &self, + roots: MigrationRoots, + preview: ResetPreviewView, + protected: Vec, + ) { + let mut blockers = Vec::new(); + let result = (|| { + blockers = openbitfun_legacy_migration::blocking_writer_processes_for_product( + 0, + &["openbitfun-data-migrator"], + ) + .map_err(|error| CommandError::from_legacy(&error))?; + if !blockers.is_empty() { + return Err(CommandError::new("reset_writers_running", "Close BitFun, OpenBitFun, their CLI/background writers, and other Data Migrator instances, then retry reset.", true)); + } + openbitfun_legacy_migration::reset_target_data(&roots, &preview.directories, &protected) + .map_err(|_| CommandError::new("reset_scope_changed", "Reset stopped because the directories failed revalidation. Review the paths again; some data may already have been removed.", true)) + })(); + self.finish_reset(roots, result, blockers); + } + + fn finish_reset( + &self, + roots: MigrationRoots, + result: Result, + blockers: Vec, + ) { + let mut session = self.lock(); + // Reload even after failure: a partial deletion invalidates old plans. + let replacement = Self::bootstrap_with(roots, session.settings_path.clone()); + std::mem::swap(&mut *session, &mut *replacement.lock()); + session.blockers = blockers; + match result { + Ok(result) => { + if !result.failed.is_empty() { + session.error = Some(CommandError::new("reset_incomplete", "Some OpenBitFun directories could not be fully removed. Close applications using them, then review and retry reset before migrating.", true)); + } + session.reset_result = Some(result); + } + Err(error) => { + session.error = Some(error); + } + } + } + pub(crate) fn export_diagnostics(&self) -> Result { let session = self.lock(); if session.running { @@ -559,6 +683,9 @@ impl MigratorCoordinator { pub(crate) fn cancel(&self) -> MigratorView { let mut session = self.lock(); + if session.resetting { + return snapshot(&session); + } session.cancellation.cancel(); if let Some(progress) = &mut session.progress { progress.code = if progress.safe_to_cancel { @@ -600,6 +727,8 @@ impl MigratorCoordinator { session.cancellation = CancellationToken::default(); session.running = true; session.error = None; + session.reset_preview = None; + session.reset_result = None; session.progress = Some(MigrationProgressEvent { run_id: session.request.run_id.clone(), phase: MigrationPhase::Scan, @@ -628,6 +757,7 @@ impl MigratorCoordinator { { let mut session = self.lock(); session.running = false; + session.resetting = false; let error = CommandError::worker_failed(); session.error = Some(error.clone()); return Err(error); @@ -781,6 +911,34 @@ fn writer_processes() -> LegacyMigrationResult> { blocking_writer_processes(0) } +fn reset_protected_directories(settings: &std::path::Path) -> Result, CommandError> { + let settings_root = settings.parent().ok_or_else(|| { + CommandError::new( + "unsafe_reset_locations", + "The migrator configuration directory is unavailable.", + false, + ) + })?; + let executable = std::env::current_exe().map_err(|_| { + CommandError::new( + "unsafe_reset_locations", + "The migrator installation directory is unavailable.", + false, + ) + })?; + let executable_root = executable.parent().ok_or_else(|| { + CommandError::new( + "unsafe_reset_locations", + "The migrator installation directory is unavailable.", + false, + ) + })?; + Ok(vec![ + settings_root.to_path_buf(), + executable_root.to_path_buf(), + ]) +} + fn validate_selection(selection: &MigrationSelection) -> Result<(), CommandError> { if selection.groups.is_empty() { return Err(CommandError::new( @@ -829,6 +987,9 @@ fn snapshot(session: &MigratorSession) -> MigratorView { running: session.running, recovery: session.recovery, error: session.error.clone(), + reset_preview: session.reset_preview.clone(), + reset_result: session.reset_result.clone(), + resetting: session.resetting, } } @@ -949,6 +1110,90 @@ mod tests { assert!(!settings.exists()); } + #[test] + fn reset_requires_fresh_confirmation_and_excludes_running_operations() { + let temp = tempfile::tempdir().unwrap(); + let root = fs::canonicalize(temp.path()).unwrap(); + let roots = fixture_roots(&root); + fs::create_dir_all(&roots.target_user_root).unwrap(); + let keep = roots.target_user_root.join("keep"); + fs::write(&keep, "target").unwrap(); + let coordinator = + MigratorCoordinator::bootstrap_with(roots, root.join("tool/locations.json")); + assert!(coordinator + .start_reset("stale".into(), "RESET".into()) + .is_err()); + let preview = coordinator.preview_reset().unwrap().reset_preview.unwrap(); + assert!(coordinator + .start_reset(preview.confirmation_id.clone(), "reset".into()) + .is_err()); + coordinator.preview_reset().unwrap(); + assert!(coordinator + .start_reset(preview.confirmation_id, "RESET".into()) + .is_err()); + coordinator.lock().running = true; + assert!(coordinator.preview_reset().is_err()); + assert!(coordinator + .start_reset("stale".into(), "RESET".into()) + .is_err()); + assert_eq!(fs::read_to_string(keep).unwrap(), "target"); + } + + #[test] + fn reset_reloads_history_after_success_or_partial_failure_and_preserves_preferences() { + let temp = tempfile::tempdir().unwrap(); + let root = fs::canonicalize(temp.path()).unwrap(); + let roots = fixture_roots(&root); + let settings = root.join("tool/locations.json"); + atomic_write_json(&settings, &roots).unwrap(); + let coordinator = MigratorCoordinator::bootstrap_with(roots.clone(), settings.clone()); + fs::create_dir_all(&roots.target_user_root).unwrap(); + fs::write(roots.target_user_root.join("keep"), "target").unwrap(); + let preview = coordinator.preview_reset().unwrap().reset_preview.unwrap(); + let protected = reset_protected_directories(&settings).unwrap(); + let result = openbitfun_legacy_migration::reset_target_data( + &roots, + &preview.directories, + &protected, + ) + .unwrap(); + coordinator.finish_reset(roots.clone(), Ok(result), Vec::new()); + let view = coordinator.snapshot(); + assert!(view.reset_result.unwrap().failed.is_empty()); + assert!(view.plan.is_none()); + assert!(view.report.is_none()); + assert!(view.saved_tasks.is_empty()); + assert!(view.reset_preview.is_none()); + assert!(!view.running && !view.resetting); + assert!(settings.exists()); + assert!(!roots.target_user_root.exists()); + + coordinator.lock().running = true; + coordinator.lock().resetting = true; + coordinator.finish_reset( + roots.clone(), + Ok(TargetResetResult { + failed: vec![roots.target_home_root.clone()], + ..Default::default() + }), + Vec::new(), + ); + let view = coordinator.snapshot(); + assert_eq!(view.error.unwrap().code, "reset_incomplete"); + assert!(!view.running && !view.resetting); + assert!(view.reset_preview.is_none()); + coordinator.finish_reset( + roots, + Err(CommandError::new("reset_scope_changed", "Changed", true)), + Vec::new(), + ); + assert_eq!( + coordinator.snapshot().error.unwrap().code, + "reset_scope_changed" + ); + assert!(settings.exists()); + } + #[test] fn unsupported_target_is_rejected_before_scan_or_any_writes() { let temp = tempfile::tempdir().unwrap(); diff --git a/src/apps/data-migrator/src/commands.rs b/src/apps/data-migrator/src/commands.rs index e9bf9de2e9..62c4631246 100644 --- a/src/apps/data-migrator/src/commands.rs +++ b/src/apps/data-migrator/src/commands.rs @@ -32,6 +32,30 @@ pub(crate) struct ResumeRequest { pub run_id: String, } +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub(crate) struct ResetRequest { + pub confirmation_id: String, + pub confirmation: String, +} + +#[tauri::command] +pub(crate) fn preview_openbitfun_reset( + state: State<'_, MigratorCoordinator>, + request: EmptyRequest, +) -> Result { + let _ = request; + state.preview_reset() +} + +#[tauri::command] +pub(crate) fn reset_openbitfun_data( + state: State<'_, MigratorCoordinator>, + request: ResetRequest, +) -> Result { + state.start_reset(request.confirmation_id, request.confirmation) +} + #[tauri::command] pub(crate) fn set_migration_locations( state: State<'_, MigratorCoordinator>, diff --git a/src/apps/data-migrator/src/lib.rs b/src/apps/data-migrator/src/lib.rs index b50be4419b..695f6db876 100644 --- a/src/apps/data-migrator/src/lib.rs +++ b/src/apps/data-migrator/src/lib.rs @@ -46,6 +46,8 @@ pub fn run() -> Result<(), RunError> { }) .invoke_handler(tauri::generate_handler![ commands::get_migrator_bootstrap, + commands::preview_openbitfun_reset, + commands::reset_openbitfun_data, commands::set_migration_locations, commands::new_migration_task, commands::resume_migration_task, diff --git a/src/apps/data-migrator/ui/app.js b/src/apps/data-migrator/ui/app.js index fe8f13e879..aec26b5f3f 100644 --- a/src/apps/data-migrator/ui/app.js +++ b/src/apps/data-migrator/ui/app.js @@ -61,6 +61,7 @@ let pollTimer; let locationsDirty = false; let locationSnapshot; let scopeSnapshot; +let resetConfirmationId; function format(template, values) { return Object.entries(values).reduce((value, [key, replacement]) => @@ -139,6 +140,23 @@ function selection() { function render(view) { current = view; + const resetPreview = view.resetPreview; + if (resetConfirmationId !== resetPreview?.confirmationId) { + resetConfirmationId = resetPreview?.confirmationId; + document.getElementById('reset-confirmation').value = ''; + } + show('reset-preview', Boolean(resetPreview) && !view.resetting); + show('reset-progress', view.resetting); + document.getElementById('reset-directories').replaceChildren(...(resetPreview?.directories || []).map((directory) => + row(directory.path, directory.exists ? text.resetExists : text.resetMissing))); + const resetResult = view.resetResult; + show('reset-result', Boolean(resetResult)); + if (resetResult) { + const resultNode = document.getElementById('reset-result'); + const summary = document.createElement('p'); + summary.textContent = resetResult.failed.length ? text.resetIncomplete : text.resetSuccess; + resultNode.replaceChildren(summary, ...resetResult.failed.map((path) => row(path, text.resetFailed))); + } const source = view.source; document.getElementById('source-badge').textContent = !source ? text.missing : source.supported ? text.ready : text.unsupported; @@ -211,7 +229,11 @@ function render(view) { output.hidden = true; } document.querySelectorAll('button, #locations input, #language, #saved-task').forEach((node) => { node.disabled = view.running; }); - document.getElementById('cancel').disabled = !view.running; + document.getElementById('cancel').disabled = !view.running || view.resetting; + document.getElementById('preview-reset').disabled = view.running || locationsDirty; + document.getElementById('reset-confirmation').disabled = view.running || locationsDirty; + document.getElementById('reset-data').disabled = view.running || locationsDirty || !resetPreview + || document.getElementById('reset-confirmation').value !== 'RESET'; document.getElementById('start').disabled = !view.canExecute || locationsDirty; document.getElementById('resume-task').disabled = !tasks.selectedOptions[0] || tasks.selectedOptions[0].disabled || view.running || locationsDirty; for (const id of ['scan', 'prepare']) document.getElementById(id).disabled = view.running || locationsDirty; @@ -292,6 +314,15 @@ document.getElementById('apply-locations').addEventListener('click', async () => }); document.getElementById('resume-task').addEventListener('click', () => call('resume_migration_task', { runId: document.getElementById('saved-task').value })); document.getElementById('new-task').addEventListener('click', () => call('new_migration_task')); +document.getElementById('preview-reset').addEventListener('click', () => call('preview_openbitfun_reset')); +document.getElementById('reset-confirmation').addEventListener('input', () => { if (current) render(current); }); +document.getElementById('reset-data').addEventListener('click', () => { + if (!current?.resetPreview) return; + call('reset_openbitfun_data', { + confirmationId: current.resetPreview.confirmationId, + confirmation: document.getElementById('reset-confirmation').value, + }); +}); for (const id of ['close', 'finish']) document.getElementById(id).addEventListener('click', () => call('finish_legacy_migration')); document.getElementById('scan').addEventListener('click', () => diff --git a/src/apps/data-migrator/ui/index.html b/src/apps/data-migrator/ui/index.html index 5efdedb074..6a0e1ff45a 100644 --- a/src/apps/data-migrator/ui/index.html +++ b/src/apps/data-migrator/ui/index.html @@ -103,6 +103,20 @@

Migration report

+
+

+

+
+ + + +
diff --git a/src/apps/data-migrator/ui/locales.json b/src/apps/data-migrator/ui/locales.json index fb8079dab0..24f885a1c1 100644 --- a/src/apps/data-migrator/ui/locales.json +++ b/src/apps/data-migrator/ui/locales.json @@ -52,6 +52,18 @@ "savedTask": "Task and result", "resumeTask": "Review / resume task", "newTask": "New migration", + "resetTitle": "Reset OpenBitFun data", + "resetHelp": "Start over after an unsuccessful migration. Close BitFun, OpenBitFun, their background writers, and other Data Migrator instances first.", + "previewReset": "Review reset directories", + "resetWarning": "Permanently deletes all data in the directories below, including OpenBitFun settings, credentials, assistant workspaces, sessions, and migration logs/backups. Save anything you need before continuing. Original BitFun source data is preserved.", + "resetConfirmLabel": "Type RESET to confirm permanent deletion", + "resetAction": "Permanently reset OpenBitFun data", + "resetExists": "Will be deleted", + "resetMissing": "Not present", + "resetProgress": "Resetting OpenBitFun data. Keep Data Migrator open until it finishes.", + "resetSuccess": "OpenBitFun data was reset. Scan the source again to begin a new migration.", + "resetIncomplete": "Some directories could not be fully removed. Close applications using them, then review and retry before migrating.", + "resetFailed": "Could not fully remove this directory", "language": "Language", "noSource": "No legacy data found. Check the source locations, then scan again.", "dirtyLocations": "Apply the edited locations before scanning or continuing.", @@ -132,6 +144,18 @@ "savedTask": "任务与结果", "resumeTask": "查看 / 恢复任务", "newTask": "新建迁移", + "resetTitle": "重置 OpenBitFun 数据", + "resetHelp": "迁移异常时清空目标后重试。请先关闭 BitFun、OpenBitFun、后台写入进程及其他迁移器实例。", + "previewReset": "查看待重置目录", + "resetWarning": "将永久删除以下目录内的全部数据,包括 OpenBitFun 配置、凭据、助理工作区、会话和迁移日志及备份。请先另存需要保留的内容。旧 BitFun 来源数据会保留。", + "resetConfirmLabel": "输入 RESET,确认永久删除", + "resetAction": "永久重置 OpenBitFun 数据", + "resetExists": "将被删除", + "resetMissing": "目录不存在", + "resetProgress": "正在重置 OpenBitFun 数据,请保持迁移器打开,等待完成。", + "resetSuccess": "OpenBitFun 数据已重置。重新扫描来源,开始新的迁移。", + "resetIncomplete": "部分目录未能完全删除。请关闭占用它们的应用,重新查看目录并重试,再进行迁移。", + "resetFailed": "未能完全删除此目录", "language": "语言", "noSource": "未发现旧版数据。请检查来源目录后重新扫描。", "dirtyLocations": "目录已修改,请先应用目录再扫描或继续迁移。", @@ -212,6 +236,18 @@ "savedTask": "工作與結果", "resumeTask": "檢視 / 恢復工作", "newTask": "新增遷移", + "resetTitle": "重設 OpenBitFun 資料", + "resetHelp": "遷移異常時清空目標後重試。請先關閉 BitFun、OpenBitFun、背景寫入程序及其他遷移器實例。", + "previewReset": "檢視待重設目錄", + "resetWarning": "將永久刪除以下目錄內的全部資料,包括 OpenBitFun 設定、憑據、助理工作區、工作階段和遷移記錄及備份。請先另存需要保留的內容。舊 BitFun 來源資料會保留。", + "resetConfirmLabel": "輸入 RESET,確認永久刪除", + "resetAction": "永久重設 OpenBitFun 資料", + "resetExists": "將被刪除", + "resetMissing": "目錄不存在", + "resetProgress": "正在重設 OpenBitFun 資料,請保持遷移器開啟,等待完成。", + "resetSuccess": "OpenBitFun 資料已重設。重新掃描來源,開始新的遷移。", + "resetIncomplete": "部分目錄未能完全刪除。請關閉佔用它們的應用程式,重新檢視目錄並重試,再進行遷移。", + "resetFailed": "未能完全刪除此目錄", "language": "語言", "noSource": "未發現舊版資料。請檢查來源目錄後重新掃描。", "dirtyLocations": "目錄已修改,請先套用目錄再掃描或繼續遷移。", diff --git a/src/crates/services/legacy-migration/AGENTS.md b/src/crates/services/legacy-migration/AGENTS.md index cbe1574353..b0850ae7c7 100644 --- a/src/crates/services/legacy-migration/AGENTS.md +++ b/src/crates/services/legacy-migration/AGENTS.md @@ -2,6 +2,9 @@ Own bounded source discovery, durable tasks, snapshot/staging/backup/journal IO, atomic commit and safe cancellation. Never initialize the main product runtime. +`reset` owns explicitly confirmed destination deletion for starting migration +again. Preview and revalidate the scope, preserve source/protected directories, +and report partial failures. The host excludes concurrent operations and writers. Historical handoff and onboarding files remain readable compatibility formats; new standalone tasks use save_task/load_task and do not depend on request expiry. Do not delete or reset corrupt plans, reports or user data. Validate UUIDs and diff --git a/src/crates/services/legacy-migration/src/lib.rs b/src/crates/services/legacy-migration/src/lib.rs index 12d3dabb8d..4766a47236 100644 --- a/src/crates/services/legacy-migration/src/lib.rs +++ b/src/crates/services/legacy-migration/src/lib.rs @@ -8,6 +8,7 @@ mod handoff; mod onboarding; mod paths; mod probe; +mod reset; mod sqlite; mod storage; mod tasks; @@ -27,6 +28,7 @@ pub use handoff::{ pub use onboarding::MigrationOnboardingStore; pub use paths::{MigrationRoots, LEGACY_PRODUCT_ID}; pub use probe::{probe_legacy_source, ProbeLimits}; +pub use reset::{plan_target_reset, reset_target_data, ResetDirectory, TargetResetResult}; pub use sqlite::{snapshot_sqlite_read_only, validate_sqlite}; pub use storage::{atomic_write_bytes, atomic_write_json, MigrationLayout, MigrationLock}; pub use tasks::{list_tasks, load_task, save_task, SavedMigrationTask}; diff --git a/src/crates/services/legacy-migration/src/paths.rs b/src/crates/services/legacy-migration/src/paths.rs index e4dbaac88f..853b75bc89 100644 --- a/src/crates/services/legacy-migration/src/paths.rs +++ b/src/crates/services/legacy-migration/src/paths.rs @@ -80,6 +80,46 @@ impl MigrationRoots { .join("bitfun-to-openbitfun") } + /// Selected destinations, expanded only at recognized platform product roots. + /// Never delete the parent of an arbitrary user-selected Skills/SSH directory. + pub(crate) fn target_reset_roots(&self) -> LegacyMigrationResult> { + let defaults = Self::current_user_locations()?; + let mut roots = vec![ + self.target_user_root.clone(), + self.target_home_root.clone(), + self.target_skills_root.clone(), + self.target_ssh_root.clone(), + ]; + for (selected, default) in [ + (&self.target_skills_root, &defaults.target_skills_root), + (&self.target_ssh_root, &defaults.target_ssh_root), + ] { + if paths_equivalent(selected, default) { + if let Some(parent) = default.parent() { + roots.push(parent.to_path_buf()); + } + } + } + if paths_equivalent(&self.target_user_root, &defaults.target_user_root) + && paths_equivalent(&self.target_home_root, &defaults.target_home_root) + { + // Desktop's Tauri identity owns persisted WebView/UI state separately + // from PathManager. The migrator has a different identity and survives. + for base in [ + dirs::config_dir(), + dirs::data_dir(), + dirs::data_local_dir(), + dirs::cache_dir(), + ] { + let base = base.ok_or_else(|| { + LegacyMigrationError::PathUnavailable("platform desktop data directory".into()) + })?; + roots.push(base.join("com.openbitfun.desktop")); + } + } + Ok(roots) + } + pub fn validate_distinct(&self) -> LegacyMigrationResult<()> { for (source, target) in [ (&self.legacy_user_root, &self.target_user_root), diff --git a/src/crates/services/legacy-migration/src/reset.rs b/src/crates/services/legacy-migration/src/reset.rs new file mode 100644 index 0000000000..65a626cff7 --- /dev/null +++ b/src/crates/services/legacy-migration/src/reset.rs @@ -0,0 +1,374 @@ +//! Explicit, confirmed reset of destination data. Source data is never removed. +use crate::{LegacyMigrationError, LegacyMigrationResult, MigrationRoots}; +use serde::Serialize; +use std::fs; +use std::path::{Component, Path, PathBuf}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ResetDirectory { + pub path: PathBuf, + pub exists: bool, +} + +#[derive(Debug, Clone, Default, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TargetResetResult { + pub removed: Vec, + pub missing: Vec, + pub failed: Vec, +} + +/// Resolve existing parents and refuse links/junctions at any root component. +fn normalized(path: &Path) -> LegacyMigrationResult { + if !path.is_absolute() + || path.parent().is_none() + || path + .components() + .any(|part| matches!(part, Component::ParentDir)) + { + return Err(LegacyMigrationError::PathEscape(path.into())); + } + let mut prefix = PathBuf::new(); + for component in path.components() { + prefix.push(component); + // A Windows drive/UNC prefix alone is not a complete absolute path. + if matches!(component, Component::Prefix(_)) { + continue; + } + match fs::symlink_metadata(&prefix) { + Ok(metadata) => { + #[cfg(windows)] + let reparse = { + use std::os::windows::fs::MetadataExt; + metadata.file_attributes() & 0x0400 != 0 + }; + #[cfg(not(windows))] + let reparse = false; + if metadata.file_type().is_symlink() || reparse { + return Err(LegacyMigrationError::LinkedPath(prefix)); + } + if !metadata.is_dir() { + return Err(LegacyMigrationError::PathEscape(prefix)); + } + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + return Err(LegacyMigrationError::Io { + path: prefix, + source: error, + }) + } + } + } + let mut existing = path; + let mut suffix = Vec::new(); + while !existing.exists() { + suffix.push( + existing + .file_name() + .ok_or_else(|| LegacyMigrationError::PathEscape(path.into()))?, + ); + existing = existing + .parent() + .ok_or_else(|| LegacyMigrationError::PathEscape(path.into()))?; + } + let mut resolved = fs::canonicalize(existing).map_err(|source| LegacyMigrationError::Io { + path: existing.into(), + source, + })?; + for part in suffix.into_iter().rev() { + resolved.push(part); + } + if cfg!(windows) { + resolved = PathBuf::from(resolved.to_string_lossy().to_lowercase()); + } + Ok(resolved) +} + +pub fn plan_target_reset( + roots: &MigrationRoots, + protected_directories: &[PathBuf], +) -> LegacyMigrationResult> { + let defaults = MigrationRoots::current_user_locations()?; + let mut protected = vec![ + roots.legacy_user_root.clone(), + roots.legacy_home_root.clone(), + roots.legacy_skills_root.clone(), + roots.legacy_ssh_root.clone(), + defaults.legacy_user_root, + defaults.legacy_home_root, + defaults.legacy_skills_root, + defaults.legacy_ssh_root, + ]; + let mut preserved = protected + .iter() + .map(|path| normalized(path)) + .collect::>>()?; + preserved.extend( + protected_directories + .iter() + .map(|path| normalized(path)) + .collect::>>()?, + ); + for base in [ + dirs::home_dir(), + dirs::config_dir(), + dirs::data_dir(), + dirs::data_local_dir(), + dirs::cache_dir(), + ] { + protected.push( + base.ok_or_else(|| LegacyMigrationError::PathUnavailable("platform directory".into()))?, + ); + } + protected.extend_from_slice(protected_directories); + let protected = protected + .iter() + .map(|path| normalized(path)) + .collect::>>()?; + let mut candidates = roots + .target_reset_roots()? + .into_iter() + .map(|path| normalized(&path).map(|resolved| (path, resolved))) + .collect::>>()?; + candidates.sort_by_key(|(_, resolved)| resolved.components().count()); + let mut selected: Vec<(PathBuf, PathBuf)> = Vec::new(); + for (path, resolved) in candidates { + if protected.iter().any(|keep| keep.starts_with(&resolved)) + || preserved.iter().any(|keep| resolved.starts_with(keep)) + { + return Err(LegacyMigrationError::PathEscape(path)); + } + if !selected + .iter() + .any(|(_, parent)| resolved.starts_with(parent)) + { + selected.push((path, resolved)); + } + } + selected + .into_iter() + .map(|(path, _)| { + let exists = match fs::symlink_metadata(&path) { + Ok(_) => true, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => false, + Err(source) => return Err(LegacyMigrationError::Io { path, source }), + }; + Ok(ResetDirectory { path, exists }) + }) + .collect() +} + +/// The host must exclude concurrent migration and check data writers first. +/// Revalidate the whole scope before deleting anything. Partial failures remain +/// visible and can be retried; deletion is never presented as atomic or reversible. +pub fn reset_target_data( + roots: &MigrationRoots, + confirmed: &[ResetDirectory], + protected_directories: &[PathBuf], +) -> LegacyMigrationResult { + let current = plan_target_reset(roots, protected_directories)?; + if current != confirmed { + return Err(LegacyMigrationError::InvalidRequest( + "reset destinations changed; review them again".into(), + )); + } + let mut result = TargetResetResult::default(); + for directory in current { + // Repeat root checks immediately before removal. Rust's remove_dir_all + // removes child symlinks themselves rather than following their targets. + if normalized(&directory.path).is_err() { + result.failed.push(directory.path); + continue; + } + match fs::remove_dir_all(&directory.path) { + Ok(()) => result.removed.push(directory.path), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + result.missing.push(directory.path) + } + Err(_) => result.failed.push(directory.path), + } + } + Ok(result) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn create_directory_link(target: &Path, link: &Path) { + #[cfg(unix)] + std::os::unix::fs::symlink(target, link).unwrap(); + #[cfg(windows)] + { + // Junctions exercise reparse safety without requiring symlink privilege. + let output = openbitfun_services_core::process_manager::create_command("cmd.exe") + .args(["/C", "mklink", "/J"]) + .arg(link) + .arg(target) + .output() + .unwrap(); + assert!( + output.status.success(), + "junction creation failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + } + + fn roots(root: &Path) -> MigrationRoots { + // macOS temporary directories may have a system symlink in their prefix. + let root = fs::canonicalize(root).unwrap(); + MigrationRoots { + legacy_user_root: root.join("legacy/user"), + legacy_home_root: root.join("legacy/home"), + legacy_skills_root: root.join("legacy/skills"), + legacy_ssh_root: root.join("legacy/ssh"), + target_user_root: root.join("target/user"), + target_home_root: root.join("target/home"), + target_skills_root: root.join("target/user/skills"), + target_ssh_root: root.join("target/ssh"), + } + } + + #[test] + fn reset_removes_only_confirmed_destinations_and_merges_nested_roots() { + let temp = tempfile::tempdir().unwrap(); + let roots = roots(temp.path()); + fs::create_dir_all(&roots.legacy_user_root).unwrap(); + fs::write(roots.legacy_user_root.join("keep"), "source").unwrap(); + fs::create_dir_all(roots.target_user_root.join("data/migrations/runs")).unwrap(); + fs::write( + roots.target_user_root.join("data/migrations/runs/report"), + "old report", + ) + .unwrap(); + fs::create_dir_all(&roots.target_home_root).unwrap(); + let plan = plan_target_reset(&roots, &[]).unwrap(); + assert_eq!(plan.len(), 3); + let result = reset_target_data(&roots, &plan, &[]).unwrap(); + assert_eq!(result.removed.len(), 2); + assert_eq!(result.missing.len(), 1); + assert!(result.failed.is_empty()); + assert_eq!( + fs::read_to_string(roots.legacy_user_root.join("keep")).unwrap(), + "source" + ); + assert!(!roots.target_user_root.exists()); + assert!(!roots.target_home_root.exists()); + let plan = plan_target_reset(&roots, &[]).unwrap(); + assert_eq!( + reset_target_data(&roots, &plan, &[]).unwrap().missing.len(), + 3 + ); + } + + #[test] + fn standard_reset_scope_covers_platform_product_roots() { + let roots = MigrationRoots::current_user_locations().unwrap(); + let candidates = roots.target_reset_roots().unwrap(); + for required in [ + roots.target_user_root, + roots.target_home_root, + roots.target_skills_root.parent().unwrap().to_path_buf(), + roots.target_ssh_root.parent().unwrap().to_path_buf(), + dirs::data_local_dir() + .unwrap() + .join("com.openbitfun.desktop"), + dirs::cache_dir().unwrap().join("com.openbitfun.desktop"), + ] { + assert!( + candidates.contains(&required), + "missing reset root: {}", + required.display() + ); + } + } + + #[cfg(windows)] + #[test] + fn locked_directory_is_reported_and_other_destinations_are_still_removed() { + use std::os::windows::fs::OpenOptionsExt; + let temp = tempfile::tempdir().unwrap(); + let roots = roots(temp.path()); + fs::create_dir_all(&roots.target_home_root).unwrap(); + fs::create_dir_all(&roots.target_user_root).unwrap(); + let file = fs::OpenOptions::new() + .create_new(true) + .write(true) + .share_mode(0) + .open(roots.target_home_root.join("locked")) + .unwrap(); + let plan = plan_target_reset(&roots, &[]).unwrap(); + let result = reset_target_data(&roots, &plan, &[]).unwrap(); + assert_eq!(result.failed, vec![roots.target_home_root.clone()]); + assert_eq!(result.removed, vec![roots.target_user_root.clone()]); + assert!(roots.target_home_root.join("locked").exists()); + drop(file); + let plan = plan_target_reset(&roots, &[]).unwrap(); + assert!(reset_target_data(&roots, &plan, &[]) + .unwrap() + .failed + .is_empty()); + } + + #[test] + fn unsafe_or_changed_scopes_fail_before_any_deletion() { + let temp = tempfile::tempdir().unwrap(); + let mut roots = roots(temp.path()); + fs::create_dir_all(&roots.target_user_root).unwrap(); + fs::write(roots.target_user_root.join("keep"), "target").unwrap(); + let plan = plan_target_reset(&roots, &[]).unwrap(); + fs::create_dir_all(&roots.target_home_root).unwrap(); + assert!(reset_target_data(&roots, &plan, &[]).is_err()); + assert!(plan_target_reset(&roots, &[roots.target_user_root.clone()]).is_err()); + assert!(plan_target_reset( + &roots, + &[roots.target_user_root.parent().unwrap().to_path_buf()] + ) + .is_err()); + for unsafe_root in [ + temp.path().to_path_buf(), + roots.legacy_home_root.clone(), + roots.legacy_home_root.join("child"), + PathBuf::from("relative"), + dirs::home_dir().unwrap(), + ] { + roots.target_home_root = unsafe_root; + assert!(plan_target_reset(&roots, &[]).is_err()); + assert_eq!( + fs::read_to_string(roots.target_user_root.join("keep")).unwrap(), + "target" + ); + } + } + + #[test] + fn root_links_are_rejected_and_child_links_do_not_delete_sources() { + let temp = tempfile::tempdir().unwrap(); + let roots = roots(temp.path()); + fs::create_dir_all(&roots.legacy_home_root).unwrap(); + fs::write(roots.legacy_home_root.join("keep"), "source").unwrap(); + fs::create_dir_all(roots.target_home_root.parent().unwrap()).unwrap(); + create_directory_link(&roots.legacy_home_root, &roots.target_home_root); + assert!(plan_target_reset(&roots, &[]).is_err()); + fs::remove_dir(&roots.target_home_root) + .or_else(|_| fs::remove_file(&roots.target_home_root)) + .unwrap(); + fs::create_dir_all(&roots.target_home_root).unwrap(); + create_directory_link( + &roots.legacy_home_root, + &roots.target_home_root.join("linked-source"), + ); + let plan = plan_target_reset(&roots, &[]).unwrap(); + assert!(reset_target_data(&roots, &plan, &[]) + .unwrap() + .failed + .is_empty()); + assert_eq!( + fs::read_to_string(roots.legacy_home_root.join("keep")).unwrap(), + "source" + ); + } +} From 5302bde14afbfb47ad7a1c4f29a42b1338b4cf60 Mon Sep 17 00:00:00 2001 From: wsp Date: Fri, 9 Oct 2026 19:42:52 +0800 Subject: [PATCH 4/4] chore(migrator): Bump version to 0.1.2 --- Cargo.lock | 2 +- src/apps/data-migrator/Cargo.toml | 2 +- src/apps/data-migrator/README.md | 2 +- src/apps/data-migrator/README.zh-CN.md | 2 +- src/apps/data-migrator/tauri.conf.json | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 42e6a2d971..6b76cc755f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6342,7 +6342,7 @@ dependencies = [ [[package]] name = "openbitfun-data-migrator" -version = "0.1.1" +version = "0.1.2" dependencies = [ "openbitfun-core-types", "openbitfun-legacy-migration", diff --git a/src/apps/data-migrator/Cargo.toml b/src/apps/data-migrator/Cargo.toml index 30e6bde21b..f47d7feb5d 100644 --- a/src/apps/data-migrator/Cargo.toml +++ b/src/apps/data-migrator/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "openbitfun-data-migrator" -version = "0.1.1" +version = "0.1.2" authors.workspace = true edition.workspace = true description = "OpenBitFun offline legacy data migrator" diff --git a/src/apps/data-migrator/README.md b/src/apps/data-migrator/README.md index f53820ce4f..00d10b49d8 100644 --- a/src/apps/data-migrator/README.md +++ b/src/apps/data-migrator/README.md @@ -7,7 +7,7 @@ A separate, optional desktop utility for importing old **BitFun** data into ## Download and run -**Latest release: [v0.1.1 — download Data Migrator](https://github.com/GCWing/OpenBitFun/releases/tag/data-migrator-v0.1.1).** +**Latest release: [v0.1.2 — download Data Migrator](https://github.com/GCWing/OpenBitFun/releases/tag/data-migrator-v0.1.2).** | Platform | Download | Launch | | --- | --- | --- | diff --git a/src/apps/data-migrator/README.zh-CN.md b/src/apps/data-migrator/README.zh-CN.md index 665456dcef..a70f0e2289 100644 --- a/src/apps/data-migrator/README.zh-CN.md +++ b/src/apps/data-migrator/README.zh-CN.md @@ -6,7 +6,7 @@ ## 下载和使用 -**最新版本:[v0.1.1 — 下载数据迁移器](https://github.com/GCWing/OpenBitFun/releases/tag/data-migrator-v0.1.1)。** +**最新版本:[v0.1.2 — 下载数据迁移器](https://github.com/GCWing/OpenBitFun/releases/tag/data-migrator-v0.1.2)。** | 系统 | 下载文件 | 启动方式 | | --- | --- | --- | diff --git a/src/apps/data-migrator/tauri.conf.json b/src/apps/data-migrator/tauri.conf.json index 5ce6274087..82071ccd23 100644 --- a/src/apps/data-migrator/tauri.conf.json +++ b/src/apps/data-migrator/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "OpenBitFun Data Migrator", - "version": "0.1.1", + "version": "0.1.2", "mainBinaryName": "openbitfun-data-migrator", "identifier": "com.openbitfun.data-migrator", "build": {