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 493d04e08b..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 | | --- | --- | --- | @@ -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..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)。** | 系统 | 下载文件 | 启动方式 | | --- | --- | --- | @@ -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/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": { 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/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 18b48d007f..370045b480 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); @@ -1134,6 +1139,15 @@ fn relocate_assistant_session_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); @@ -1150,11 +1164,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 +1299,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)?; @@ -1258,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(); @@ -1265,6 +1357,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 +1408,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 +2487,62 @@ 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); + } + } + + #[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()); let fixture = PathBuf::from(env!("CARGO_MANIFEST_DIR")) @@ -2417,10 +2594,15 @@ 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 = 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 +2610,9 @@ 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(), "project_workspace_path": source_display.clone(), "execution_target": { @@ -2451,6 +2636,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 +2649,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 +2658,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 +2715,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 [ "/config/workspace_path", "/config/project_workspace_path", @@ -2545,6 +2748,106 @@ 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(); + 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 [ + 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] 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" + ); + } +} 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| {