diff --git a/Cargo.lock b/Cargo.lock index 6eb09e10f1..7d9fa167ab 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5901,6 +5901,7 @@ dependencies = [ "openbitfun-dsh-adapter", "openbitfun-events", "openbitfun-external-sources", + "openbitfun-legacy-migration", "openbitfun-opencode-adapter", "openbitfun-opencode-plugin-host", "openbitfun-plugin-runtime-client", @@ -5942,6 +5943,23 @@ dependencies = [ "ts-rs", ] +[[package]] +name = "openbitfun-data-migrator" +version = "1.0.0" +dependencies = [ + "openbitfun-core", + "openbitfun-core-types", + "openbitfun-legacy-migration", + "openbitfun-product-capabilities", + "openbitfun-product-domains", + "serde", + "serde_json", + "tauri", + "tauri-build", + "tempfile", + "uuid", +] + [[package]] name = "openbitfun-desktop" version = "1.0.0" @@ -5985,6 +6003,7 @@ dependencies = [ "openbitfun-core", "openbitfun-core-types", "openbitfun-events", + "openbitfun-legacy-migration", "openbitfun-product-domains", "openbitfun-relay-service", "openbitfun-runtime-ports", @@ -6070,6 +6089,28 @@ dependencies = [ "tokio", ] +[[package]] +name = "openbitfun-legacy-migration" +version = "1.0.0" +dependencies = [ + "chrono", + "dirs 6.0.0", + "fs2", + "hex", + "libc", + "openbitfun-product-domains", + "openbitfun-services-core", + "rusqlite", + "semver", + "serde", + "serde_json", + "sha2", + "tempfile", + "thiserror 2.0.19", + "uuid", + "windows 0.61.3", +] + [[package]] name = "openbitfun-miniapp-market-server" version = "1.0.0" diff --git a/Cargo.toml b/Cargo.toml index 18eaa6b17c..b4f13d7fe5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,7 @@ members = [ "src/apps/cli", "src/apps/sdk-host", "src/apps/desktop", + "src/apps/data-migrator", "src/apps/server", "src/apps/relay-server", "src/apps/miniapp-market-server", @@ -27,6 +28,7 @@ members = [ "src/crates/adapters/transport", "src/crates/services/services-core", "src/crates/services/services-integrations", + "src/crates/services/legacy-migration", "src/crates/services/miniapp-market-service", "src/crates/services/skin-market-service", "src/crates/services/relay-service", @@ -197,9 +199,10 @@ urlencoding = "2.1" oxc = { version = "0.138.0", default-features = false, features = ["ast_visit", "codegen", "semantic", "transformer"] } oxc-parse = { package = "oxc", version = "0.138.0", default-features = false } sqlx = { version = "0.8", default-features = false } +rusqlite = { version = "0.32", default-features = false } # Tauri (desktop only) -tauri = { version = "2.11", features = ["unstable", "macos-private-api", "tray-icon"] } +tauri = { version = "2.11" } tauri-plugin-opener = "2.5" tauri-plugin-dialog = "2.7" tauri-plugin-fs = "2.5" diff --git a/OpenBitFun-Installer/scripts/build-installer.cjs b/OpenBitFun-Installer/scripts/build-installer.cjs index 3cc9f52f2c..20bd2776a4 100644 --- a/OpenBitFun-Installer/scripts/build-installer.cjs +++ b/OpenBitFun-Installer/scripts/build-installer.cjs @@ -28,6 +28,7 @@ const STRICT_PAYLOAD_VALIDATION = !isDev; const MIN_APP_EXE_BYTES = 5 * 1024 * 1024; const REQUIRED_PAYLOAD_FILES = [ "openbitfun-desktop.exe", + "openbitfun-data-migrator.exe", "frontend/dist/index.html", "mobile-web/dist/index.html", "resources/ext-host/extension-host.js", diff --git a/OpenBitFun-Installer/scripts/build-installer.test.cjs b/OpenBitFun-Installer/scripts/build-installer.test.cjs index 985b8c9eeb..2bdfb09c11 100644 --- a/OpenBitFun-Installer/scripts/build-installer.test.cjs +++ b/OpenBitFun-Installer/scripts/build-installer.test.cjs @@ -103,16 +103,38 @@ test("all Installer validators share the required runtime file contract", () => path.join(installerRoot, "src-tauri", "src", "installer", "commands.rs"), "utf8" ); + const installerModRs = fs.readFileSync( + path.join(installerRoot, "src-tauri", "src", "installer", "mod.rs"), + "utf8" + ); for (const relativePath of REQUIRED_PAYLOAD_FILES) { assert.match(buildRs, new RegExp(escapeRegExp(relativePath))); if (relativePath === "openbitfun-desktop.exe") { assert.match(commandsRs, /MAIN_APP_EXE/); + } else if (relativePath === "openbitfun-data-migrator.exe") { + assert.match(installerModRs, new RegExp(escapeRegExp(relativePath))); + assert.match(commandsRs, /DATA_MIGRATOR_EXE/); } else { assert.match(commandsRs, new RegExp(escapeRegExp(relativePath))); } } }); +test("Data Migrator launch resolves only the registered installation", () => { + const commandsRs = fs.readFileSync( + path.join(installerRoot, "src-tauri", "src", "installer", "commands.rs"), + "utf8" + ); + const commandStart = commandsRs.indexOf("pub(crate) fn launch_legacy_data_migrator"); + const commandEnd = commandsRs.indexOf("/// Close the installer window.", commandStart); + assert.notEqual(commandStart, -1); + assert.notEqual(commandEnd, -1); + const commandSource = commandsRs.slice(commandStart, commandEnd); + assert.doesNotMatch(commandSource, /request\.install_path/); + assert.match(commandSource, /read_existing_install_from_uninstall_registry/); + assert.match(commandSource, /read_tauri_install_location/); +}); + function minorLine(version) { assert.match(version, /^\d+\.\d+\.\d+$/); return version.split(".").slice(0, 2).join("."); diff --git a/OpenBitFun-Installer/src-tauri/Cargo.toml b/OpenBitFun-Installer/src-tauri/Cargo.toml index e76b8edcff..370c3291f7 100644 --- a/OpenBitFun-Installer/src-tauri/Cargo.toml +++ b/OpenBitFun-Installer/src-tauri/Cargo.toml @@ -30,6 +30,9 @@ zip = "0.6" openbitfun-core-types = { path = "../../src/crates/contracts/core-types" } openbitfun-ai-adapters = { path = "../../src/crates/adapters/ai-adapters" } openbitfun-services-core = { path = "../../src/crates/services/services-core", features = ["json-io"] } +openbitfun-legacy-migration = { path = "../../src/crates/services/legacy-migration" } +openbitfun-product-domains = { path = "../../src/crates/contracts/product-domains", features = ["legacy-migration"] } +uuid = { version = "1", features = ["v4"] } [target.'cfg(windows)'.dependencies] winreg = "0.52" diff --git a/OpenBitFun-Installer/src-tauri/build.rs b/OpenBitFun-Installer/src-tauri/build.rs index 11110fd940..88045a3664 100644 --- a/OpenBitFun-Installer/src-tauri/build.rs +++ b/OpenBitFun-Installer/src-tauri/build.rs @@ -5,8 +5,9 @@ use std::path::{Path, PathBuf}; use zip::write::FileOptions; use zip::{CompressionMethod, ZipWriter}; -const REQUIRED_PAYLOAD_FILES: [&str; 5] = [ +const REQUIRED_PAYLOAD_FILES: [&str; 6] = [ "openbitfun-desktop.exe", + "openbitfun-data-migrator.exe", "frontend/dist/index.html", "mobile-web/dist/index.html", "resources/ext-host/extension-host.js", @@ -14,6 +15,7 @@ const REQUIRED_PAYLOAD_FILES: [&str; 5] = [ ]; fn main() { + println!("cargo:rerun-if-env-changed=OPENBITFUN_RELEASE_CHANNEL"); if let Err(err) = build_embedded_payload() { panic!("failed to build embedded payload: {err}"); } diff --git a/OpenBitFun-Installer/src-tauri/src/installer/commands.rs b/OpenBitFun-Installer/src-tauri/src/installer/commands.rs index 1576058fab..b53102c14e 100644 --- a/OpenBitFun-Installer/src-tauri/src/installer/commands.rs +++ b/OpenBitFun-Installer/src-tauri/src/installer/commands.rs @@ -6,12 +6,23 @@ use super::types::{ ConnectionTestResult, DiskSpaceInfo, InstallOptions, InstallProgress, ModelConfig, RemoteModelInfo, }; -use super::MAIN_APP_EXE; +use super::{DATA_MIGRATOR_EXE, MAIN_APP_EXE}; use openbitfun_core_types::{ installer_config_handoff::{ InstallerConfigHandoff, InstallerModelHandoff, INSTALLER_CONFIG_HANDOFF_FILE_NAME, }, - product_identity::{data_namespace, hidden_data_directory}, + product_identity::{data_namespace, hidden_data_directory, product_id}, +}; +#[cfg(target_os = "windows")] +use openbitfun_legacy_migration::{ + launch_trusted_executable, probe_legacy_source, HandoffStore, MigrationOnboardingStore, + MigrationRoots, ProbeLimits, TrustedInstallationResolver, +}; +#[cfg(target_os = "windows")] +use openbitfun_product_domains::legacy_migration::{ + MigrationPromptChoice, MigrationSelection, MigratorHandoffRequest, + MigratorProtocolCapabilities, MigratorRequestMode, MigratorRequestOrigin, + CURRENT_MIGRATION_FORMAT_VERSION, }; use openbitfun_services_core::json_store::JsonFileStore; use serde::{Deserialize, Serialize}; @@ -22,6 +33,8 @@ use std::fs::File; use std::io::{Cursor, Read}; use std::path::{Path, PathBuf}; use std::sync::LazyLock; +#[cfg(target_os = "windows")] +use std::time::{SystemTime, UNIX_EPOCH}; use tauri::{Emitter, Manager, Window}; #[cfg(target_os = "windows")] @@ -35,14 +48,22 @@ struct WindowsInstallState { const MIN_WINDOWS_APP_EXE_BYTES: u64 = 5 * 1024 * 1024; const PAYLOAD_MANIFEST_FILE: &str = "payload-manifest.json"; -const REQUIRED_PAYLOAD_FILES: [&str; 5] = [ +const REQUIRED_PAYLOAD_FILES: [&str; 6] = [ MAIN_APP_EXE, + DATA_MIGRATOR_EXE, "frontend/dist/index.html", "mobile-web/dist/index.html", "resources/ext-host/extension-host.js", "resources/worker_host.js", ]; const INSTALLER_STATE_FILE: &str = "installer-state.json"; +#[cfg(target_os = "windows")] +const MIGRATOR_HANDOFF_LIFETIME_MS: i64 = 10 * 60 * 1000; +#[cfg(target_os = "windows")] +const RELEASE_CHANNEL: &str = match option_env!("OPENBITFUN_RELEASE_CHANNEL") { + Some(value) => value, + None => "stable", +}; const EMBEDDED_PAYLOAD_ZIP: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/embedded_payload.zip")); @@ -836,6 +857,111 @@ pub(crate) fn launch_application(install_path: String) -> Result<(), String> { Ok(()) } +/// Create an Installer-origin onboarding handoff and launch the installed +/// standalone Data Migrator. The Installer never reads or writes migrated +/// product data itself. +#[derive(Debug, Default, Deserialize)] +#[serde(default, deny_unknown_fields, rename_all = "camelCase")] +pub(crate) struct LaunchLegacyDataMigratorRequest {} + +#[tauri::command] +pub(crate) fn launch_legacy_data_migrator( + request: LaunchLegacyDataMigratorRequest, +) -> Result { + #[cfg(not(target_os = "windows"))] + { + let _ = request; + return Err( + "Legacy data migration is not supported by this Installer platform.".to_string(), + ); + } + + #[cfg(target_os = "windows")] + { + let _ = request; + let roots = MigrationRoots::resolve_current_user() + .map_err(|_| "Could not resolve the current-user migration storage.".to_string())?; + let Some(source) = probe_legacy_source(&roots, ProbeLimits::default()) + .map_err(|_| "Could not safely inspect older data.".to_string())? + else { + return Ok(false); + }; + if !source.supported { + return Err( + "The discovered legacy data format is not supported by this Data Migrator." + .to_string(), + ); + } + + let now_ms = migration_now_ms(); + let capabilities = MigratorProtocolCapabilities::current(); + let handoff = MigratorHandoffRequest { + protocol_version: capabilities.protocol_version, + mode: MigratorRequestMode::Onboarding, + origin: MigratorRequestOrigin::Installer, + run_id: uuid::Uuid::new_v4().to_string(), + nonce: uuid::Uuid::new_v4().to_string(), + source_id: Some(source.source_id.clone()), + source_fingerprint: Some(source.source_fingerprint.clone()), + selection: MigrationSelection::all(), + caller_process_id: std::process::id(), + product_id: product_id().to_string(), + release_channel: RELEASE_CHANNEL.to_string(), + created_at_ms: now_ms, + expires_at_ms: now_ms.saturating_add(MIGRATOR_HANDOFF_LIFETIME_MS), + required_capabilities: capabilities.capabilities, + }; + HandoffStore::new(roots.clone(), product_id(), RELEASE_CHANNEL) + .write_request(&handoff, now_ms) + .map_err(|_| "Could not create a safe Data Migrator handoff.".to_string())?; + MigrationOnboardingStore::new(roots) + .update(|state| { + state.format_version = CURRENT_MIGRATION_FORMAT_VERSION; + state.source_fingerprint = source.source_fingerprint.clone(); + state.detected_at_ms.get_or_insert(now_ms); + state.choice = MigrationPromptChoice::Unset; + state.last_prompted_version = Some(env!("CARGO_PKG_VERSION").to_string()); + state.run_id = Some(handoff.run_id.clone()); + state.handled_run_id = None; + }) + .map_err(|_| "Could not persist the Data Migrator handoff state.".to_string())?; + + let registered_install_path = super::registry::read_existing_install_from_uninstall_registry() + .map(|registration| registration.install_location) + .or_else(super::registry::read_tauri_install_location) + .ok_or_else(|| { + "The registered OpenBitFun installation could not be found. Repair the installation before starting Data Migrator." + .to_string() + })?; + let desktop = PathBuf::from(registered_install_path).join(MAIN_APP_EXE); + let executable = TrustedInstallationResolver::resolve_sibling( + &desktop, + MAIN_APP_EXE, + DATA_MIGRATOR_EXE, + ) + .map_err(|_| { + "The installed Data Migrator is missing or failed installation layout checks. Repair the OpenBitFun installation." + .to_string() + })?; + launch_trusted_executable( + &executable, + &[std::ffi::OsStr::new(handoff.run_id.as_str())], + ) + .map_err(|_| "Could not launch the installed Data Migrator.".to_string())?; + Ok(true) + } +} + +#[cfg(target_os = "windows")] +fn migration_now_ms() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() + .try_into() + .unwrap_or(i64::MAX) +} + /// Close the installer window. #[tauri::command] pub(crate) fn close_installer(window: Window) { diff --git a/OpenBitFun-Installer/src-tauri/src/installer/mod.rs b/OpenBitFun-Installer/src-tauri/src/installer/mod.rs index c918bb1ecb..fc65d1510e 100644 --- a/OpenBitFun-Installer/src-tauri/src/installer/mod.rs +++ b/OpenBitFun-Installer/src-tauri/src/installer/mod.rs @@ -6,6 +6,7 @@ mod types; /// Windows main binary file name — must match `src/apps/desktop` `[[bin]]` and Tauri NSIS output. const MAIN_APP_EXE: &str = "openbitfun-desktop.exe"; +const DATA_MIGRATOR_EXE: &str = "openbitfun-data-migrator.exe"; #[cfg(target_os = "windows")] mod registry; diff --git a/OpenBitFun-Installer/src-tauri/src/lib.rs b/OpenBitFun-Installer/src-tauri/src/lib.rs index 3a3904b400..bf14be84ed 100644 --- a/OpenBitFun-Installer/src-tauri/src/lib.rs +++ b/OpenBitFun-Installer/src-tauri/src/lib.rs @@ -21,6 +21,7 @@ pub fn run() { commands::set_theme_preference, commands::uninstall, commands::launch_application, + commands::launch_legacy_data_migrator, commands::close_installer, ]) .run(tauri::generate_context!()) diff --git a/OpenBitFun-Installer/src/App.tsx b/OpenBitFun-Installer/src/App.tsx index 3b71bb5462..cef022e03b 100644 --- a/OpenBitFun-Installer/src/App.tsx +++ b/OpenBitFun-Installer/src/App.tsx @@ -90,6 +90,7 @@ function App() { options={installer.options} setOptions={installer.setOptions} onLaunch={installer.launchApp} + onLaunchMigration={installer.launchLegacyDataMigrator} onClose={installer.closeInstaller} /> ); diff --git a/OpenBitFun-Installer/src/hooks/useInstaller.ts b/OpenBitFun-Installer/src/hooks/useInstaller.ts index a6e21cc0b5..8c7429f3b1 100644 --- a/OpenBitFun-Installer/src/hooks/useInstaller.ts +++ b/OpenBitFun-Installer/src/hooks/useInstaller.ts @@ -38,6 +38,7 @@ export interface UseInstallerReturn { saveModelConfig: () => Promise; testModelConnection: (modelConfig: ModelConfig) => Promise; launchApp: () => Promise; + launchLegacyDataMigrator: () => Promise; closeInstaller: () => void; refreshDiskSpace: (path: string) => Promise; clearInstallError: () => void; @@ -333,6 +334,12 @@ export function useInstaller(): UseInstallerReturn { await invoke('launch_application', { installPath: options.installPath }); }, [options.installPath]); + const launchLegacyDataMigrator = useCallback(async () => { + return invoke('launch_legacy_data_migrator', { + request: {}, + }); + }, []); + const closeInstaller = useCallback(() => { invoke('close_installer'); }, []); @@ -379,7 +386,7 @@ export function useInstaller(): UseInstallerReturn { progress, isInstalling, installationCompleted, error, diskSpace, existingInstall, launchRegisteredUninstaller, install, canConfirmProgress, confirmProgress, retryInstall, backToOptions, - saveModelConfig, testModelConnection, launchApp, closeInstaller, refreshDiskSpace, clearInstallError, + saveModelConfig, testModelConnection, launchApp, launchLegacyDataMigrator, closeInstaller, refreshDiskSpace, clearInstallError, isUninstallMode, isUninstalling, uninstallCompleted, uninstallError, uninstallProgress, startUninstall, }; } diff --git a/OpenBitFun-Installer/src/i18n/locales/en.json b/OpenBitFun-Installer/src/i18n/locales/en.json index 858834ec63..afd0daf843 100644 --- a/OpenBitFun-Installer/src/i18n/locales/en.json +++ b/OpenBitFun-Installer/src/i18n/locales/en.json @@ -225,7 +225,9 @@ } }, "complete": { - "finish": "Finish" + "finish": "Finish", + "migrateLegacyData": "Migrate legacy data after installation", + "migrateLegacyDataDescription": "Starts the installed Data Migrator. It keeps the old data unchanged, asks you to confirm the scope, and reopens OpenBitFun when migration ends." }, "uninstall": { "title": "Uninstall OpenBitFun", diff --git a/OpenBitFun-Installer/src/i18n/locales/zh-TW.json b/OpenBitFun-Installer/src/i18n/locales/zh-TW.json index 9e6e6d4afd..cd72d342e7 100644 --- a/OpenBitFun-Installer/src/i18n/locales/zh-TW.json +++ b/OpenBitFun-Installer/src/i18n/locales/zh-TW.json @@ -225,7 +225,9 @@ } }, "complete": { - "finish": "完成" + "finish": "完成", + "migrateLegacyData": "安裝完成後遷移舊 舊版資料", + "migrateLegacyDataDescription": "啟動已安裝的 Data Migrator。它會保持舊資料不變,請你確認遷移範圍,並在遷移結束後重新開啟 OpenBitFun。" }, "uninstall": { "title": "解除安裝 OpenBitFun", diff --git a/OpenBitFun-Installer/src/i18n/locales/zh.json b/OpenBitFun-Installer/src/i18n/locales/zh.json index 36e27b06af..7888d8e19c 100644 --- a/OpenBitFun-Installer/src/i18n/locales/zh.json +++ b/OpenBitFun-Installer/src/i18n/locales/zh.json @@ -225,7 +225,9 @@ } }, "complete": { - "finish": "完成" + "finish": "完成", + "migrateLegacyData": "安装完成后迁移旧版 数据", + "migrateLegacyDataDescription": "启动已安装的 Data Migrator。它会保持旧数据不变,请你确认迁移范围,并在迁移结束后重新打开 OpenBitFun。" }, "uninstall": { "title": "卸载 OpenBitFun", diff --git a/OpenBitFun-Installer/src/pages/ThemeSetup.tsx b/OpenBitFun-Installer/src/pages/ThemeSetup.tsx index 235500a0a5..8aef762ae8 100644 --- a/OpenBitFun-Installer/src/pages/ThemeSetup.tsx +++ b/OpenBitFun-Installer/src/pages/ThemeSetup.tsx @@ -10,10 +10,11 @@ interface ThemeSetupProps { options: InstallOptions; setOptions: React.Dispatch>; onLaunch: () => Promise; + onLaunchMigration: () => Promise; onClose: () => void; } -export function ThemeSetup({ options, setOptions, onLaunch, onClose }: ThemeSetupProps) { +export function ThemeSetup({ options, setOptions, onLaunch, onLaunchMigration, onClose }: ThemeSetupProps) { const { t } = useTranslation(); const [isFinishing, setIsFinishing] = useState(false); const [finishError, setFinishError] = useState(null); @@ -57,7 +58,10 @@ export function ThemeSetup({ options, setOptions, onLaunch, onClose }: ThemeSetu console.warn('Failed to persist theme preference:', err); } - if (options.launchAfterInstall) { + const migratorLaunched = options.migrateLegacyData + ? await onLaunchMigration() + : false; + if (!migratorLaunched && options.launchAfterInstall) { await onLaunch(); } onClose(); @@ -123,6 +127,18 @@ export function ThemeSetup({ options, setOptions, onLaunch, onClose }: ThemeSetu
+ setOptions((prev) => ({ ...prev, migrateLegacyData: checked }))} + label={t('complete.migrateLegacyData')} + /> +

+ {t('complete.migrateLegacyDataDescription')} +

setOptions((prev) => ({ ...prev, launchAfterInstall: checked }))} diff --git a/OpenBitFun-Installer/src/types/installer.ts b/OpenBitFun-Installer/src/types/installer.ts index 8913048820..7f1d18ebe0 100644 --- a/OpenBitFun-Installer/src/types/installer.ts +++ b/OpenBitFun-Installer/src/types/installer.ts @@ -83,6 +83,7 @@ export interface InstallOptions { desktopShortcut: boolean; startMenu: boolean; launchAfterInstall: boolean; + migrateLegacyData: boolean; appLanguage: AppLanguage; themePreference: ThemePreferenceId; modelConfig: ModelConfig | null; @@ -109,6 +110,7 @@ export const DEFAULT_OPTIONS: InstallOptions = { desktopShortcut: true, startMenu: true, launchAfterInstall: true, + migrateLegacyData: false, appLanguage: 'zh-CN', themePreference: SYSTEM_THEME_ID, modelConfig: null, diff --git a/docs/architecture/product-customization-blueprint.md b/docs/architecture/product-customization-blueprint.md index 332424d165..22ff0d80bd 100644 --- a/docs/architecture/product-customization-blueprint.md +++ b/docs/architecture/product-customization-blueprint.md @@ -8,7 +8,7 @@ 本文同时记录长期目标边界与最小架构切片。设计只保留已有或近期有明确消费方的概念,不建立通用 白标平台、构建脚本运行时或跨 GUI/TUI 的组件协议;未在“当前实现”中列出的对象只是后续边界,不应被视为已支持能力。 -## 0. 当前实现(C0a) +## 0. 当前实现(C0b) C0a 实现一个构建期 JSONC 产品定义、严格解析器和确定性解析摘要,入口位于 `products/` 与 `scripts/product-customization/`。默认命令不需要参数;只有多产品仓库、CI 矩阵或外部定义文件需要显式传入 @@ -17,13 +17,15 @@ C0a 实现一个构建期 JSONC 产品定义、严格解析器和确定性解析 当前真实消费者只有: - Desktop build adapter:从解析结果覆盖 Tauri `productName`、`mainBinaryName` 与 bundle identifier; +- Data Migrator build adapter:从解析结果覆盖独立 Tauri 身份,并把 Desktop 与 Migrator 的 sibling binary name + 编译进交接边界; - CLI dev/build wrapper:从同一解析结果设置命令名、隔离定制构建缓存,并按成员 `binaryName` 暂存构建产物; -- First-party Rust artifacts:Desktop/CLI build adapter 通过编译期环境注入 `productId`、 +- First-party Rust artifacts:Desktop/Data Migrator/CLI build adapter 通过编译期环境注入 `productId`、 `dataNamespace` 与由其派生的隐藏目录名,`openbitfun-core-types::product_identity` 作为最小事实 owner,供数据路径、Runtime ownership、Remote Connect 与 Detached Dispatch 复用; `product:check` / `product:explain` 只是构建作者的校验与解释工具,不计作产品字段的生产消费者。C0a 不生成无人读取的 -通用产品 manifest 或 locale projection;Desktop 与 CLI build adapter 直接消费同一次内存解析结果,Rust consumer +通用产品 manifest 或 locale projection;三个 build adapter 直接消费同一次内存解析结果,Rust consumer 只读取随对应产品 artifact 编译进去的不可变事实,不在运行时重新选产品。 产品定义 v1 仅包含已被这些消费者读取的字段,未知字段一律拒绝。localized 名称独立于技术 ID,并按共享 locale contract @@ -35,7 +37,7 @@ GUI/TUI 布局、插件/内置扩展选择、Installer/Store target、更新与 ### 0.1 当前定义与解析契约 -产品定义描述一个 family,其中 Desktop 与 CLI 是分别命名、分别消费的成员;Installer 与 Store 是可能的 Desktop +产品定义描述一个 family,其中 Desktop、Data Migrator 与 CLI 是分别命名、分别消费的成员;Installer 与 Store 是可能的 Desktop 交付目标,不是独立成员,当前也没有对应实现。schema v1 只接受以下已消费字段: ```jsonc @@ -49,6 +51,11 @@ GUI/TUI 布局、插件/内置扩展选择、Installer/Store target、更新与 "binaryName": "acme-desktop", "bundleId": "com.acme.desktop" }, + "dataMigrator": { + "displayNameKey": "product.dataMigrator.name", + "binaryName": "acme-data-migrator", + "bundleId": "com.acme.data-migrator" + }, "cli": { "displayNameKey": "product.cli.name", "binaryName": "acme" @@ -58,7 +65,8 @@ GUI/TUI 布局、插件/内置扩展选择、Installer/Store target、更新与 ``` 解析器先校验完整 family、双方 locale key、owned path 与技术 ID,再选择命令对应成员;digest-bearing `assembly` -只携带 schema/source digest、成员、display-name key、binary/bundle identity、locale contract facts 与 assembly digest。 +只携带 schema/source digest、成员、display-name key、binary/bundle identity、交接所需 sibling binary names、 +locale contract facts 与 assembly digest。 构建 adapter 所需的源路径、localized 名称、输出目录和 default-product 标记保留在外围 build context,不扩展成通用 manifest。相同输入必须产生相同摘要;非默认产品使用 digest-scoped Cargo target 目录,避免复用其他产品的编译期身份。 @@ -71,7 +79,7 @@ i18n locale 集合和 key parity。 - 只修改默认产品定义或资源引用时运行 `pnpm run product:check`;非默认定义运行 `pnpm run product:check -- --product-config `,确保校验实际改动的产品; -- 修改 schema、resolver 或 Desktop/CLI build adapter 行为时,再运行 `pnpm run product:test`; +- 修改 schema、resolver 或 Desktop/Data Migrator/CLI build adapter 行为时,再运行 `pnpm run product:test`; - 打包和平台矩阵只在变更触及对应交付路径时运行,不作为产品定义的默认本地预检。 ## 1. 设计结论 diff --git a/docs/interactive-capabilities/README.md b/docs/interactive-capabilities/README.md index 09eac55a30..3bcdd2c880 100644 --- a/docs/interactive-capabilities/README.md +++ b/docs/interactive-capabilities/README.md @@ -1,9 +1,9 @@ # OpenBitFun 功能与设置目录 / OpenBitFun Features & Settings -OpenBitFun Playbook 当前包含 **22 个功能**和 **21 个设置页**,共 **43 个**用户可理解的条目、**321 项**有源码证据的子能力。每个条目有独立 Markdown,并直接服务于说明书网站、OpenBitFun 全局搜索和 `OpenBitFunControl` Agent 工具。 +OpenBitFun Playbook 当前包含 **22 个功能**和 **22 个设置页**,共 **44 个**用户可理解的条目、**326 项**有源码证据的子能力。每个条目有独立 Markdown,并直接服务于说明书网站、OpenBitFun 全局搜索和 `OpenBitFunControl` Agent 工具。 -OpenBitFun Playbook currently contains **22 features**, **21 settings pages**, and **321** source-backed sub-capabilities across **43** user-facing entries. Every entry has its own Markdown page and directly powers the website, in-app global search, and the `OpenBitFunControl` agent tool. +OpenBitFun Playbook currently contains **22 features**, **22 settings pages**, and **326** source-backed sub-capabilities across **44** user-facing entries. Every entry has its own Markdown page and directly powers the website, in-app global search, and the `OpenBitFunControl` agent tool. ## 唯一事实源 / Single source of truth @@ -27,20 +27,20 @@ OpenBitFun Playbook currently contains **22 features**, **21 settings pages**, a - Generated per-item interaction audit: `docs/interactive-capabilities/technical/product-control-open-audit.json` - Generated low-level audit map: `docs/interactive-capabilities/technical/tauri-command-map.json` -说明书、网站、搜索和 Agent 只看“功能 + 设置 + 子能力”。每项子能力都必须引用已注册 Tauri Command 或可解析的源码标记;这些证据不会进入公开目录。当前 **666** 个 Tauri 命令只用于实现覆盖审计。产品 UI 交互源码会在生成和检查时扫描并校验,但不会保存成随普通 UI 改动频繁变化的版本化快照。 +说明书、网站、搜索和 Agent 只看“功能 + 设置 + 子能力”。每项子能力都必须引用已注册 Tauri Command 或可解析的源码标记;这些证据不会进入公开目录。当前 **671** 个 Tauri 命令只用于实现覆盖审计。产品 UI 交互源码会在生成和检查时扫描并校验,但不会保存成随普通 UI 改动频繁变化的版本化快照。 -Docs, website, search, and agents see only features, settings, and documented sub-capabilities. Every sub-capability must reference a registered Tauri command or a resolvable source marker; evidence is stripped from public projections. The **666** Tauri commands remain implementation-audit evidence only. Product UI interaction sources are scanned and validated during generation and checks, but are not stored as a versioned snapshot that churns with ordinary UI changes. +Docs, website, search, and agents see only features, settings, and documented sub-capabilities. Every sub-capability must reference a registered Tauri command or a resolvable source marker; evidence is stripped from public projections. The **671** Tauri commands remain implementation-audit evidence only. Product UI interaction sources are scanned and validated during generation and checks, but are not stored as a versioned snapshot that churns with ordinary UI changes. ## 控制边界 / Control boundary -- 每个子能力都明确标记为直接控制、委托给专用 Agent 工具、需交互打开或不支持;“打开页面”不会再被统计成“Agent 已控制”。当前覆盖:直接 **48**、委托 **61**、需交互 **212**、不支持 **0**。 +- 每个子能力都明确标记为直接控制、委托给专用 Agent 工具、需交互打开或不支持;“打开页面”不会再被统计成“Agent 已控制”。当前覆盖:直接 **48**、委托 **61**、需交互 **217**、不支持 **0**。 - 稳定行为声明为带 JSON 输入契约的 `operations` 或 `options`,并绑定原生产品控制 Provider;Agent 不接触原始 Tauri Command。 -- `OpenBitFunControl list` 和 `search` 都返回带 `nextCursor` 的精简分页结果;目录可持续增长,不靠固定总量上限。完整目录和 321 项子能力都不会写入 system prompt。 +- `OpenBitFunControl list` 和 `search` 都返回带 `nextCursor` 的精简分页结果;目录可持续增长,不靠固定总量上限。完整目录和 326 项子能力都不会写入 system prompt。 - 目录发现与契约读取不依赖 React 或可见窗口。普通配置型 option 统一由 Product Assembly 的共享 ConfigService 执行器读、写并回读,因此 Desktop、CLI 与 Headless 表面走同一份实现;只有宿主原生 operation/provider option 和界面导航按表面注册适配器,缺失时必须明确返回不可用,禁止静默回退本机。只读 Agent 只能发现和读取目录。 -- Every documented item is classified as direct control, delegated Agent control, interactive opening, or unsupported; opening a page is never counted as direct control. Current coverage is **48 direct**, **61 delegated**, **212 interactive**, and **0 unsupported**. +- Every documented item is classified as direct control, delegated Agent control, interactive opening, or unsupported; opening a page is never counted as direct control. Current coverage is **48 direct**, **61 delegated**, **217 interactive**, and **0 unsupported**. - Stable behavior becomes a typed `operation` or `option` with a JSON input contract and a native product-control provider. Agents never receive raw Tauri commands. -- `OpenBitFunControl list` and `search` return compact pages with a `nextCursor`; the catalog can grow without a fixed total-size ceiling. Neither the full catalog nor its 321 documented items enters the system prompt. +- `OpenBitFunControl list` and `search` return compact pages with a `nextCursor`; the catalog can grow without a fixed total-size ceiling. Neither the full catalog nor its 326 documented items enters the system prompt. - Discovery and contract lookup do not depend on React or a visible window. Ordinary config-backed options are read, written, and read back by one Product Assembly ConfigService executor shared by Desktop, CLI, and headless surfaces. Only host-native operations/provider options and presentation routes install surface adapters; missing adapters return explicit unavailability without local fallback. Read-only agents may only discover and inspect entries. ## 防腐化门禁 / Anti-drift gates diff --git a/docs/interactive-capabilities/capabilities.json b/docs/interactive-capabilities/capabilities.json index a8c4149de3..653efc45a6 100644 --- a/docs/interactive-capabilities/capabilities.json +++ b/docs/interactive-capabilities/capabilities.json @@ -4,7 +4,7 @@ "title": "OpenBitFun Playbook", "origin": "https://playbook.openbitfun.com", "source": "src/shared/interactive-capabilities/catalog.json", - "digest": "d7a7419ddd673eb733ae8bd33dbb3dcd3b4d1ce0acfc953067a3403fe26d6699", + "digest": "c77c16c414ce9fe50929fe249d900489fdceedb8e646aaaca03893aa33d4fbc8", "ownerDigest": "c0e5c187cf62bc6ed06196ce8520b3eb427bf268cf24659b72d2552fb1d99c54", "searchAcceptance": [ { @@ -136,13 +136,13 @@ ], "counts": { "features": 22, - "settings": 21, - "userFacing": 43, - "documentedItems": 321, + "settings": 22, + "userFacing": 44, + "documentedItems": 326, "controlCoverage": { "direct": 48, "delegated": 61, - "interactive": 212, + "interactive": 217, "unsupported": 0 } }, @@ -18765,6 +18765,285 @@ "pageId": "data.archived" } }, + { + "id": "setting.data.migration:query", + "capabilityId": "setting.data.migration", + "itemIds": [ + "scan", + "scope", + "launch", + "report", + "reminder" + ], + "kind": "query", + "risk": "read", + "executionHost": "productHost", + "availability": { + "desktop": { + "available": true + }, + "cli": { + "available": true + }, + "peer": { + "available": true, + "requiredCapabilities": [ + "product_control_v1" + ] + }, + "remoteControl": { + "available": true + }, + "detachedDispatch": { + "available": true + } + }, + "inputSchema": { + "type": "object", + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "additionalProperties": true + }, + "valueSource": { + "kind": "static" + }, + "presentationTarget": { + "kind": "settings", + "pageId": "data.migration" + } + }, + { + "id": "setting.data.migration:open:scan", + "capabilityId": "setting.data.migration", + "itemIds": [ + "scan" + ], + "kind": "open", + "risk": "ui", + "executionHost": "presentationSurface", + "availability": { + "desktop": { + "available": true + }, + "cli": { + "available": false, + "reason": "This delivery profile has no live presentation surface" + }, + "peer": { + "available": true, + "requiredCapabilities": [ + "product_control_v1", + "product_control_presentation_v1" + ] + }, + "remoteControl": { + "available": true + }, + "detachedDispatch": { + "available": false, + "reason": "This delivery profile has no live presentation surface" + } + }, + "inputSchema": { + "type": "object", + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "additionalProperties": true + }, + "openReason": "unstructuredInteraction", + "presentationTarget": { + "kind": "settings", + "pageId": "data.migration" + } + }, + { + "id": "setting.data.migration:open:scope", + "capabilityId": "setting.data.migration", + "itemIds": [ + "scope" + ], + "kind": "open", + "risk": "ui", + "executionHost": "presentationSurface", + "availability": { + "desktop": { + "available": true + }, + "cli": { + "available": false, + "reason": "This delivery profile has no live presentation surface" + }, + "peer": { + "available": true, + "requiredCapabilities": [ + "product_control_v1", + "product_control_presentation_v1" + ] + }, + "remoteControl": { + "available": true + }, + "detachedDispatch": { + "available": false, + "reason": "This delivery profile has no live presentation surface" + } + }, + "inputSchema": { + "type": "object", + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "additionalProperties": true + }, + "openReason": "visualSelection", + "presentationTarget": { + "kind": "settings", + "pageId": "data.migration" + } + }, + { + "id": "setting.data.migration:open:launch", + "capabilityId": "setting.data.migration", + "itemIds": [ + "launch" + ], + "kind": "open", + "risk": "ui", + "executionHost": "presentationSurface", + "availability": { + "desktop": { + "available": true + }, + "cli": { + "available": false, + "reason": "This delivery profile has no live presentation surface" + }, + "peer": { + "available": true, + "requiredCapabilities": [ + "product_control_v1", + "product_control_presentation_v1" + ] + }, + "remoteControl": { + "available": true + }, + "detachedDispatch": { + "available": false, + "reason": "This delivery profile has no live presentation surface" + } + }, + "inputSchema": { + "type": "object", + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "additionalProperties": true + }, + "openReason": "unstructuredInteraction", + "presentationTarget": { + "kind": "settings", + "pageId": "data.migration" + } + }, + { + "id": "setting.data.migration:open:report", + "capabilityId": "setting.data.migration", + "itemIds": [ + "report" + ], + "kind": "open", + "risk": "ui", + "executionHost": "presentationSurface", + "availability": { + "desktop": { + "available": true + }, + "cli": { + "available": false, + "reason": "This delivery profile has no live presentation surface" + }, + "peer": { + "available": true, + "requiredCapabilities": [ + "product_control_v1", + "product_control_presentation_v1" + ] + }, + "remoteControl": { + "available": true + }, + "detachedDispatch": { + "available": false, + "reason": "This delivery profile has no live presentation surface" + } + }, + "inputSchema": { + "type": "object", + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "additionalProperties": true + }, + "openReason": "unstructuredInteraction", + "presentationTarget": { + "kind": "settings", + "pageId": "data.migration" + } + }, + { + "id": "setting.data.migration:open:reminder", + "capabilityId": "setting.data.migration", + "itemIds": [ + "reminder" + ], + "kind": "open", + "risk": "ui", + "executionHost": "presentationSurface", + "availability": { + "desktop": { + "available": true + }, + "cli": { + "available": false, + "reason": "This delivery profile has no live presentation surface" + }, + "peer": { + "available": true, + "requiredCapabilities": [ + "product_control_v1", + "product_control_presentation_v1" + ] + }, + "remoteControl": { + "available": true + }, + "detachedDispatch": { + "available": false, + "reason": "This delivery profile has no live presentation surface" + } + }, + "inputSchema": { + "type": "object", + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "additionalProperties": true + }, + "openReason": "unstructuredInteraction", + "presentationTarget": { + "kind": "settings", + "pageId": "data.migration" + } + }, { "id": "setting.data.diagnostics:query", "capabilityId": "setting.data.diagnostics", @@ -29565,6 +29844,156 @@ ], "docsUrl": "https://playbook.openbitfun.com/capabilities/setting.data.archived/" }, + { + "id": "setting.data.migration", + "kind": "setting", + "categoryId": "data", + "titleZh": "旧版数据迁移", + "titleEn": "Legacy data migration", + "summaryZh": "从本机旧版 安装扫描并导入受支持的数据,查看去敏报告,同时保持旧来源不变。", + "summaryEn": "Scan and import supported data from a local legacy installation, inspect redacted reports, and leave the legacy source unchanged.", + "keywordsZh": [ + "旧版数据迁移", + "旧版数据", + "迁移报告", + "导入旧数据", + "Data Migrator" + ], + "keywordsEn": [ + "legacy data migration", + "legacy data", + "migration report", + "import old data", + "Data Migrator" + ], + "highlightsZh": [ + "只读扫描本机旧版数据", + "按五个高层数据组选择迁移范围", + "通过独立 Data Migrator 导入并查看去敏报告" + ], + "highlightsEn": [ + "Read-only scan of local legacy data", + "Choose migration scope across five high-level data groups", + "Import through the standalone Data Migrator and inspect redacted reports" + ], + "items": [ + { + "id": "scan", + "titleZh": "只读扫描本机旧版数据来源及所选数据组", + "titleEn": "Read-only scan the local legacy source and selected data groups", + "control": { + "kind": "open", + "reasonCode": "unstructuredInteraction", + "reasonZh": "“只读扫描本机旧版数据来源及所选数据组”:扫描依赖当前设备上的旧版数据、实时范围选择和结果状态;Agent 会打开精确入口,并把选择与扫描保留在用户可见界面。", + "reasonEn": "Read-only scan the local legacy source and selected data groups: Scanning depends on legacy data on the current device, live scope selection, and result state; the Agent opens the exact entry and keeps selection and scanning visible to the user." + } + }, + { + "id": "scope", + "titleZh": "选择设置、扩展、会话、记忆和远程连接迁移范围", + "titleEn": "Choose settings, extensions, sessions, memory, and remote-connection migration scope", + "control": { + "kind": "open", + "reasonCode": "visualSelection", + "reasonZh": "迁移范围是影响本机持久数据的五组可见选择;Agent 会打开精确入口,由用户确认所需范围。", + "reasonEn": "Migration scope is a visible five-group selection affecting local persisted data; the Agent opens the exact entry so the user can confirm the intended scope." + } + }, + { + "id": "launch", + "titleZh": "确认关闭影响后启动独立 Data Migrator", + "titleEn": "Launch the standalone Data Migrator after confirming shutdown impact", + "control": { + "kind": "open", + "reasonCode": "unstructuredInteraction", + "reasonZh": "“确认关闭影响后启动独立 Data Migrator”:启动迁移器会停止正在运行的 Agent 和终端任务、关闭 Desktop,并交接到独立本机进程;Agent 只打开入口,确认和启动保留在用户可见界面。", + "reasonEn": "Launch the standalone Data Migrator after confirming shutdown impact: Launching the migrator can stop running agents and terminal tasks, close Desktop, and hand off to a separate local process; the Agent only opens the entry while confirmation and launch remain visible to the user." + } + }, + { + "id": "report", + "titleZh": "查看最近运行结果和各领域去敏状态", + "titleEn": "Inspect the latest run result and redacted per-domain status", + "control": { + "kind": "open", + "reasonCode": "unstructuredInteraction", + "reasonZh": "“查看最近运行结果和各领域去敏状态”:报告取决于本机最近一次迁移运行和各领域实时状态;Agent 会打开精确入口,并把报告查看与失败组重试保留在用户可见界面。", + "reasonEn": "Inspect the latest run result and redacted per-domain status: Reports depend on the most recent local migration run and live per-domain state; the Agent opens the exact entry and keeps report review and failed-group retry visible to the user." + } + }, + { + "id": "reminder", + "titleZh": "恢复已关闭的首次启动迁移提醒", + "titleEn": "Restore the first-start migration reminder after it was disabled", + "control": { + "kind": "open", + "reasonCode": "unstructuredInteraction", + "reasonZh": "“恢复已关闭的首次启动迁移提醒”:提醒偏好与当前本机旧数据来源绑定;Agent 会打开精确入口,由用户在可见界面决定是否恢复提醒。", + "reasonEn": "Restore the first-start migration reminder after it was disabled: The reminder preference is bound to the current local legacy source; the Agent opens the exact entry so the user can decide visibly whether to restore it." + } + } + ], + "stepsZh": [ + "打开设置", + "进入“数据 > 旧版数据迁移”", + "扫描来源、选择范围并在确认关闭影响后启动迁移器" + ], + "stepsEn": [ + "Open Settings", + "Go to Data > Legacy data migration", + "Scan the source, choose scope, and launch the migrator after confirming shutdown impact" + ], + "agentExamplesZh": [ + "打开旧版数据迁移", + "带我查看数据迁移报告" + ], + "agentExamplesEn": [ + "Open legacy data migration", + "Show me the data migration report" + ], + "destination": { + "kind": "settings", + "pageId": "data.migration" + }, + "operations": [], + "options": [], + "searchTerms": [ + "setting.data.migration", + "旧版数据迁移", + "Legacy data migration", + "数据与诊断", + "Data & diagnostics", + "旧版数据", + "迁移报告", + "导入旧数据", + "Data Migrator", + "legacy data migration", + "legacy data", + "migration report", + "import old data", + "只读扫描本机旧版数据", + "按五个高层数据组选择迁移范围", + "通过独立 Data Migrator 导入并查看去敏报告", + "Read-only scan of local legacy data", + "Choose migration scope across five high-level data groups", + "Import through the standalone Data Migrator and inspect redacted reports", + "只读扫描本机旧版数据来源及所选数据组", + "Read-only scan the local legacy source and selected data groups", + "选择设置、扩展、会话、记忆和远程连接迁移范围", + "Choose settings, extensions, sessions, memory, and remote-connection migration scope", + "确认关闭影响后启动独立 Data Migrator", + "Launch the standalone Data Migrator after confirming shutdown impact", + "查看最近运行结果和各领域去敏状态", + "Inspect the latest run result and redacted per-domain status", + "恢复已关闭的首次启动迁移提醒", + "Restore the first-start migration reminder after it was disabled", + "打开旧版数据迁移", + "带我查看数据迁移报告", + "Open legacy data migration", + "Show me the data migration report" + ], + "docsUrl": "https://playbook.openbitfun.com/capabilities/setting.data.migration/" + }, { "id": "setting.data.diagnostics", "kind": "setting", diff --git a/docs/interactive-capabilities/capabilities/setting.data.migration.md b/docs/interactive-capabilities/capabilities/setting.data.migration.md new file mode 100644 index 0000000000..a8039fde4c --- /dev/null +++ b/docs/interactive-capabilities/capabilities/setting.data.migration.md @@ -0,0 +1,63 @@ + +--- +id: setting.data.migration +kind: setting +category: data +title_zh: "旧版数据迁移" +title_en: "Legacy data migration" +--- + +# 旧版数据迁移 / Legacy data migration + +> 设置 / Setting + +从本机旧版 安装扫描并导入受支持的数据,查看去敏报告,同时保持旧来源不变。 + +Scan and import supported data from a local legacy installation, inspect redacted reports, and leave the legacy source unchanged. + +## 完整功能清单 / Everything included + +- **Agent 可定位入口,需交互完成 / Agent opens; interaction required** · 只读扫描本机旧版数据来源及所选数据组 + - Read-only scan the local legacy source and selected data groups +- **Agent 可定位入口,需交互完成 / Agent opens; interaction required** · 选择设置、扩展、会话、记忆和远程连接迁移范围 + - Choose settings, extensions, sessions, memory, and remote-connection migration scope +- **Agent 可定位入口,需交互完成 / Agent opens; interaction required** · 确认关闭影响后启动独立 Data Migrator + - Launch the standalone Data Migrator after confirming shutdown impact +- **Agent 可定位入口,需交互完成 / Agent opens; interaction required** · 查看最近运行结果和各领域去敏状态 + - Inspect the latest run result and redacted per-domain status +- **Agent 可定位入口,需交互完成 / Agent opens; interaction required** · 恢复已关闭的首次启动迁移提醒 + - Restore the first-start migration reminder after it was disabled + +## 怎么用 / How to use it + +1. 打开设置 + Open Settings +2. 进入“数据 > 旧版数据迁移” + Go to Data > Legacy data migration +3. 扫描来源、选择范围并在确认关闭影响后启动迁移器 + Scan the source, choose scope, and launch the migrator after confirming shutdown impact + +入口 / Entry: OpenBitFun 设置 + +## Agent 可替你做什么 / What an agent can do for you + +| 操作 / Action | 中文说明 | English description | +| --- | --- | --- | +| 打开对应界面 / Open the UI | 进入 OpenBitFun 中对应的功能界面。 | Open the matching feature in OpenBitFun. | + +## 可配置选项 / Configurable options + +| 选项 / Option | 可用值 / Values | 中文说明 | English description | +| --- | --- | --- | --- | +| 在界面中配置 / Configure in the UI | — | 此页面的设置在对应界面中完成。 | Configure this page in its matching UI. | + +## 可以直接对 Agent 说 / Try saying + +- “打开旧版数据迁移” + - “Open legacy data migration” +- “带我查看数据迁移报告” + - “Show me the data migration report” + +Agent 会先查找相关功能或设置,确认目标后再替你打开、执行或修改。完整能力目录不会预先塞进对话上下文。 + +The agent first finds the relevant feature or setting, confirms the target, and then opens, runs, or changes it for you. The full catalog is never embedded in the conversation context. diff --git a/docs/interactive-capabilities/technical/product-control-open-audit.json b/docs/interactive-capabilities/technical/product-control-open-audit.json index b2f434a198..1839909c20 100644 --- a/docs/interactive-capabilities/technical/product-control-open-audit.json +++ b/docs/interactive-capabilities/technical/product-control-open-audit.json @@ -1,13 +1,13 @@ { "schemaVersion": 1, "generatedFrom": "src/shared/interactive-capabilities/catalog.json", - "catalogDigest": "d7a7419ddd673eb733ae8bd33dbb3dcd3b4d1ce0acfc953067a3403fe26d6699", - "count": 212, + "catalogDigest": "c77c16c414ce9fe50929fe249d900489fdceedb8e646aaaca03893aa33d4fbc8", + "count": 217, "reasonCounts": { "externalAuth": 4, "secretEntry": 5, - "unstructuredInteraction": 185, - "visualSelection": 18 + "unstructuredInteraction": 189, + "visualSelection": 19 }, "entries": [ { @@ -3749,6 +3749,91 @@ "command:delete_all_archived_sessions" ] }, + { + "capabilityId": "setting.data.migration", + "itemId": "scan", + "titleZh": "只读扫描本机旧版数据来源及所选数据组", + "titleEn": "Read-only scan the local legacy source and selected data groups", + "reasonCode": "unstructuredInteraction", + "reasonZh": "“只读扫描本机旧版数据来源及所选数据组”:扫描依赖当前设备上的旧版数据、实时范围选择和结果状态;Agent 会打开精确入口,并把选择与扫描保留在用户可见界面。", + "reasonEn": "Read-only scan the local legacy source and selected data groups: Scanning depends on legacy data on the current device, live scope selection, and result state; the Agent opens the exact entry and keeps selection and scanning visible to the user.", + "presentationTarget": { + "kind": "settings", + "pageId": "data.migration" + }, + "evidence": [ + "command:get_legacy_migration_status", + "command:scan_legacy_migration", + "source:src/web-ui/src/locales/zh-CN/settings/legacy-migration.json#actions.scan" + ] + }, + { + "capabilityId": "setting.data.migration", + "itemId": "scope", + "titleZh": "选择设置、扩展、会话、记忆和远程连接迁移范围", + "titleEn": "Choose settings, extensions, sessions, memory, and remote-connection migration scope", + "reasonCode": "visualSelection", + "reasonZh": "迁移范围是影响本机持久数据的五组可见选择;Agent 会打开精确入口,由用户确认所需范围。", + "reasonEn": "Migration scope is a visible five-group selection affecting local persisted data; the Agent opens the exact entry so the user can confirm the intended scope.", + "presentationTarget": { + "kind": "settings", + "pageId": "data.migration" + }, + "evidence": [ + "source:src/web-ui/src/locales/zh-CN/settings/legacy-migration.json#sections.scope.title" + ] + }, + { + "capabilityId": "setting.data.migration", + "itemId": "launch", + "titleZh": "确认关闭影响后启动独立 Data Migrator", + "titleEn": "Launch the standalone Data Migrator after confirming shutdown impact", + "reasonCode": "unstructuredInteraction", + "reasonZh": "“确认关闭影响后启动独立 Data Migrator”:启动迁移器会停止正在运行的 Agent 和终端任务、关闭 Desktop,并交接到独立本机进程;Agent 只打开入口,确认和启动保留在用户可见界面。", + "reasonEn": "Launch the standalone Data Migrator after confirming shutdown impact: Launching the migrator can stop running agents and terminal tasks, close Desktop, and hand off to a separate local process; the Agent only opens the entry while confirmation and launch remain visible to the user.", + "presentationTarget": { + "kind": "settings", + "pageId": "data.migration" + }, + "evidence": [ + "command:prepare_legacy_migration", + "source:src/web-ui/src/locales/zh-CN/settings/legacy-migration.json#confirm.title" + ] + }, + { + "capabilityId": "setting.data.migration", + "itemId": "report", + "titleZh": "查看最近运行结果和各领域去敏状态", + "titleEn": "Inspect the latest run result and redacted per-domain status", + "reasonCode": "unstructuredInteraction", + "reasonZh": "“查看最近运行结果和各领域去敏状态”:报告取决于本机最近一次迁移运行和各领域实时状态;Agent 会打开精确入口,并把报告查看与失败组重试保留在用户可见界面。", + "reasonEn": "Inspect the latest run result and redacted per-domain status: Reports depend on the most recent local migration run and live per-domain state; the Agent opens the exact entry and keeps report review and failed-group retry visible to the user.", + "presentationTarget": { + "kind": "settings", + "pageId": "data.migration" + }, + "evidence": [ + "command:get_legacy_migration_report", + "source:src/web-ui/src/locales/zh-CN/settings/legacy-migration.json#sections.report.title" + ] + }, + { + "capabilityId": "setting.data.migration", + "itemId": "reminder", + "titleZh": "恢复已关闭的首次启动迁移提醒", + "titleEn": "Restore the first-start migration reminder after it was disabled", + "reasonCode": "unstructuredInteraction", + "reasonZh": "“恢复已关闭的首次启动迁移提醒”:提醒偏好与当前本机旧数据来源绑定;Agent 会打开精确入口,由用户在可见界面决定是否恢复提醒。", + "reasonEn": "Restore the first-start migration reminder after it was disabled: The reminder preference is bound to the current local legacy source; the Agent opens the exact entry so the user can decide visibly whether to restore it.", + "presentationTarget": { + "kind": "settings", + "pageId": "data.migration" + }, + "evidence": [ + "command:set_legacy_migration_prompt_preference", + "source:src/web-ui/src/locales/zh-CN/settings/legacy-migration.json#actions.restoreReminder" + ] + }, { "capabilityId": "setting.data.diagnostics", "itemId": "log-path", diff --git a/docs/interactive-capabilities/technical/tauri-command-map.json b/docs/interactive-capabilities/technical/tauri-command-map.json index 04b6121d30..c33322462f 100644 --- a/docs/interactive-capabilities/technical/tauri-command-map.json +++ b/docs/interactive-capabilities/technical/tauri-command-map.json @@ -1,11 +1,11 @@ { "schemaVersion": 2, "generatedFrom": "src/shared/interactive-capabilities/catalog.json", - "catalogDigest": "d7a7419ddd673eb733ae8bd33dbb3dcd3b4d1ce0acfc953067a3403fe26d6699", - "commandCount": 666, + "catalogDigest": "c77c16c414ce9fe50929fe249d900489fdceedb8e646aaaca03893aa33d4fbc8", + "commandCount": 671, "coverage": { - "commandCount": 666, - "documentedCommandCount": 633, + "commandCount": 671, + "documentedCommandCount": 638, "implementationCommandCount": 33, "implementationDigest": "35539d9c1510287cb47f4a68fe35859b78f93bb06cd66b86e58d878a48d8c509" }, @@ -3230,6 +3230,38 @@ "signature": "fn get_latest_insights() -> Result, String>", "remoteWorkspacePolicy": "LocalOnly" }, + { + "id": "get_legacy_migration_report", + "moduleId": "legacy_migration", + "capabilityId": "setting.data.migration", + "capabilityIds": [ + "setting.data.migration" + ], + "documentedItemIds": [ + "setting.data.migration:report" + ], + "visibility": "documented", + "rustPath": "api::legacy_migration_api::get_legacy_migration_report", + "sourceFile": "src/apps/desktop/src/api/legacy_migration_api.rs", + "signature": "fn get_legacy_migration_report( request: GetLegacyMigrationReportRequest, ) -> Result, LegacyMigrationCommandError>", + "remoteWorkspacePolicy": "LocalOnly" + }, + { + "id": "get_legacy_migration_status", + "moduleId": "legacy_migration", + "capabilityId": "setting.data.migration", + "capabilityIds": [ + "setting.data.migration" + ], + "documentedItemIds": [ + "setting.data.migration:scan" + ], + "visibility": "documented", + "rustPath": "api::legacy_migration_api::get_legacy_migration_status", + "sourceFile": "src/apps/desktop/src/api/legacy_migration_api.rs", + "signature": "fn get_legacy_migration_status( request: EmptyLegacyMigrationRequest, ) -> Result", + "remoteWorkspacePolicy": "LocalOnly" + }, { "id": "get_mcp_prompt", "moduleId": "mcp", @@ -6614,6 +6646,22 @@ "signature": "fn predownload_acp_client_adapter( state: State<'_, AppState>, request: AcpClientIdRequest, ) -> Result<(), String>", "remoteWorkspacePolicy": "LegacyUnaudited" }, + { + "id": "prepare_legacy_migration", + "moduleId": "legacy_migration", + "capabilityId": "setting.data.migration", + "capabilityIds": [ + "setting.data.migration" + ], + "documentedItemIds": [ + "setting.data.migration:launch" + ], + "visibility": "documented", + "rustPath": "api::legacy_migration_api::prepare_legacy_migration", + "sourceFile": "src/apps/desktop/src/api/legacy_migration_api.rs", + "signature": "fn prepare_legacy_migration( app: AppHandle, request: PrepareLegacyMigrationRequest, ) -> Result", + "remoteWorkspacePolicy": "LocalOnly" + }, { "id": "preview_commit_message", "moduleId": "git_agent", @@ -8369,6 +8417,22 @@ "signature": "fn save_web_search_credential( _state: State<'_, AppState>, request: openbitfun_core::service::web_search::SaveWebSearchCredentialRequest, ) -> Result", "remoteWorkspacePolicy": "WorkspaceAgnostic" }, + { + "id": "scan_legacy_migration", + "moduleId": "legacy_migration", + "capabilityId": "setting.data.migration", + "capabilityIds": [ + "setting.data.migration" + ], + "documentedItemIds": [ + "setting.data.migration:scan" + ], + "visibility": "documented", + "rustPath": "api::legacy_migration_api::scan_legacy_migration", + "sourceFile": "src/apps/desktop/src/api/legacy_migration_api.rs", + "signature": "fn scan_legacy_migration( request: ScanLegacyMigrationRequest, ) -> Result", + "remoteWorkspacePolicy": "LocalOnly" + }, { "id": "scan_workspace_info", "moduleId": "commands", @@ -8837,6 +8901,22 @@ "signature": "fn set_global_skill_disabled( request: SetGlobalSkillDisabledRequest, ) -> Result", "remoteWorkspacePolicy": "WorkspaceAgnostic" }, + { + "id": "set_legacy_migration_prompt_preference", + "moduleId": "legacy_migration", + "capabilityId": "setting.data.migration", + "capabilityIds": [ + "setting.data.migration" + ], + "documentedItemIds": [ + "setting.data.migration:reminder" + ], + "visibility": "documented", + "rustPath": "api::legacy_migration_api::set_legacy_migration_prompt_preference", + "sourceFile": "src/apps/desktop/src/api/legacy_migration_api.rs", + "signature": "fn set_legacy_migration_prompt_preference( request: SetLegacyMigrationPromptPreferenceRequest, ) -> Result", + "remoteWorkspacePolicy": "LocalOnly" + }, { "id": "set_macos_edit_menu_mode", "moduleId": "system", diff --git a/package.json b/package.json index 0e7048af6a..2748bf7658 100644 --- a/package.json +++ b/package.json @@ -114,7 +114,9 @@ "target:gc": "node scripts/cargo-target-gc.mjs", "product:check": "node scripts/product-customization/cli.mjs check", "product:explain": "node scripts/product-customization/cli.mjs explain", - "product:test": "node --test --test-concurrency=1 scripts/product-customization/*.test.mjs scripts/cli-product.test.mjs scripts/desktop-tauri-build.test.mjs", + "product:test": "node --test --test-concurrency=1 scripts/product-customization/*.test.mjs scripts/cli-product.test.mjs scripts/desktop-tauri-build.test.mjs scripts/data-migrator-tauri-build.test.mjs", + "data-migrator:check": "cargo check -p openbitfun-data-migrator", + "data-migrator:build": "node scripts/data-migrator-tauri-build.mjs", "desktop:build": "node scripts/desktop-tauri-build.mjs", "desktop:build:fast": "node scripts/desktop-tauri-build.mjs --debug --no-bundle", "desktop:build:release-fast": "node scripts/desktop-tauri-build.mjs --no-bundle -- --profile release-fast --features devtools", diff --git a/products/fixtures/acme/locales/en-US.json b/products/fixtures/acme/locales/en-US.json index a8d066b01c..fa286b519c 100644 --- a/products/fixtures/acme/locales/en-US.json +++ b/products/fixtures/acme/locales/en-US.json @@ -1,4 +1,5 @@ { "product.cli.name": "Acme CLI", + "product.dataMigrator.name": "Acme Data Migrator", "product.desktop.name": "Acme Desktop" } diff --git a/products/fixtures/acme/locales/zh-CN.json b/products/fixtures/acme/locales/zh-CN.json index 140f3111ef..ed492ddd53 100644 --- a/products/fixtures/acme/locales/zh-CN.json +++ b/products/fixtures/acme/locales/zh-CN.json @@ -1,4 +1,5 @@ { "product.cli.name": "Acme 命令行", + "product.dataMigrator.name": "Acme 数据迁移器", "product.desktop.name": "Acme 桌面版" } diff --git a/products/fixtures/acme/locales/zh-TW.json b/products/fixtures/acme/locales/zh-TW.json index 770e9abd60..a9e7eb4ac9 100644 --- a/products/fixtures/acme/locales/zh-TW.json +++ b/products/fixtures/acme/locales/zh-TW.json @@ -1,4 +1,5 @@ { "product.cli.name": "Acme 命令列", + "product.dataMigrator.name": "Acme 資料遷移器", "product.desktop.name": "Acme 桌面版" } diff --git a/products/fixtures/acme/product.jsonc b/products/fixtures/acme/product.jsonc index 2af9a0dfa4..6b2ab8af27 100644 --- a/products/fixtures/acme/product.jsonc +++ b/products/fixtures/acme/product.jsonc @@ -10,6 +10,11 @@ "binaryName": "acme-desktop", "bundleId": "com.acme.desktop" }, + "dataMigrator": { + "displayNameKey": "product.dataMigrator.name", + "binaryName": "acme-data-migrator", + "bundleId": "com.acme.data-migrator" + }, "cli": { "displayNameKey": "product.cli.name", "binaryName": "acme" diff --git a/products/openbitfun/locales/en-US.json b/products/openbitfun/locales/en-US.json index 92463c4799..e2ef5aa960 100644 --- a/products/openbitfun/locales/en-US.json +++ b/products/openbitfun/locales/en-US.json @@ -1,4 +1,5 @@ { "product.cli.name": "OpenBitFun CLI", + "product.dataMigrator.name": "OpenBitFun Data Migrator", "product.desktop.name": "OpenBitFun" } diff --git a/products/openbitfun/locales/zh-CN.json b/products/openbitfun/locales/zh-CN.json index 92463c4799..223b6b308e 100644 --- a/products/openbitfun/locales/zh-CN.json +++ b/products/openbitfun/locales/zh-CN.json @@ -1,4 +1,5 @@ { "product.cli.name": "OpenBitFun CLI", + "product.dataMigrator.name": "OpenBitFun 数据迁移器", "product.desktop.name": "OpenBitFun" } diff --git a/products/openbitfun/locales/zh-TW.json b/products/openbitfun/locales/zh-TW.json index 92463c4799..61b90a2bfb 100644 --- a/products/openbitfun/locales/zh-TW.json +++ b/products/openbitfun/locales/zh-TW.json @@ -1,4 +1,5 @@ { "product.cli.name": "OpenBitFun CLI", + "product.dataMigrator.name": "OpenBitFun 資料遷移器", "product.desktop.name": "OpenBitFun" } diff --git a/products/openbitfun/product.jsonc b/products/openbitfun/product.jsonc index b6f199e067..29c10f9d8a 100644 --- a/products/openbitfun/product.jsonc +++ b/products/openbitfun/product.jsonc @@ -10,6 +10,11 @@ "binaryName": "openbitfun-desktop", "bundleId": "com.openbitfun.desktop" }, + "dataMigrator": { + "displayNameKey": "product.dataMigrator.name", + "binaryName": "openbitfun-data-migrator", + "bundleId": "com.openbitfun.data-migrator" + }, "cli": { "displayNameKey": "product.cli.name", "binaryName": "openbitfun" diff --git a/products/schemas/product-definition.schema.json b/products/schemas/product-definition.schema.json index a5e530f87e..8ec650671e 100644 --- a/products/schemas/product-definition.schema.json +++ b/products/schemas/product-definition.schema.json @@ -14,9 +14,10 @@ "members": { "type": "object", "additionalProperties": false, - "required": ["desktop", "cli"], + "required": ["desktop", "dataMigrator", "cli"], "properties": { "desktop": { "$ref": "#/$defs/desktopMember" }, + "dataMigrator": { "$ref": "#/$defs/desktopMember" }, "cli": { "$ref": "#/$defs/commonMember" } } } diff --git a/scripts/check-core-boundaries.test.mjs b/scripts/check-core-boundaries.test.mjs index df0dd7086c..bdb2450a10 100644 --- a/scripts/check-core-boundaries.test.mjs +++ b/scripts/check-core-boundaries.test.mjs @@ -194,6 +194,7 @@ test('Agent Runtime leaf capabilities have one managed feature and source contra assert.deepEqual(Object.keys(rule.featureProfiles).sort(), [ 'agent-runtime', 'default', + 'definition-contracts', 'native-hook-runtime', 'native-hook-settings', ]); @@ -920,6 +921,11 @@ test('contract and AI adapter tests keep reviewed feature and failure-domain top path: 'tests/miniapp_contracts.rs', requiredFeatures: ['miniapp'], }, + { + name: 'legacy_migration_contracts', + path: 'tests/legacy_migration_contracts.rs', + requiredFeatures: ['legacy-migration'], + }, { name: 'plugin_source_contracts', path: 'tests/plugin_source_contracts.rs', @@ -3894,6 +3900,7 @@ test('services-core capability profiles keep heavy owners out of the empty profi 'tokio/time', 'windows/Win32_Foundation', 'windows/Win32_System_Diagnostics_ToolHelp', + 'windows/Win32_System_JobObjects', 'windows/Win32_System_Threading', ]); assert.deepEqual(profiles.get('workspace-instructions'), [ @@ -4149,6 +4156,7 @@ test('Core Tokio capabilities cannot hide behind an unreviewed owner feature', ( ], features: { 'agent-runtime': ['tokio/io-util', 'tokio/macros', 'tokio/rt', 'tokio/time'], + 'legacy-migration': ['tokio/rt'], 'mcp-runtime': ['agent-runtime', 'tokio/rt-multi-thread'], 'browser-control': ['tokio/net', 'tokio/rt', 'tokio/time'], sneaky: ['agent-runtime', 'browser-control'], @@ -4170,6 +4178,7 @@ test('reviewed Tokio aggregates cannot declare runtime capabilities directly', ( dependencies: [{ name: 'tokio', kind: null, optional: false, features: ['fs', 'sync'] }], features: { 'agent-runtime': ['tokio/io-util', 'tokio/macros', 'tokio/rt', 'tokio/time'], + 'legacy-migration': ['tokio/rt'], 'mcp-runtime': ['agent-runtime', 'tokio/rt-multi-thread'], 'browser-control': ['tokio/net', 'tokio/rt', 'tokio/time'], 'product-full': ['agent-runtime', 'tokio/net'], diff --git a/scripts/core-boundaries/cargo-dependency-boundaries.mjs b/scripts/core-boundaries/cargo-dependency-boundaries.mjs index 41509024c0..57e9fdebb0 100644 --- a/scripts/core-boundaries/cargo-dependency-boundaries.mjs +++ b/scripts/core-boundaries/cargo-dependency-boundaries.mjs @@ -141,6 +141,7 @@ const SERVICES_INTEGRATIONS_TOKIO_FEATURES = new Map([ ['file-watch', ['rt', 'sync']], ['function-agents', ['fs', 'io-util', 'macros', 'rt', 'time']], ['mcp', ['fs', 'io-util', 'net', 'process', 'rt', 'sync', 'time']], + ['miniapp-storage', ['fs', 'time']], ['miniapp-runtime', ['fs', 'io-util', 'net', 'process', 'rt', 'sync', 'time']], ['miniapp-market', ['fs', 'io-util', 'net', 'process', 'rt', 'sync', 'time']], ['plugin-source', ['fs', 'rt', 'sync', 'time']], @@ -173,6 +174,7 @@ const SERVICES_INTEGRATIONS_TOKIO_AGGREGATES = new Set(['product-full']); const SERVICES_CORE_TOKIO_AGGREGATES = new Set(['session-git', 'token-usage-statistics']); const CORE_TOKIO_FEATURES = new Map([ ['agent-runtime', ['io-util', 'macros', 'rt', 'time']], + ['legacy-migration', ['rt']], ['mcp-runtime', ['io-util', 'macros', 'rt', 'rt-multi-thread', 'time']], ['browser-control', ['net', 'rt', 'time']], ]); diff --git a/scripts/core-boundaries/explicit-test-topology.mjs b/scripts/core-boundaries/explicit-test-topology.mjs index 50e4e2cc54..d3026092bb 100644 --- a/scripts/core-boundaries/explicit-test-topology.mjs +++ b/scripts/core-boundaries/explicit-test-topology.mjs @@ -201,6 +201,11 @@ export const productDomainsIntegrationTestTargets = [ path: 'tests/miniapp_contracts.rs', requiredFeatures: ['miniapp'], }, + { + name: 'legacy_migration_contracts', + path: 'tests/legacy_migration_contracts.rs', + requiredFeatures: ['legacy-migration'], + }, { name: 'plugin_source_contracts', path: 'tests/plugin_source_contracts.rs', diff --git a/scripts/core-boundaries/rules/crate-layout.mjs b/scripts/core-boundaries/rules/crate-layout.mjs index b3fd8d30ae..1f550325e3 100644 --- a/scripts/core-boundaries/rules/crate-layout.mjs +++ b/scripts/core-boundaries/rules/crate-layout.mjs @@ -23,6 +23,7 @@ export const crateLayoutRules = [ { crateName: 'services-core', layer: 'services', path: 'src/crates/services/services-core' }, { crateName: 'services-integrations', layer: 'services', path: 'src/crates/services/services-integrations' }, + { crateName: 'legacy-migration', layer: 'services', path: 'src/crates/services/legacy-migration' }, { crateName: 'miniapp-market-service', layer: 'services', path: 'src/crates/services/miniapp-market-service' }, { crateName: 'skin-market-service', layer: 'services', path: 'src/crates/services/skin-market-service' }, { crateName: 'relay-service', layer: 'services', path: 'src/crates/services/relay-service' }, diff --git a/scripts/core-boundaries/rules/feature-rules.mjs b/scripts/core-boundaries/rules/feature-rules.mjs index bd9ae23f97..c576e7ead8 100644 --- a/scripts/core-boundaries/rules/feature-rules.mjs +++ b/scripts/core-boundaries/rules/feature-rules.mjs @@ -70,7 +70,7 @@ export const optionalDependencyFeatureOwnerRules = [ 'workspace-instructions', ], }, - { depName: 'rusqlite', ownerFeatures: ['permission', 'session-search'] }, + { depName: 'rusqlite', ownerFeatures: ['memory-store', 'permission', 'session-search'] }, { depName: 'rustls', ownerFeatures: ['tls-provider'] }, { depName: 'serde_yaml', ownerFeatures: ['markdown', 'workspace-instructions'] }, { depName: 'similar', ownerFeatures: ['diff', 'local-storage'] }, @@ -128,19 +128,19 @@ export const optionalDependencyFeatureOwnerRules = [ { depName: 'async-trait', ownerFeatures: ['agent-runtime', 'native-hook-runtime'] }, { depName: 'openbitfun-agent-stream', ownerFeatures: ['agent-runtime'] }, { depName: 'openbitfun-agent-tools', ownerFeatures: ['agent-runtime'] }, - { depName: 'openbitfun-core-types', ownerFeatures: ['agent-runtime'] }, + { depName: 'openbitfun-core-types', ownerFeatures: ['agent-runtime', 'definition-contracts'] }, { depName: 'openbitfun-events', ownerFeatures: ['agent-runtime'] }, { depName: 'openbitfun-runtime-ports', ownerFeatures: ['agent-runtime'] }, { depName: 'openbitfun-runtime-services', ownerFeatures: ['agent-runtime'] }, { depName: 'dashmap', ownerFeatures: ['agent-runtime'] }, { depName: 'hex', ownerFeatures: ['agent-runtime'] }, { depName: 'log', ownerFeatures: ['agent-runtime', 'native-hook-runtime'] }, - { depName: 'regex', ownerFeatures: ['agent-runtime', 'native-hook-settings'] }, - { depName: 'serde', ownerFeatures: ['agent-runtime', 'native-hook-runtime'] }, + { depName: 'regex', ownerFeatures: ['agent-runtime', 'definition-contracts', 'native-hook-settings'] }, + { depName: 'serde', ownerFeatures: ['agent-runtime', 'definition-contracts', 'native-hook-runtime'] }, { depName: 'serde_json', ownerFeatures: ['agent-runtime', 'native-hook-runtime', 'native-hook-settings'] }, - { depName: 'serde_yaml', ownerFeatures: ['agent-runtime'] }, + { depName: 'serde_yaml', ownerFeatures: ['agent-runtime', 'definition-contracts'] }, { depName: 'sha2', ownerFeatures: ['agent-runtime'] }, - { depName: 'thiserror', ownerFeatures: ['agent-runtime', 'native-hook-runtime'] }, + { depName: 'thiserror', ownerFeatures: ['agent-runtime', 'definition-contracts', 'native-hook-runtime'] }, { depName: 'tokio', ownerFeatures: ['agent-runtime', 'native-hook-runtime'] }, { depName: 'tokio-util', ownerFeatures: ['agent-runtime'] }, { depName: 'uuid', ownerFeatures: ['agent-runtime'] }, @@ -157,13 +157,14 @@ export const optionalDependencyFeatureOwnerRules = [ depName: 'openbitfun-ai-adapters', ownerFeatures: ['ai-adapter-runtime', 'subscription-auth'], }, - { depName: 'openbitfun-agent-runtime', ownerFeatures: ['agent-runtime'] }, + { depName: 'openbitfun-agent-runtime', ownerFeatures: ['agent-runtime', 'legacy-migration'] }, { depName: 'openbitfun-agent-workflows', ownerFeatures: ['deep-research'] }, { depName: 'openbitfun-agent-stream', ownerFeatures: ['agent-runtime'] }, { depName: 'openbitfun-agent-tools', ownerFeatures: ['agent-runtime', 'local-storage', 'mcp-runtime'] }, { depName: 'openbitfun-claude-code-adapter', ownerFeatures: ['external-sources'] }, { depName: 'openbitfun-codex-adapter', ownerFeatures: ['external-sources'] }, { depName: 'openbitfun-external-sources', ownerFeatures: ['external-sources'] }, + { depName: 'openbitfun-legacy-migration', ownerFeatures: ['legacy-migration'] }, { depName: 'openbitfun-opencode-adapter', ownerFeatures: ['external-sources'] }, { depName: 'openbitfun-dsh-adapter', ownerFeatures: ['external-sources'] }, { depName: 'openbitfun-plugin-runtime-client', ownerFeatures: ['plugin-runtime'] }, @@ -174,6 +175,7 @@ export const optionalDependencyFeatureOwnerRules = [ 'agent-runtime', 'canvas-runtime', 'function-agents', + 'legacy-migration', 'plugin-source', 'product-search', 'tools-miniapp', @@ -191,6 +193,7 @@ export const optionalDependencyFeatureOwnerRules = [ 'external-sources', 'file-watch', 'function-agents', + 'legacy-migration', 'git', 'mcp-runtime', 'model-catalog', @@ -236,7 +239,7 @@ export const optionalDependencyFeatureOwnerRules = [ { depName: 'md5', ownerFeatures: ['agent-runtime'] }, { depName: 'reqwest', ownerFeatures: ['mcp-runtime', 'tools-miniapp'] }, { depName: 'regex', ownerFeatures: ['agent-runtime'] }, - { depName: 'rusqlite', ownerFeatures: ['agent-runtime'] }, + { depName: 'rusqlite', ownerFeatures: ['agent-runtime', 'legacy-migration'] }, { depName: 'semver', ownerFeatures: ['tools-miniapp'] }, { depName: 'serde_yaml', ownerFeatures: ['workspace-runtime'] }, { depName: 'similar', ownerFeatures: ['agent-runtime'] }, @@ -258,15 +261,15 @@ export const optionalDependencyFeatureOwnerRules = [ reviewedAggregateFeatures: ['speech-realtime'], dependencies: [ { depName: 'aes', ownerFeatures: ['remote-connect'] }, - { depName: 'aes-gcm', ownerFeatures: ['mcp', 'remote-connect', 'remote-ssh-concrete'] }, - { depName: 'anyhow', ownerFeatures: ['browser-control', 'deep-research', 'mcp', 'remote-connect', 'remote-ssh', 'remote-ssh-concrete'] }, + { depName: 'aes-gcm', ownerFeatures: ['mcp', 'remote-connect', 'remote-persistence', 'remote-ssh-concrete'] }, + { depName: 'anyhow', ownerFeatures: ['browser-control', 'deep-research', 'mcp', 'remote-connect', 'remote-persistence', 'remote-ssh', 'remote-ssh-concrete'] }, { depName: 'async-trait', ownerFeatures: ['deep-research', 'git', 'mcp', 'remote-connect', 'remote-ssh', 'remote-ssh-concrete', 'review-platform', 'script-tool-runtime', 'speech', 'web-tools', 'workspace-search'], }, { depName: 'base64', - ownerFeatures: ['mcp', 'miniapp-runtime', 'remote-connect', 'remote-ssh-concrete', 'speech'], + ownerFeatures: ['mcp', 'miniapp-runtime', 'remote-connect', 'remote-persistence', 'remote-ssh-concrete', 'speech'], }, { depName: 'openbitfun-agent-runtime', ownerFeatures: ['hook-import'] }, { depName: 'openbitfun-agent-workflows', ownerFeatures: ['deep-research'] }, @@ -274,7 +277,7 @@ export const optionalDependencyFeatureOwnerRules = [ depName: 'openbitfun-core-types', ownerFeatures: ['deep-research', 'remote-connect', 'speech'], }, - { depName: 'openbitfun-product-domains', ownerFeatures: ['canvas-runtime', 'function-agents', 'hook-import', 'miniapp-market', 'miniapp-runtime', 'plugin-source'] }, + { depName: 'openbitfun-product-domains', ownerFeatures: ['canvas-runtime', 'function-agents', 'hook-import', 'miniapp-market', 'miniapp-runtime', 'miniapp-storage', 'plugin-source'] }, { depName: 'openbitfun-runtime-ports', ownerFeatures: ['deep-research', 'git', 'remote-connect', 'remote-ssh', 'remote-ssh-concrete', 'script-tool-runtime', 'web-tools'] }, { depName: 'openbitfun-services-core', @@ -289,6 +292,7 @@ export const optionalDependencyFeatureOwnerRules = [ 'models-dev', 'process-tree', 'remote-connect', + 'remote-persistence', 'remote-ssh', 'remote-ssh-concrete', 'review-platform', @@ -306,7 +310,7 @@ export const optionalDependencyFeatureOwnerRules = [ { depName: 'futures-util', ownerFeatures: ['speech', 'web-tools'] }, { depName: 'git2', ownerFeatures: ['git'] }, { depName: 'hex', ownerFeatures: ['hook-import', 'mcp', 'miniapp-market', 'plugin-source', 'remote-connect'] }, - { depName: 'hostname', ownerFeatures: ['remote-connect'] }, + { depName: 'hostname', ownerFeatures: ['remote-connect', 'remote-persistence'] }, { depName: 'image', ownerFeatures: ['miniapp-market', 'remote-connect'] }, { depName: 'local-ip-address', ownerFeatures: ['remote-connect'] }, { depName: 'libc', ownerFeatures: ['plugin-source'] }, @@ -315,7 +319,7 @@ export const optionalDependencyFeatureOwnerRules = [ { depName: 'notify', ownerFeatures: ['file-watch'] }, { depName: 'oxc', ownerFeatures: ['canvas-runtime'] }, { depName: 'qrcode', ownerFeatures: ['remote-connect'] }, - { depName: 'rand', ownerFeatures: ['mcp', 'remote-connect', 'remote-ssh-concrete'] }, + { depName: 'rand', ownerFeatures: ['mcp', 'remote-connect', 'remote-persistence', 'remote-ssh-concrete'] }, // remote-ssh-concrete: one-click relay deploy fetches the signed release // checksum over HTTPS and verifies it on this device, because the target // server has no minisign and no trust root of its own. @@ -327,7 +331,7 @@ export const optionalDependencyFeatureOwnerRules = [ { depName: 'rustls', ownerFeatures: ['remote-connect'] }, { depName: 'rustls-native-certs', ownerFeatures: ['remote-connect'] }, { depName: 'schannel', ownerFeatures: ['remote-connect'] }, - { depName: 'sha2', ownerFeatures: ['canvas-runtime', 'hook-import', 'mcp', 'miniapp-market', 'models-dev', 'plugin-source', 'remote-connect', 'remote-ssh', 'review-platform', 'speech'] }, + { depName: 'sha2', ownerFeatures: ['canvas-runtime', 'hook-import', 'mcp', 'miniapp-market', 'models-dev', 'plugin-source', 'remote-connect', 'remote-persistence', 'remote-ssh', 'review-platform', 'speech'] }, { depName: 'sherpa-onnx', ownerFeatures: ['speech'] }, { depName: 'shellexpand', ownerFeatures: ['remote-ssh-concrete'] }, { depName: 'sse-stream', ownerFeatures: ['mcp'] }, @@ -338,9 +342,9 @@ export const optionalDependencyFeatureOwnerRules = [ { depName: 'tokio-tungstenite', ownerFeatures: ['remote-connect', 'speech-realtime'] }, { depName: 'tokio-util', ownerFeatures: ['remote-ssh', 'speech'] }, { depName: 'urlencoding', ownerFeatures: ['canvas-runtime', 'miniapp-market', 'remote-connect', 'review-platform'] }, - { depName: 'uuid', ownerFeatures: ['canvas-runtime', 'hook-import', 'miniapp-runtime', 'plugin-source', 'remote-connect', 'remote-ssh-concrete', 'speech'] }, + { depName: 'uuid', ownerFeatures: ['canvas-runtime', 'hook-import', 'miniapp-runtime', 'miniapp-storage', 'plugin-source', 'remote-connect', 'remote-ssh-concrete', 'speech'] }, { depName: 'which', ownerFeatures: ['miniapp-runtime', 'remote-connect', 'script-tool-runtime', 'workspace-search'] }, - { depName: 'windows', ownerFeatures: ['models-dev', 'plugin-source', 'review-platform'] }, + { depName: 'windows', ownerFeatures: ['models-dev', 'plugin-source', 'remote-connect', 'remote-persistence', 'remote-ssh-concrete', 'review-platform'] }, { depName: 'x25519-dalek', ownerFeatures: ['remote-connect'] }, ], }, @@ -585,6 +589,13 @@ export const capabilityContractDependencyRules = [ manifestPath: 'src/crates/execution/agent-runtime/Cargo.toml', featureProfiles: { default: [], + 'definition-contracts': [ + 'dep:openbitfun-core-types', + 'dep:regex', + 'dep:serde', + 'dep:serde_yaml', + 'dep:thiserror', + ], 'native-hook-settings': ['dep:regex', 'dep:serde_json'], 'native-hook-runtime': [ 'native-hook-settings', @@ -601,6 +612,7 @@ export const capabilityContractDependencyRules = [ 'tokio/time', ], 'agent-runtime': [ + 'definition-contracts', 'native-hook-runtime', 'dep:async-trait', 'dep:openbitfun-agent-stream', @@ -643,8 +655,9 @@ export const capabilityContractDependencyRules = [ [capabilityEdge([], { optional: true })], [ capabilityForwarder('agent-runtime', 'agent-runtime'), + capabilityForwarder('legacy-migration', 'definition-contracts'), ], - ['agent-runtime'], + ['agent-runtime', 'legacy-migration'], ['external-sources', 'mcp-runtime', 'opencode-plugin-host', 'plugin-runtime', 'product-search', 'product-full', 'remote-connect', 'tools-mcp'], )], ['openbitfun-desktop', capabilityConsumer([ @@ -869,6 +882,7 @@ export const coreClosedFeatureProfileRules = [ 'dep:tool-runtime', // Complete ExecCommand constraint syntax facts live in the tool owner. 'tool-runtime/shell-analysis', + 'openbitfun-services-core/memory-store', 'openbitfun-services-core/permission', 'openbitfun-services-core/runtime-ownership', 'openbitfun-services-core/session-git', @@ -1478,6 +1492,13 @@ export const coreClosedFeatureProfileRules = [ exact: true, reason: 'services-core local-storage must own durable JSON, session, usage, and cleanup primitives', }, + { + manifestPath: 'src/crates/services/services-core/Cargo.toml', + featureName: 'memory-store', + requiredFeatureRefs: ['dep:rusqlite'], + exact: true, + reason: 'services-core memory-store must own only the durable Memory SQLite format', + }, { manifestPath: 'src/crates/services/services-core/Cargo.toml', featureName: 'token-usage-statistics', @@ -1500,6 +1521,7 @@ export const coreClosedFeatureProfileRules = [ 'tokio/time', 'windows/Win32_Foundation', 'windows/Win32_System_Diagnostics_ToolHelp', + 'windows/Win32_System_JobObjects', 'windows/Win32_System_Threading', ], exact: true, @@ -1743,7 +1765,14 @@ export const ownerCrateFeatureAssemblyRules = [ { manifestPath: 'src/crates/contracts/product-domains/Cargo.toml', reason: 'product-domains must keep product domain feature groups explicit and default-light', - requiredProductFullFeatures: ['appearance-market', 'plugin-source', 'miniapp', 'function-agents', 'external-sources'], + requiredProductFullFeatures: [ + 'appearance-market', + 'plugin-source', + 'miniapp', + 'function-agents', + 'external-sources', + 'legacy-migration', + ], }, ]; diff --git a/scripts/core-boundaries/rules/source/forbidden-rules.mjs b/scripts/core-boundaries/rules/source/forbidden-rules.mjs index 3c62363b7b..c005cb7764 100644 --- a/scripts/core-boundaries/rules/source/forbidden-rules.mjs +++ b/scripts/core-boundaries/rules/source/forbidden-rules.mjs @@ -3,7 +3,7 @@ import { agentRuntimeRootPublicModules } from './public-api-rules.mjs'; const agentRuntimeRootUnexpectedLine = new RegExp( - `^(?!(?:[ \\t]*|[ \\t]*\\/\\/!.*|[ \\t]*#\\[cfg\\(feature = "(?:agent-runtime|deep-research|native-hook-settings)"\\)\\][ \\t]*|[ \\t]*pub mod (?:${agentRuntimeRootPublicModules.join('|')});[ \\t]*)\\r?$).+$`, + `^(?!(?:[ \\t]*|[ \\t]*\\/\\/!.*|[ \\t]*#\\[cfg\\(feature = "(?:agent-runtime|deep-research|native-hook-settings)"\\)\\][ \\t]*|[ \\t]*#\\[cfg\\(any\\(feature = "agent-runtime", feature = "definition-contracts"\\)\\)\\][ \\t]*|[ \\t]*pub mod (?:${agentRuntimeRootPublicModules.join('|')});[ \\t]*)\\r?$).+$`, 'm', ); diff --git a/scripts/core-boundaries/rules/source/required-rules.mjs b/scripts/core-boundaries/rules/source/required-rules.mjs index 18d641a4e8..dfa82f0157 100644 --- a/scripts/core-boundaries/rules/source/required-rules.mjs +++ b/scripts/core-boundaries/rules/source/required-rules.mjs @@ -2,6 +2,8 @@ import { agentRuntimeRootPublicModules } from './public-api-rules.mjs'; +const agentRuntimeDefinitionContractModules = new Set(['custom_agent', 'prompt', 'skills']); + export const requiredContentRules = [ { path: 'src/web-ui/src/infrastructure/api/service-api/ExternalSourcesAPI.ts', @@ -5156,8 +5158,14 @@ export const requiredContentRules = [ ...agentRuntimeRootPublicModules .filter((moduleName) => moduleName !== 'native_hooks') .map((moduleName) => ({ - regex: new RegExp(`#\\[cfg\\(feature = "agent-runtime"\\)\\]\\r?\\npub mod ${moduleName};`), - message: `${moduleName} must stay behind the full agent-runtime owner`, + regex: new RegExp( + agentRuntimeDefinitionContractModules.has(moduleName) + ? `#\\[cfg\\(any\\(feature = "agent-runtime", feature = "definition-contracts"\\)\\)\\]\\r?\\npub mod ${moduleName};` + : `#\\[cfg\\(feature = "agent-runtime"\\)\\]\\r?\\npub mod ${moduleName};`, + ), + message: agentRuntimeDefinitionContractModules.has(moduleName) + ? `${moduleName} must stay behind the full runtime or definition-contracts owner` + : `${moduleName} must stay behind the full agent-runtime owner`, })), ], }, diff --git a/scripts/core-boundaries/self-test.mjs b/scripts/core-boundaries/self-test.mjs index 544866c589..0667091eae 100644 --- a/scripts/core-boundaries/self-test.mjs +++ b/scripts/core-boundaries/self-test.mjs @@ -434,6 +434,7 @@ export function runManifestParserSelfTest({ ], ], [servicesCoreManifest, 'product-identity', ['dep:openbitfun-core-types']], + [servicesCoreManifest, 'memory-store', ['dep:rusqlite']], [ servicesCoreManifest, 'local-storage', @@ -471,6 +472,7 @@ export function runManifestParserSelfTest({ 'tokio/time', 'windows/Win32_Foundation', 'windows/Win32_System_Diagnostics_ToolHelp', + 'windows/Win32_System_JobObjects', 'windows/Win32_System_Threading', ], ], @@ -1160,7 +1162,7 @@ export function runManifestParserSelfTest({ 'regex', ['diagnostics', 'filesystem', 'local-storage', 'markdown', 'workspace-instructions'], ], - ['rusqlite', ['permission']], + ['rusqlite', ['memory-store', 'permission']], ['serde_yaml', ['markdown', 'workspace-instructions']], ['similar', ['diff', 'local-storage']], [ @@ -1259,6 +1261,14 @@ export function runManifestParserSelfTest({ throw new Error(`services-integrations plugin-source must own optional dependency ${dep}`); } } + for (const dep of ['aes-gcm', 'anyhow', 'base64', 'hostname', 'openbitfun-services-core', 'rand', 'sha2', 'windows']) { + const owner = servicesOptionalOwnerRule?.dependencies.find( + (dependency) => dependency.depName === dep, + ); + if (!owner?.ownerFeatures.includes('remote-persistence')) { + throw new Error(`services-integrations remote-persistence must own optional dependency ${dep}`); + } + } for (const dep of ['openbitfun-product-domains', 'image']) { const owner = servicesOptionalOwnerRule?.dependencies.find( (dependency) => dependency.depName === dep, diff --git a/scripts/data-migrator-tauri-build.mjs b/scripts/data-migrator-tauri-build.mjs new file mode 100644 index 0000000000..e52219ac7f --- /dev/null +++ b/scripts/data-migrator-tauri-build.mjs @@ -0,0 +1,87 @@ +#!/usr/bin/env node +/** Builds the standalone, non-updating Data Migrator Tauri bundle. */ +import { spawnSync } from 'node:child_process'; +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { extractProductConfigArg } from './product-customization/cli.mjs'; +import { productBuildEnvironment } from './product-customization/projections.mjs'; +import { resolveProductDefinition } from './product-customization/resolver.mjs'; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); +const APP_DIR = join(ROOT, 'src', 'apps', 'data-migrator'); + +export function prepareDataMigratorTauriConfig( + baseConfigPath, + resolution, + outputDirectory = join(APP_DIR, 'gen'), +) { + if (resolution.assembly.member !== 'dataMigrator') { + throw new Error('Data Migrator packaging requires the dataMigrator product member.'); + } + const config = JSON.parse(readFileSync(baseConfigPath, 'utf8')); + const productName = resolution.productNames[resolution.assembly.fallbackLocale] + ?? resolution.productNames[resolution.assembly.defaultLocale]; + config.productName = productName; + config.mainBinaryName = resolution.assembly.binaryName; + config.identifier = resolution.assembly.bundleId; + config.build = { + frontendDist: config.build?.frontendDist || 'ui', + }; + if (config.plugins) { + delete config.plugins.updater; + if (Object.keys(config.plugins).length === 0) delete config.plugins; + } + if (config.bundle) delete config.bundle.createUpdaterArtifacts; + + mkdirSync(outputDirectory, { recursive: true }); + const output = join( + outputDirectory, + `tauri.${resolution.assembly.assemblyDigest}.generated.conf.json`, + ); + writeFileSync(output, `${JSON.stringify(config, null, 2)}\n`, 'utf8'); + return output; +} + +function tauriArguments(raw) { + let offset = 0; + while (raw[offset] === '--') offset += 1; + return raw.slice(offset); +} + +async function main() { + const { productConfig, forwardArgs } = extractProductConfigArg( + tauriArguments(process.argv.slice(2)), + ); + const resolution = resolveProductDefinition({ + rootDir: ROOT, + productConfig, + member: 'dataMigrator', + }); + Object.assign(process.env, productBuildEnvironment(resolution)); + process.env.CI = 'true'; + const generated = prepareDataMigratorTauriConfig( + join(APP_DIR, 'tauri.conf.json'), + resolution, + ); + console.log(`[product] dataMigrator ${resolution.assembly.assemblyDigest}`); + + const tauriBin = join(ROOT, 'node_modules', '.bin', 'tauri'); + const result = spawnSync(tauriBin, ['build', '--config', generated, ...forwardArgs], { + cwd: APP_DIR, + env: process.env, + stdio: 'inherit', + shell: true, + windowsHide: true, + }); + if (result.error) throw result.error; + process.exit(result.status ?? 1); +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + main().catch((error) => { + console.error(error?.stack || error); + process.exit(1); + }); +} diff --git a/scripts/data-migrator-tauri-build.test.mjs b/scripts/data-migrator-tauri-build.test.mjs new file mode 100644 index 0000000000..8d2cad1615 --- /dev/null +++ b/scripts/data-migrator-tauri-build.test.mjs @@ -0,0 +1,92 @@ +import assert from 'node:assert/strict'; +import { mkdtempSync, readFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import test from 'node:test'; + +import { prepareDataMigratorTauriConfig } from './data-migrator-tauri-build.mjs'; +import { productBuildEnvironment } from './product-customization/projections.mjs'; +import { resolveProductDefinition } from './product-customization/resolver.mjs'; + +const ROOT = resolve(import.meta.dirname, '..'); +const APP = join(ROOT, 'src', 'apps', 'data-migrator'); +const ACME = join(ROOT, 'products', 'fixtures', 'acme', 'product.jsonc'); + +test('Data Migrator has an independent product and non-updating bundle identity', () => { + const resolution = resolveProductDefinition({ + rootDir: ROOT, + productConfig: ACME, + member: 'dataMigrator', + }); + const output = prepareDataMigratorTauriConfig( + join(APP, 'tauri.conf.json'), + resolution, + mkdtempSync(join(tmpdir(), 'openbitfun-data-migrator-config-')), + ); + const config = JSON.parse(readFileSync(output, 'utf8')); + + assert.equal(config.productName, 'Acme Data Migrator'); + assert.equal(config.mainBinaryName, 'acme-data-migrator'); + assert.equal(config.identifier, 'com.acme.data-migrator'); + assert.notEqual(config.identifier, 'com.acme.desktop'); + assert.deepEqual(config.build, { frontendDist: 'ui' }); + assert.equal(config.plugins?.updater, undefined); + assert.equal(config.bundle.createUpdaterArtifacts, undefined); + assert.deepEqual(Object.values(config.bundle.resources), ['THIRD_PARTY_NOTICES.md']); + assert.deepEqual(config.app.windows.map(({ label }) => label), ['migrator']); +}); + +test('Data Migrator projection provides both trusted sibling binary names', () => { + const resolution = resolveProductDefinition({ + rootDir: ROOT, + productConfig: ACME, + member: 'dataMigrator', + }); + const environment = productBuildEnvironment(resolution); + + assert.equal(environment.OPENBITFUN_PRODUCT_BINARY_NAME, 'acme-data-migrator'); + assert.equal(environment.OPENBITFUN_DATA_MIGRATOR_BINARY_NAME, 'acme-data-migrator'); + assert.equal(environment.OPENBITFUN_DESKTOP_BINARY_NAME, 'acme-desktop'); +}); + +test('Data Migrator dependency and command closure stays migration-only', () => { + const manifest = readFileSync(join(APP, 'Cargo.toml'), 'utf8'); + const source = readFileSync(join(APP, 'src', 'app_state.rs'), 'utf8'); + const registration = readFileSync(join(APP, 'src', 'lib.rs'), 'utf8'); + const capability = readFileSync(join(APP, 'capabilities', 'migrator.json'), 'utf8'); + + assert.match(manifest, /openbitfun-core[^\n]+features = \["legacy-migration"\]/); + for (const forbidden of ['product-full', 'openbitfun-agent-runtime', 'plugin-runtime']) { + assert.equal(manifest.includes(forbidden), false, `manifest must not include ${forbidden}`); + } + assert.match(source, /product_assembly_plan_for_profile\(DeliveryProfile::DataMigrator\)/); + assert.match(registration, /tauri::generate_handler!/); + assert.match(registration, /commands::export_migration_diagnostics/); + assert.match( + readFileSync(join(ROOT, 'scripts', 'data-migrator-tauri-build.mjs'), 'utf8'), + /windowsHide:\s*true/, + ); + for (const forbidden of ['fs:', 'shell:', 'updater:', 'dialog:']) { + assert.equal(capability.includes(forbidden), false, `capability must not include ${forbidden}`); + } +}); + +test('Data Migrator gates onboarding actions on authenticated bootstrap', () => { + const html = readFileSync(join(APP, 'ui', 'index.html'), 'utf8'); + const source = readFileSync(join(APP, 'ui', 'app.js'), 'utf8'); + + assert.match(html, /
]+hidden>/); + assert.match(source, /function requireBootstrap\(\)/); + assert.match(source, /if \(!requireBootstrap\(\)\) return;/); + assert.match(source, /catch \(error\) \{\s+notice\(/); +}); + +test('Data Migrator labels unverified report counts as staged', () => { + const source = readFileSync(join(APP, 'ui', 'app.js'), 'utf8'); + + assert.match( + source, + /result\.state === 'verified' \? text\.imported : text\.staged/, + ); + assert.match(source, /\$\{result\.imported\} \$\{transferLabel\(result\)\}/); +}); diff --git a/scripts/desktop-tauri-build.mjs b/scripts/desktop-tauri-build.mjs index fcf1ae263e..e362c16503 100644 --- a/scripts/desktop-tauri-build.mjs +++ b/scripts/desktop-tauri-build.mjs @@ -30,6 +30,7 @@ const LINUX_FLASHGREP_BINARIES = [ 'flashgrep-aarch64-unknown-linux-musl', 'flashgrep-aarch64-unknown-linux-gnu', ]; +const DATA_MIGRATOR_CARGO_BINARY = 'openbitfun-data-migrator'; function tauriBuildArgsFromArgv() { const args = process.argv.slice(2); @@ -53,6 +54,7 @@ async function main() { const desktopDir = join(ROOT, 'src', 'apps', 'desktop'); preparePluginHost(); + const dataMigratorSidecar = prepareDataMigratorSidecar(forward, resolution, desktopDir); // Flashgrep distribution is temporarily suspended. const flashgrepBinary = null; // Tauri CLI reads CI and rejects numeric "1" (common in CI providers). @@ -66,6 +68,7 @@ async function main() { const tauriConfig = prepareTauriConfig(join(desktopDir, 'tauri.conf.json'), { desktopDir, flashgrepBinary, + dataMigratorSidecar, resolution, releaseChannel, }); @@ -96,6 +99,10 @@ async function main() { process.exit(1); } + if (r.status === 0 && forward.includes('--no-bundle')) { + stageNoBundleDataMigrator(dataMigratorSidecar); + } + // Keep only the latest useful Cargo caches for this build profile after tauri build ends. try { const { profileFromTauriBuildArgs, runGcBestEffort, targetFromTauriBuildArgs } = await import( @@ -119,6 +126,100 @@ async function main() { process.exit(r.status ?? 1); } +function rustHostTargetTriple() { + const result = spawnSync('rustc', ['-vV'], { + cwd: ROOT, + encoding: 'utf8', + shell: false, + windowsHide: true, + }); + if (result.error || result.status !== 0) { + const detail = result.error?.message || result.stderr || `exit status ${result.status}`; + throw new Error(`Could not determine the Rust host target: ${detail}`); + } + const host = String(result.stdout).match(/^host:\s*(\S+)$/m)?.[1]; + if (!host) throw new Error('rustc -vV did not report a host target triple.'); + return host; +} + +export function planDataMigratorSidecar( + args, + resolution, + desktopDir, + runtime = {}, +) { + const explicitTarget = optionValue(args, '--target'); + const targetTriple = explicitTarget || runtime.hostTarget || rustHostTargetTriple(); + const profile = args.includes('--debug') ? 'debug' : optionValue(args, '--profile') || 'release'; + const targetDirValue = runtime.cargoTargetDir ?? process.env.CARGO_TARGET_DIR; + const targetDir = targetDirValue + ? isAbsolute(targetDirValue) + ? targetDirValue + : resolve(ROOT, targetDirValue) + : join(ROOT, 'target'); + const windowsTarget = targetTriple.includes('windows'); + const suffix = windowsTarget ? '.exe' : ''; + const artifactDirectory = join(targetDir, ...(explicitTarget ? [explicitTarget] : []), profile); + const cargoArgs = ['build', '-p', 'openbitfun-data-migrator', '--bin', DATA_MIGRATOR_CARGO_BINARY]; + if (explicitTarget) cargoArgs.push('--target', explicitTarget); + if (args.includes('--debug')) { + // Cargo's default profile is the Tauri CLI's debug profile. + } else if (optionValue(args, '--profile')) { + cargoArgs.push('--profile', profile); + } else { + cargoArgs.push('--release'); + } + + const siblingBinaryName = resolution.assembly.memberBinaryNames.dataMigrator; + const externalBinBase = join(desktopDir, 'gen', 'sidecars', siblingBinaryName); + return { + artifactDirectory, + cargoArgs, + externalBinBase, + externalBinInput: `${externalBinBase}-${targetTriple}${suffix}`, + sourceArtifact: join(artifactDirectory, `${DATA_MIGRATOR_CARGO_BINARY}${suffix}`), + siblingArtifact: join(artifactDirectory, `${siblingBinaryName}${suffix}`), + siblingBinaryName, + targetTriple, + }; +} + +function prepareDataMigratorSidecar(args, resolution, desktopDir) { + const plan = planDataMigratorSidecar(args, resolution, desktopDir); + console.log( + `[tauri-build] Building Data Migrator sidecar (${plan.targetTriple}, ${plan.artifactDirectory})` + ); + const result = spawnSync('cargo', plan.cargoArgs, { + cwd: ROOT, + env: process.env, + stdio: 'inherit', + shell: false, + windowsHide: true, + }); + if (result.error) throw result.error; + if (result.status !== 0) { + throw new Error(`Data Migrator sidecar build failed with exit code ${result.status}`); + } + if (!existsSync(plan.sourceArtifact)) { + throw new Error(`Data Migrator build did not produce ${plan.sourceArtifact}`); + } + mkdirSync(dirname(plan.externalBinInput), { recursive: true }); + copyFileSync(plan.sourceArtifact, plan.externalBinInput); + if (!plan.externalBinInput.endsWith('.exe')) { + chmodSync(plan.externalBinInput, statSync(plan.externalBinInput).mode | 0o111); + } + return plan; +} + +export function stageNoBundleDataMigrator(plan) { + if (resolve(plan.sourceArtifact) === resolve(plan.siblingArtifact)) return plan.siblingArtifact; + copyFileSync(plan.sourceArtifact, plan.siblingArtifact); + if (!plan.siblingArtifact.endsWith('.exe')) { + chmodSync(plan.siblingArtifact, statSync(plan.siblingArtifact).mode | 0o111); + } + return plan.siblingArtifact; +} + function preparePluginHost() { const result = spawnSync('pnpm', ['run', 'plugin-host:prepare'], { cwd: ROOT, @@ -277,7 +378,7 @@ export function prepareMacOSFlashgrepForSigning( export function prepareTauriConfig( baseConfigPath, - { desktopDir, flashgrepBinary, resolution, releaseChannel } + { desktopDir, flashgrepBinary, dataMigratorSidecar, resolution, releaseChannel } ) { const config = JSON.parse(readFileSync(baseConfigPath, 'utf8')); if (resolution) { @@ -289,6 +390,7 @@ export function prepareTauriConfig( config.identifier = resolution.assembly.bundleId; } injectTargetFlashgrepResource(config, desktopDir, flashgrepBinary); + injectDataMigratorSidecar(config, desktopDir, dataMigratorSidecar); // The DeepSeek bridge is not a compile-time resource: cargo check and // desktop:dev must not require packages/dsh-acp/dist-profile. Official // packaging injects it here; frontend:build-all (beforeBuildCommand) @@ -355,6 +457,16 @@ export function prepareTauriConfig( return generatedConfig; } +function injectDataMigratorSidecar(config, desktopDir, sidecar) { + if (!sidecar) return; + const externalBin = new Set(config.bundle?.externalBin || []); + externalBin.add(toTauriPath(relative(desktopDir, sidecar.externalBinBase))); + config.bundle = { + ...(config.bundle || {}), + externalBin: [...externalBin], + }; +} + const DSH_PROFILE_RESOURCE_SOURCE = '../../../packages/dsh-acp/dist-profile'; const DSH_PROFILE_RESOURCE_TARGET = 'resources/dsh-profile'; const EXTERNAL_FRONTEND_RESOURCE_SOURCE = '../../../dist'; diff --git a/scripts/desktop-tauri-build.test.mjs b/scripts/desktop-tauri-build.test.mjs index 2399ecc306..5c27bcda53 100644 --- a/scripts/desktop-tauri-build.test.mjs +++ b/scripts/desktop-tauri-build.test.mjs @@ -5,8 +5,10 @@ import { join } from 'node:path'; import test from 'node:test'; import { configureDesktopWebFontProfile, + planDataMigratorSidecar, prepareMacOSFlashgrepForSigning, prepareTauriConfig, + stageNoBundleDataMigrator, shouldRetryMacDmgBuild, } from './desktop-tauri-build.mjs'; import { resolveProductDefinition } from './product-customization/resolver.mjs'; @@ -343,6 +345,58 @@ test('Desktop packaging works without the suspended Flashgrep resource', () => { } }); +test('Desktop packaging builds and projects the matching Data Migrator sidecar', () => { + const fixture = join(tmpdir(), `openbitfun-migrator-sidecar-${process.pid}-${Date.now()}`); + const desktopDir = join(fixture, 'src', 'apps', 'desktop'); + const targetDir = join(fixture, 'target'); + mkdirSync(desktopDir, { recursive: true }); + const baseConfig = join(fixture, 'tauri.conf.json'); + writeFileSync(baseConfig, JSON.stringify({ bundle: { resources: {} } })); + try { + const resolution = resolveProductDefinition({ + rootDir: ROOT, + productConfig: join(ROOT, 'products', 'fixtures', 'acme', 'product.jsonc'), + member: 'desktop', + }); + const plan = planDataMigratorSidecar( + ['--target', 'x86_64-pc-windows-msvc', '--profile', 'release-fast'], + resolution, + desktopDir, + { cargoTargetDir: targetDir }, + ); + assert.deepEqual(plan.cargoArgs, [ + 'build', + '-p', + 'openbitfun-data-migrator', + '--bin', + 'openbitfun-data-migrator', + '--target', + 'x86_64-pc-windows-msvc', + '--profile', + 'release-fast', + ]); + assert.equal( + plan.externalBinInput, + join(desktopDir, 'gen', 'sidecars', 'acme-data-migrator-x86_64-pc-windows-msvc.exe'), + ); + const generated = prepareTauriConfig(baseConfig, { + desktopDir, + flashgrepBinary: join(fixture, 'flashgrep'), + dataMigratorSidecar: plan, + resolution, + }); + const config = JSON.parse(readFileSync(generated, 'utf8')); + assert.deepEqual(config.bundle.externalBin, ['gen/sidecars/acme-data-migrator']); + + mkdirSync(plan.artifactDirectory, { recursive: true }); + writeFileSync(plan.sourceArtifact, 'migrator'); + assert.equal(stageNoBundleDataMigrator(plan), plan.siblingArtifact); + assert.equal(readFileSync(plan.siblingArtifact, 'utf8'), 'migrator'); + } finally { + rmSync(fixture, { force: true, recursive: true }); + } +}); + test('Windows updater installs NSIS packages without showing its progress window', () => { const fixture = join(tmpdir(), `openbitfun-tauri-updater-${process.pid}-${Date.now()}`); const baseConfig = join(fixture, 'tauri.conf.json'); diff --git a/scripts/dev.cjs b/scripts/dev.cjs index 9a48d5c484..494668ddfc 100644 --- a/scripts/dev.cjs +++ b/scripts/dev.cjs @@ -153,6 +153,7 @@ function runCommandPrefixed(prefix, cmd, args, cwd = ROOT_DIR, envOverrides = {} const child = spawn(cmd, args, { cwd, shell: process.platform === 'win32', + windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, @@ -672,6 +673,22 @@ async function main() { process.exit(1); } + // Build after version generation and before Desktop starts. Cargo checks + // freshness so an existing but stale Migrator is rebuilt as well. + if (desktopMode) { + printInfo('Preparing Data Migrator (incremental Debug build)'); + const migratorBuild = await runCommandPrefixed( + 'data-migrator', + 'cargo', + ['build', '-p', 'openbitfun-data-migrator', '--bin', 'openbitfun-data-migrator'], + ); + if (!migratorBuild.ok) { + printError('Data Migrator build failed; Desktop was not started'); + if (migratorBuild.error?.message) printError(migratorBuild.error.message); + process.exit(1); + } + } + if (desktopMode) { const baselineHelperUrl = pathToFileURL( path.join(__dirname, 'frontend-workbench-dev-baseline.mjs') diff --git a/scripts/product-customization/cli.mjs b/scripts/product-customization/cli.mjs index 962fc93d43..225e9fbf71 100644 --- a/scripts/product-customization/cli.mjs +++ b/scripts/product-customization/cli.mjs @@ -58,7 +58,7 @@ export function explainProduct(resolution) { localizedNames: resolution.productNames, localeDigest: resolution.assembly.localeDigest, assemblyDigest: resolution.assembly.assemblyDigest, - implementedScope: 'identity-and-localized-name-c0a', + implementedScope: 'identity-and-localized-name-c0b', }; } diff --git a/scripts/product-customization/projections.mjs b/scripts/product-customization/projections.mjs index 8bf06c09a9..8918e16777 100644 --- a/scripts/product-customization/projections.mjs +++ b/scripts/product-customization/projections.mjs @@ -46,6 +46,9 @@ export function productBuildEnvironment(resolution) { OPENBITFUN_HIDDEN_DATA_DIRECTORY: `.${resolution.assembly.dataNamespace}`, OPENBITFUN_PRODUCT_BINARY_NAME: resolution.assembly.binaryName, OPENBITFUN_PRODUCT_DISPLAY_NAME: fallbackName, + OPENBITFUN_DESKTOP_BINARY_NAME: resolution.assembly.memberBinaryNames.desktop, + OPENBITFUN_DATA_MIGRATOR_BINARY_NAME: + resolution.assembly.memberBinaryNames.dataMigrator, }; if (!resolution.isDefaultProduct) { const cargoTargetRoot = process.env.CARGO_TARGET_DIR diff --git a/scripts/product-customization/projections.test.mjs b/scripts/product-customization/projections.test.mjs index 6f6ffeb472..25334218db 100644 --- a/scripts/product-customization/projections.test.mjs +++ b/scripts/product-customization/projections.test.mjs @@ -32,4 +32,9 @@ test('build environment isolates custom Cargo output without overriding the defa assert.equal(customEnvironment.OPENBITFUN_HIDDEN_DATA_DIRECTORY, '.acme'); assert.equal(customEnvironment.OPENBITFUN_PRODUCT_BINARY_NAME, 'acme'); assert.equal(customEnvironment.OPENBITFUN_PRODUCT_DISPLAY_NAME, 'Acme CLI'); + assert.equal(customEnvironment.OPENBITFUN_DESKTOP_BINARY_NAME, 'acme-desktop'); + assert.equal( + customEnvironment.OPENBITFUN_DATA_MIGRATOR_BINARY_NAME, + 'acme-data-migrator', + ); }); diff --git a/scripts/product-customization/resolver.mjs b/scripts/product-customization/resolver.mjs index dba0b067a5..c522f00ad4 100644 --- a/scripts/product-customization/resolver.mjs +++ b/scripts/product-customization/resolver.mjs @@ -15,9 +15,9 @@ const ROOT_FIELDS = new Set([ 'localeRoot', 'members', ]); -const MEMBERS_FIELDS = new Set(['desktop', 'cli']); +const MEMBERS_FIELDS = new Set(['desktop', 'dataMigrator', 'cli']); const COMMON_MEMBER_FIELDS = new Set(['displayNameKey', 'binaryName']); -const DESKTOP_MEMBER_FIELDS = new Set([...COMMON_MEMBER_FIELDS, 'bundleId']); +const BUNDLED_MEMBER_FIELDS = new Set([...COMMON_MEMBER_FIELDS, 'bundleId']); export class ProductDefinitionError extends Error { constructor(code, message, action) { @@ -192,12 +192,13 @@ function ownedLocaleFile(localeRoot, locale) { function validateMember(raw, member) { const owner = `members.${member}`; const value = requireObject(raw, owner); - rejectUnknownFields(value, member === 'desktop' ? DESKTOP_MEMBER_FIELDS : COMMON_MEMBER_FIELDS, owner); + const bundled = member === 'desktop' || member === 'dataMigrator'; + rejectUnknownFields(value, bundled ? BUNDLED_MEMBER_FIELDS : COMMON_MEMBER_FIELDS, owner); const result = { displayNameKey: requiredString(value.displayNameKey, `${owner}.displayNameKey`), binaryName: binaryName(value.binaryName, `${owner}.binaryName`), }; - if (member === 'desktop') result.bundleId = bundleId(value.bundleId, `${owner}.bundleId`); + if (bundled) result.bundleId = bundleId(value.bundleId, `${owner}.bundleId`); return result; } @@ -234,7 +235,9 @@ function loadProductNames(rootDir, localeRoot, displayNameKeys) { } export function resolveProductDefinition({ rootDir, productConfig, member }) { - if (!['desktop', 'cli'].includes(member)) fail('invalid_member', `Unsupported product member: ${member}`, 'Use desktop or cli.'); + if (!['desktop', 'dataMigrator', 'cli'].includes(member)) { + fail('invalid_member', `Unsupported product member: ${member}`, 'Use desktop, dataMigrator, or cli.'); + } const canonicalRoot = realpathSync.native(resolve(rootDir)); const defaultPath = realpathSync.native(join(canonicalRoot, 'products', 'openbitfun', 'product.jsonc')); const selectedPath = resolve(productConfig || defaultPath); @@ -259,12 +262,17 @@ export function resolveProductDefinition({ rootDir, productConfig, member }) { rejectUnknownFields(members, MEMBERS_FIELDS, 'members'); const normalizedMembers = { desktop: validateMember(members.desktop, 'desktop'), + dataMigrator: validateMember(members.dataMigrator, 'dataMigrator'), cli: validateMember(members.cli, 'cli'), }; const locales = loadProductNames( canonicalRoot, localeRoot, - [normalizedMembers.desktop.displayNameKey, normalizedMembers.cli.displayNameKey], + [ + normalizedMembers.desktop.displayNameKey, + normalizedMembers.dataMigrator.displayNameKey, + normalizedMembers.cli.displayNameKey, + ], ); const selected = normalizedMembers[member]; const assemblyContent = { @@ -273,6 +281,10 @@ export function resolveProductDefinition({ rootDir, productConfig, member }) { productId, dataNamespace, member, + memberBinaryNames: { + desktop: normalizedMembers.desktop.binaryName, + dataMigrator: normalizedMembers.dataMigrator.binaryName, + }, displayNameKey: selected.displayNameKey, binaryName: selected.binaryName, localeDigest: locales.digest, diff --git a/scripts/product-customization/resolver.test.mjs b/scripts/product-customization/resolver.test.mjs index 4c2283601c..79eae41705 100644 --- a/scripts/product-customization/resolver.test.mjs +++ b/scripts/product-customization/resolver.test.mjs @@ -12,6 +12,7 @@ const ACME = join(ROOT, 'products', 'fixtures', 'acme', 'product.jsonc'); test('default and custom members resolve through one deterministic contract', () => { const openbitfun = resolveProductDefinition({ rootDir: ROOT, member: 'desktop' }); const desktop = resolveProductDefinition({ rootDir: ROOT, productConfig: ACME, member: 'desktop' }); + const dataMigrator = resolveProductDefinition({ rootDir: ROOT, productConfig: ACME, member: 'dataMigrator' }); const cli = resolveProductDefinition({ rootDir: ROOT, productConfig: ACME, member: 'cli' }); assert.equal(openbitfun.assembly.productId, 'openbitfun'); @@ -19,9 +20,16 @@ test('default and custom members resolve through one deterministic contract', () assert.equal(openbitfun.assembly.binaryName, 'openbitfun-desktop'); assert.equal(openbitfun.assembly.bundleId, 'com.openbitfun.desktop'); assert.equal(desktop.assembly.bundleId, 'com.acme.desktop'); + assert.equal(dataMigrator.assembly.binaryName, 'acme-data-migrator'); + assert.equal(dataMigrator.assembly.bundleId, 'com.acme.data-migrator'); + assert.deepEqual(dataMigrator.assembly.memberBinaryNames, { + desktop: 'acme-desktop', + dataMigrator: 'acme-data-migrator', + }); assert.equal(cli.assembly.binaryName, 'acme'); assert.equal(cli.assembly.bundleId, undefined); assert.notEqual(desktop.assembly.assemblyDigest, cli.assembly.assemblyDigest); + assert.notEqual(desktop.assembly.assemblyDigest, dataMigrator.assembly.assemblyDigest); assert.equal( resolveProductDefinition({ rootDir: ROOT, productConfig: ACME, member: 'desktop' }) .assembly.assemblyDigest, diff --git a/scripts/product-identity-audit.mjs b/scripts/product-identity-audit.mjs index 8144309428..84c2253364 100644 --- a/scripts/product-identity-audit.mjs +++ b/scripts/product-identity-audit.mjs @@ -3,11 +3,9 @@ /** * Prevent the retired product identity from returning to production sources. * - * The normal product is OpenBitFun-only. The only legacy exception is the - * exact legacy data-directory ignore entry for machine-local data retained - * across upgrades. - * A future one-time importer may add another deliberately narrow allowlist for - * its source adapters and fixtures. + * The normal product is OpenBitFun-only. Legacy exceptions are restricted to + * the exact data-directory ignore entry and the one-time migration documents, + * migrator app/service boundary, and fixtures used for in-place upgrades. */ import { execFileSync } from 'node:child_process'; import { existsSync, readFileSync, statSync } from 'node:fs'; @@ -21,6 +19,12 @@ const retiredProductToken = `${'bit'}${'fun'}`; const shortPrefix = `${'b'}${'f'}`; const productIdentityOwner = 'src/crates/contracts/core-types/src/product_identity.rs'; const retiredIdentityDataBoundaryFiles = new Set([ + 'OPENBITFUN_LEGACY_DATA_MIGRATION_IMPLEMENTATION_PLAN.md', + 'OPENBITFUN_LEGACY_DATA_MIGRATION_INVENTORY.md', + 'src/apps/desktop/src/api/legacy_migration_api.rs', + 'src/web-ui/src/locales/en-US/settings/legacy-migration.json', + 'src/web-ui/src/locales/zh-CN/settings/legacy-migration.json', + 'src/web-ui/src/locales/zh-TW/settings/legacy-migration.json', 'deploy/openbitfun-host/README.md', 'deploy/openbitfun-host/migrate-market-data-v1.py', 'src/apps/relay-server/README.md', @@ -31,7 +35,13 @@ const retiredIdentityDataBoundaryFiles = new Set([ 'src/apps/mobile/harmonyos/entry/src/main/ets/services/HarmonyUpgradeIdentityContract.ets', 'src/apps/mobile/harmonyos/entry/src/main/resources/base/profile/backup_config.json', ]); +const retiredIdentityDataBoundaryPrefixes = Object.freeze([ + 'src/apps/data-migrator/', + 'src/crates/assembly/core/src/legacy_migration/', + 'src/crates/services/legacy-migration/', +]); const noncanonicalIdentityDataBoundaryFiles = new Set([ + 'OPENBITFUN_LEGACY_DATA_MIGRATION_INVENTORY.md', 'deploy/openbitfun-host/migrate-market-data-v1.py', ]); @@ -56,6 +66,7 @@ const identityRules = Object.freeze([ && location.location === 'content' && location.lineText?.trim() === `.${retiredProductToken}/`, allowedFiles: retiredIdentityDataBoundaryFiles, + allowedFilePrefixes: retiredIdentityDataBoundaryPrefixes, }), Object.freeze({ id: 'retired-css-token-prefix', @@ -228,6 +239,9 @@ function collectMatches(value, location) { if (rule.allowedFiles?.has(location.file)) { continue; } + if (rule.allowedFilePrefixes?.some((prefix) => location.file.startsWith(prefix))) { + continue; + } if (rule.isViolation && !rule.isViolation(match[0])) { continue; } diff --git a/scripts/product-identity-audit.test.mjs b/scripts/product-identity-audit.test.mjs index bc070216fe..6d11658599 100644 --- a/scripts/product-identity-audit.test.mjs +++ b/scripts/product-identity-audit.test.mjs @@ -105,6 +105,21 @@ test('allows only the exact legacy data-directory ignore entry', () => { }); test('limits retired identity data to the one-time production migration boundary', () => { + for (const file of [ + 'src/apps/desktop/src/api/legacy_migration_api.rs', + 'src/web-ui/src/locales/en-US/settings/legacy-migration.json', + 'src/web-ui/src/locales/zh-CN/settings/legacy-migration.json', + 'src/web-ui/src/locales/zh-TW/settings/legacy-migration.json', + ]) { + assert.deepEqual(violationsFor(retiredName, file), []); + } + for (const file of [ + 'src/apps/desktop/src/lib.rs', + 'src/web-ui/src/locales/en-US/settings.json', + 'src/shared/interactive-capabilities/catalog.json', + ]) { + assert.equal(violationsFor(retiredName, file).length, 1); + } const retiredField = ['min', 'Bit', 'fun', 'Version'].join(''); assert.deepEqual( violationsFor( @@ -117,6 +132,41 @@ test('limits retired identity data to the one-time production migration boundary violationsFor(`const field = "${retiredField}";`, 'src/example.ts').length, 1, ); + assert.deepEqual( + violationsFor( + `const SOURCE_PRODUCT: &str = "${retiredLowerName}";`, + 'src/crates/services/legacy-migration/src/source.rs', + ), + [], + ); + assert.deepEqual( + violationsFor( + `const SOURCE_PRODUCT: &str = "${retiredLowerName}";`, + 'src/crates/assembly/core/src/legacy_migration/source.rs', + ), + [], + ); + assert.deepEqual( + violationsFor( + `const sourceLabel = "${retiredName}";`, + 'src/apps/data-migrator/ui/app.js', + ), + [], + ); + assert.equal( + violationsFor( + `const SOURCE_PRODUCT: &str = "${retiredLowerName}";`, + 'src/crates/services/example/src/source.rs', + ).length, + 1, + ); + assert.equal( + violationsFor( + `const sourceLabel = "${retiredName}";`, + 'src/apps/desktop/src/example.rs', + ).length, + 1, + ); }); test('allows retired Harmony identifiers only at the upgrade identity boundary', () => { diff --git a/src/apps/data-migrator/AGENTS.md b/src/apps/data-migrator/AGENTS.md new file mode 100644 index 0000000000..5593d2ca29 --- /dev/null +++ b/src/apps/data-migrator/AGENTS.md @@ -0,0 +1,37 @@ +# Data Migrator Agent Guide + +Scope: this guide applies to `src/apps/data-migrator`. + +This app is the offline, local-only host for importing legacy BitFun data. It +must remain a separate executable and WebView identity from Desktop. + +## Guardrails + +- Select `DeliveryProfile::DataMigrator` and only the Core + `legacy-migration` feature. Do not add `product-full`, Agent Runtime, + plugin runtime, normal session startup, updater, shell, or frontend + filesystem capabilities. +- Accept only the handoff `run_id` on the command line. Derive the request path + from `MigrationRoots`; never accept a request or executable path from UI or + command-line input. +- Keep all filesystem, process, credential, and restart work in Rust. The UI + may call only the typed commands registered in `src/lib.rs`. +- Report domain, phase, and counts. Do not invent progress percentages or emit + secrets, user content, credential values, or absolute paths in errors. +- Cancellation is advisory and may be honored only at engine-declared safe + boundaries. Closing during execution requests cancellation and keeps the + window open until a safe boundary. +- The migrator never updates itself. Resolve Desktop as a fixed-name sibling + binary using product-definition projections and the trusted installation + resolver; do not accept executable paths from handoff input. + +## Verification + +```bash +cargo test -p openbitfun-data-migrator +node --test scripts/data-migrator-tauri-build.test.mjs +``` + +Run `pnpm run check:core-boundaries` when dependencies or delivery-profile +selection change. Packaging, signing, and UI interaction are separate explicit +verification steps. diff --git a/src/apps/data-migrator/Cargo.toml b/src/apps/data-migrator/Cargo.toml new file mode 100644 index 0000000000..8c112cad8d --- /dev/null +++ b/src/apps/data-migrator/Cargo.toml @@ -0,0 +1,34 @@ +[package] +name = "openbitfun-data-migrator" +version.workspace = true +authors.workspace = true +edition.workspace = true +description = "OpenBitFun offline legacy data migrator" + +[lib] +name = "openbitfun_data_migrator_lib" +crate-type = ["staticlib", "cdylib", "rlib"] + +[[bin]] +name = "openbitfun-data-migrator" +path = "src/main.rs" + +[build-dependencies] +tauri-build = { workspace = true } + +[dependencies] +openbitfun-core = { path = "../../crates/assembly/core", features = ["legacy-migration"] } +openbitfun-core-types = { path = "../../crates/contracts/core-types" } +openbitfun-legacy-migration = { path = "../../crates/services/legacy-migration" } +openbitfun-product-capabilities = { path = "../../crates/assembly/product-capabilities" } +openbitfun-product-domains = { path = "../../crates/contracts/product-domains", features = ["legacy-migration"] } +serde = { workspace = true } +serde_json = { workspace = true } +tauri = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } +uuid = { workspace = true } + +[lints] +workspace = true diff --git a/src/apps/data-migrator/build.rs b/src/apps/data-migrator/build.rs new file mode 100644 index 0000000000..2b49de0736 --- /dev/null +++ b/src/apps/data-migrator/build.rs @@ -0,0 +1,11 @@ +fn main() { + for name in [ + "OPENBITFUN_RELEASE_CHANNEL", + "OPENBITFUN_PRODUCT_ID", + "OPENBITFUN_DESKTOP_BINARY_NAME", + "OPENBITFUN_DATA_MIGRATOR_BINARY_NAME", + ] { + println!("cargo:rerun-if-env-changed={name}"); + } + tauri_build::build(); +} diff --git a/src/apps/data-migrator/capabilities/migrator.json b/src/apps/data-migrator/capabilities/migrator.json new file mode 100644 index 0000000000..444fec74c0 --- /dev/null +++ b/src/apps/data-migrator/capabilities/migrator.json @@ -0,0 +1,10 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "migrator", + "description": "Minimal local capability set for OpenBitFun Data Migrator", + "windows": ["migrator"], + "permissions": [ + "core:default", + "core:window:allow-close" + ] +} diff --git a/src/apps/data-migrator/gen/.gitignore b/src/apps/data-migrator/gen/.gitignore new file mode 100644 index 0000000000..d6b7ef32c8 --- /dev/null +++ b/src/apps/data-migrator/gen/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/src/apps/data-migrator/src/app_state.rs b/src/apps/data-migrator/src/app_state.rs new file mode 100644 index 0000000000..83df00a76e --- /dev/null +++ b/src/apps/data-migrator/src/app_state.rs @@ -0,0 +1,1134 @@ +use openbitfun_core::legacy_migration::adapters_for_groups; +use openbitfun_core_types::product_identity::product_id; +use openbitfun_legacy_migration::{ + blocking_writer_processes_for_product, export_failure_diagnostics, launch_trusted_executable, + probe_legacy_source, CancellationToken, HandoffDisposition, HandoffStore, LegacyMigrationError, + LegacyMigrationResult, MigrationEngine, MigrationLayout, MigrationOnboardingStore, + MigrationRoots, NoCrashInjection, ProbeLimits, TrustedInstallationResolver, WriterProcess, +}; +use openbitfun_product_capabilities::{product_assembly_plan_for_profile, DeliveryProfile}; +use openbitfun_product_domains::legacy_migration::{ + FindingSeverity, LegacySourceDescriptor, MigrationPhase, MigrationPlan, MigrationProgressEvent, + MigrationPromptChoice, MigrationRunReport, MigrationRunStatus, MigrationSelection, + MigratorHandoffRequest, MigratorProtocolCapabilities, MigratorRequestMode, ScanFinding, + CURRENT_MIGRATION_FORMAT_VERSION, +}; +use serde::Serialize; +use std::ffi::OsStr; +use std::path::Path; +use std::sync::{Arc, Mutex, MutexGuard}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +const RELEASE_CHANNEL: &str = match option_env!("OPENBITFUN_RELEASE_CHANNEL") { + Some(value) => value, + None => "stable", +}; +const DESKTOP_BINARY_NAME: &str = match option_env!("OPENBITFUN_DESKTOP_BINARY_NAME") { + Some(value) => value, + None => "openbitfun-desktop", +}; +const DATA_MIGRATOR_BINARY_NAME: &str = match option_env!("OPENBITFUN_DATA_MIGRATOR_BINARY_NAME") { + Some(value) => value, + None => "openbitfun-data-migrator", +}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum FinishAction { + CloseForDevRestart, + RestartDesktop, +} + +const fn finish_action() -> FinishAction { + finish_action_for_build(cfg!(debug_assertions)) +} + +const fn finish_action_for_build(is_debug_build: bool) -> FinishAction { + if is_debug_build { + FinishAction::CloseForDevRestart + } else { + FinishAction::RestartDesktop + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct CommandError { + pub code: String, + pub message: String, + pub recoverable: bool, +} + +impl CommandError { + fn new(code: &str, message: &str, recoverable: bool) -> Self { + Self { + code: code.to_string(), + message: message.to_string(), + recoverable, + } + } + + pub(crate) fn worker_failed() -> Self { + Self::new( + "worker_failed", + "The migration worker stopped before returning a result.", + true, + ) + } + + fn operation_in_progress() -> Self { + Self::new( + "operation_in_progress", + "Another migration operation is still running.", + true, + ) + } + + fn from_legacy(error: &LegacyMigrationError) -> Self { + match error { + LegacyMigrationError::PathUnavailable(_) => Self::new( + "path_unavailable", + "A required data location is unavailable for the current user.", + true, + ), + LegacyMigrationError::SourceEqualsTarget(_) => Self::new( + "source_equals_target", + "The legacy and OpenBitFun data locations are not safely separated.", + false, + ), + LegacyMigrationError::UnsupportedSource(_) => Self::new( + "unsupported_source", + "This legacy BitFun data format is not supported by this migrator.", + false, + ), + LegacyMigrationError::InvalidRequest(_) => Self::new( + "invalid_handoff", + "The migration handoff could not be authenticated or has expired.", + false, + ), + LegacyMigrationError::InvalidPlan(_) => Self::new( + "invalid_plan", + "The saved migration plan no longer matches this request or source.", + true, + ), + LegacyMigrationError::PathEscape(_) | LegacyMigrationError::LinkedPath(_) => Self::new( + "unsafe_source_path", + "Migration stopped because a source path failed its safety check.", + false, + ), + LegacyMigrationError::ResourceLimit(_) => Self::new( + "resource_limit", + "Migration stopped at a configured safety limit.", + true, + ), + LegacyMigrationError::LockUnavailable => Self::new( + "migration_locked", + "Another migration process currently owns the migration lock.", + true, + ), + LegacyMigrationError::Cancelled => Self::new( + "cancelled", + "Migration was cancelled at a safe boundary.", + true, + ), + LegacyMigrationError::ProcessInspection(_) => Self::new( + "process_inspection_failed", + "OpenBitFun could not verify that all data-writing processes have stopped.", + true, + ), + LegacyMigrationError::UntrustedExecutable(_) + | LegacyMigrationError::TrustedInstallationUnavailable(_) => Self::new( + "trusted_installation_unavailable", + "The signed OpenBitFun installation could not be verified.", + true, + ), + LegacyMigrationError::InjectedCrash(_) => Self::new( + "migration_interrupted", + "Migration was interrupted and can be resumed from its journal.", + true, + ), + LegacyMigrationError::Domain { .. } => Self::new( + "domain_failed", + "One migration domain failed validation. Other verified domains remain intact.", + true, + ), + LegacyMigrationError::Io { .. } + | LegacyMigrationError::Json { .. } + | LegacyMigrationError::Sqlite { .. } => Self::new( + "storage_failed", + "Migration could not safely read or write one of its data stores.", + true, + ), + } + } + + fn from_report_failure(report: &MigrationRunReport) -> Option { + let diagnostic = report.diagnostics.iter().rev().find(|diagnostic| { + diagnostic.severity == FindingSeverity::Blocking + && diagnostic.domain.is_some() + && diagnostic.code.starts_with("domain_") + })?; + let mut message = diagnostic.message.clone(); + if let Some(action) = diagnostic.action.as_deref() { + if !message.is_empty() && !message.ends_with(char::is_whitespace) { + message.push(' '); + } + message.push_str(action); + } + Some(Self { + code: diagnostic.code.clone(), + message, + recoverable: true, + }) + } +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct MigratorView { + pub delivery_profile: String, + pub restart_desktop_on_finish: bool, + pub protocol: MigratorProtocolCapabilities, + pub mode: MigratorRequestMode, + pub source: Option, + pub selection: MigrationSelection, + pub findings: Vec, + pub plan: Option, + pub report: Option, + pub progress: Option, + pub blockers: Vec, + pub status: MigrationRunStatus, + pub running: bool, + pub can_execute: bool, + pub recovery: bool, + pub error: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct DiagnosticsExportView { + pub file_path: String, +} + +#[derive(Debug)] +struct MigratorSession { + roots: MigrationRoots, + request: MigratorHandoffRequest, + disposition: HandoffDisposition, + source: Option, + selection: MigrationSelection, + findings: Vec, + plan: Option, + report: Option, + progress: Option, + blockers: Vec, + status: MigrationRunStatus, + running: bool, + error: Option, + cancellation: CancellationToken, +} + +#[derive(Clone)] +pub(crate) struct MigratorCoordinator { + session: Arc>, +} + +impl MigratorCoordinator { + pub(crate) fn bootstrap(run_id: &str) -> LegacyMigrationResult { + Self::bootstrap_with( + run_id, + MigrationRoots::resolve_current_user()?, + product_id(), + RELEASE_CHANNEL, + ) + } + + fn bootstrap_with( + run_id: &str, + roots: MigrationRoots, + expected_product_id: &str, + expected_release_channel: &str, + ) -> LegacyMigrationResult { + let product_plan = product_assembly_plan_for_profile(DeliveryProfile::DataMigrator); + if !product_plan.capability_set().ids().is_empty() + || !product_plan.capability_assembly().agent_ids().is_empty() + || !product_plan.feature_groups().is_empty() + { + return Err(LegacyMigrationError::InvalidRequest( + "data migrator delivery profile unexpectedly selected runtime capabilities" + .to_string(), + )); + } + + let store = HandoffStore::new(roots.clone(), expected_product_id, expected_release_channel); + let handoff = store.load_request(run_id, now_ms())?; + let request = handoff.request().clone(); + let source = probe_bound_source(&roots, &request)?; + let plan = store.load_authorized_plan(&handoff)?; + let report = handoff + .layout() + .read_json::(&handoff.layout().report_path())?; + let selection = plan + .as_ref() + .map(|plan| plan.selection.clone()) + .unwrap_or_else(|| request.selection.clone()); + let findings = plan + .as_ref() + .map(|plan| plan.findings.clone()) + .unwrap_or_default(); + let status = report + .as_ref() + .map(|report| report.status) + .unwrap_or_else(|| { + if plan.is_some() { + MigrationRunStatus::Planned + } else if source.is_some() { + MigrationRunStatus::Discovered + } else { + MigrationRunStatus::default() + } + }); + let blockers = writer_processes(request.caller_process_id)?; + + Ok(Self { + session: Arc::new(Mutex::new(MigratorSession { + roots, + request, + disposition: handoff.disposition(), + source, + selection, + findings, + plan, + report, + progress: None, + blockers, + status, + running: false, + error: None, + cancellation: CancellationToken::default(), + })), + }) + } + + pub(crate) fn snapshot(&self) -> MigratorView { + let session = self.lock(); + snapshot(&session) + } + + pub(crate) fn export_diagnostics(&self) -> Result { + let session = self.lock(); + if session.running { + return Err(CommandError::operation_in_progress()); + } + let report = session.report.clone().ok_or_else(|| { + CommandError::new( + "diagnostics_unavailable", + "Failure diagnostics are available after a migration failure.", + false, + ) + })?; + let layout = MigrationLayout::new(&session.roots, &report.run_id); + drop(session); + let path = export_failure_diagnostics(&layout, &report).map_err(|_| { + CommandError::new( + "diagnostics_export_failed", + "OpenBitFun could not write the sanitized migration diagnostics file.", + true, + ) + })?; + Ok(DiagnosticsExportView { + file_path: path.to_string_lossy().to_string(), + }) + } + + pub(crate) fn scan(&self, selection: MigrationSelection) -> Result { + let (roots, request, cancellation) = self.begin_operation(&selection)?; + let coordinator = self.clone(); + self.spawn_worker("legacy-migration-scan", move || { + coordinator.scan_background(roots, request, selection, cancellation); + }) + } + + fn scan_background( + &self, + roots: MigrationRoots, + request: MigratorHandoffRequest, + selection: MigrationSelection, + cancellation: CancellationToken, + ) { + let result = (|| { + let source = probe_bound_source(&roots, &request)?.ok_or_else(|| { + LegacyMigrationError::UnsupportedSource( + "no supported legacy BitFun data was discovered".to_string(), + ) + })?; + let engine = migration_engine(roots.clone(), &selection)?; + let scans = engine.scan(&selection, &cancellation)?; + Ok::<_, LegacyMigrationError>(( + source, + scans + .into_iter() + .map(|scan| scan.finding) + .collect::>(), + )) + })(); + + let mut session = self.lock(); + session.running = false; + match result { + Ok((source, findings)) => { + session.source = Some(source); + session.selection = selection; + session.findings = findings; + session.plan = None; + session.report = None; + session.status = MigrationRunStatus::Scanned; + session.progress = Some(MigrationProgressEvent { + run_id: session.request.run_id.clone(), + phase: MigrationPhase::Scan, + processed: session.selection.expanded_domains().len() as u64, + total: session.selection.expanded_domains().len() as u64, + safe_to_cancel: true, + code: "scan_completed".to_string(), + ..MigrationProgressEvent::default() + }); + session.error = None; + } + Err(error) => { + self.finish_error_locked(&mut session, &error); + } + } + } + + pub(crate) fn prepare( + &self, + selection: MigrationSelection, + ) -> Result { + let (roots, request, cancellation) = self.begin_operation(&selection)?; + let coordinator = self.clone(); + self.spawn_worker("legacy-migration-plan", move || { + coordinator.prepare_background(roots, request, selection, cancellation); + }) + } + + fn prepare_background( + &self, + roots: MigrationRoots, + request: MigratorHandoffRequest, + selection: MigrationSelection, + cancellation: CancellationToken, + ) { + let result = (|| { + let source = probe_bound_source(&roots, &request)?.ok_or_else(|| { + LegacyMigrationError::UnsupportedSource( + "no supported legacy BitFun data was discovered".to_string(), + ) + })?; + let engine = migration_engine(roots, &selection)?; + let plan = engine.plan_with_run_id( + &source, + selection.clone(), + request.run_id.clone(), + &cancellation, + )?; + let blockers = writer_processes(request.caller_process_id)?; + Ok::<_, LegacyMigrationError>((source, plan, blockers)) + })(); + + let mut session = self.lock(); + session.running = false; + match result { + Ok((source, plan, blockers)) => { + session.source = Some(source); + session.selection = selection; + session.findings = plan.findings.clone(); + session.plan = Some(plan); + session.report = None; + session.blockers = blockers; + session.status = MigrationRunStatus::Planned; + session.progress = Some(MigrationProgressEvent { + run_id: session.request.run_id.clone(), + phase: MigrationPhase::Plan, + processed: session.selection.expanded_domains().len() as u64, + total: session.selection.expanded_domains().len() as u64, + safe_to_cancel: true, + code: "plan_ready".to_string(), + ..MigrationProgressEvent::default() + }); + session.error = None; + } + Err(error) => { + self.finish_error_locked(&mut session, &error); + } + } + } + + pub(crate) fn refresh_blockers(&self) -> Result { + let caller_process_id = self.lock().request.caller_process_id; + match writer_processes(caller_process_id) { + Ok(blockers) => { + let mut session = self.lock(); + session.blockers = blockers; + session.error = None; + Ok(snapshot(&session)) + } + Err(error) => { + let mut session = self.lock(); + Err(self.finish_error_locked(&mut session, &error)) + } + } + } + + pub(crate) fn start(&self, plan_hash: String) -> Result { + let (roots, request, plan, cancellation) = { + let mut session = self.lock(); + if session.running { + return Err(CommandError::operation_in_progress()); + } + let plan = session.plan.clone().ok_or_else(|| { + CommandError::new( + "plan_required", + "Run the preflight plan before starting migration.", + true, + ) + })?; + if plan.plan_hash != plan_hash { + return Err(CommandError::new( + "stale_plan", + "The confirmed plan is no longer the active migration plan.", + true, + )); + } + let store = HandoffStore::new(session.roots.clone(), product_id(), RELEASE_CHANNEL); + let handoff = store + .load_request(&session.request.run_id, now_ms()) + .map_err(|error| CommandError::from_legacy(&error))?; + store + .authorize_plan(&handoff, &plan, now_ms()) + .map_err(|error| CommandError::from_legacy(&error))?; + + session.cancellation = CancellationToken::default(); + session.running = true; + session.error = None; + session.status = MigrationRunStatus::WaitingForProcesses; + session.progress = Some(MigrationProgressEvent { + run_id: session.request.run_id.clone(), + phase: MigrationPhase::Acquire, + processed: 0, + total: plan.steps.len() as u64, + safe_to_cancel: true, + code: "waiting_for_writer_processes".to_string(), + ..MigrationProgressEvent::default() + }); + ( + session.roots.clone(), + session.request.clone(), + plan, + session.cancellation.clone(), + ) + }; + + let coordinator = self.clone(); + self.spawn_worker("legacy-data-migration", move || { + coordinator.execute_background(roots, request, plan, cancellation); + }) + } + + pub(crate) fn cancel(&self) -> MigratorView { + let mut session = self.lock(); + session.cancellation.cancel(); + if let Some(progress) = &mut session.progress { + progress.code = if progress.safe_to_cancel { + "cancellation_requested".to_string() + } else { + "cancellation_pending_safe_boundary".to_string() + }; + } + snapshot(&session) + } + + pub(crate) fn is_running(&self) -> bool { + self.lock().running + } + + pub(crate) fn finish_and_restart( + &self, + choice: MigrationPromptChoice, + ) -> Result<(), CommandError> { + if self.is_running() { + return Err(CommandError::operation_in_progress()); + } + if choice == MigrationPromptChoice::Unset { + return Err(CommandError::new( + "invalid_prompt_choice", + "Choose whether to migrate now, be reminded later, or stop reminders.", + true, + )); + } + if choice == MigrationPromptChoice::MigrateNow && self.lock().report.is_none() { + return Err(CommandError::new( + "migration_result_required", + "A completed or recoverable migration report is required before finishing.", + true, + )); + } + + let result = (|| { + self.persist_prompt_choice(choice)?; + match finish_action() { + FinishAction::CloseForDevRestart => Ok(()), + FinishAction::RestartDesktop => self.restart_desktop(), + } + })(); + if let Err(error) = result { + let mut session = self.lock(); + let command_error = self.finish_error_locked(&mut session, &error); + return Err(command_error); + } + Ok(()) + } + + pub(crate) fn close_and_restart(&self) -> Result<(), CommandError> { + let choice = if self.lock().report.is_some() { + MigrationPromptChoice::MigrateNow + } else { + MigrationPromptChoice::RemindLater + }; + self.finish_and_restart(choice) + } + + fn begin_operation( + &self, + selection: &MigrationSelection, + ) -> Result<(MigrationRoots, MigratorHandoffRequest, CancellationToken), CommandError> { + let mut session = self.lock(); + if session.running { + return Err(CommandError::operation_in_progress()); + } + validate_selection(&session.request, selection)?; + session.cancellation = CancellationToken::default(); + session.running = true; + session.error = None; + session.progress = Some(MigrationProgressEvent { + run_id: session.request.run_id.clone(), + phase: MigrationPhase::Scan, + processed: 0, + total: selection.expanded_domains().len() as u64, + safe_to_cancel: true, + code: "scanning_source".to_string(), + ..MigrationProgressEvent::default() + }); + Ok(( + session.roots.clone(), + session.request.clone(), + session.cancellation.clone(), + )) + } + + fn spawn_worker( + &self, + name: &str, + worker: impl FnOnce() + Send + 'static, + ) -> Result { + if std::thread::Builder::new() + .name(name.to_string()) + .spawn(worker) + .is_err() + { + let mut session = self.lock(); + session.running = false; + let error = CommandError::worker_failed(); + session.error = Some(error.clone()); + return Err(error); + } + + Ok(self.snapshot()) + } + + fn execute_background( + &self, + roots: MigrationRoots, + request: MigratorHandoffRequest, + plan: MigrationPlan, + cancellation: CancellationToken, + ) { + loop { + if cancellation.is_cancelled() { + self.finish_cancelled_before_execution(&plan); + return; + } + match writer_processes(request.caller_process_id) { + Ok(blockers) => { + let done = blockers.is_empty(); + let mut session = self.lock(); + session.blockers = blockers; + if let Some(progress) = &mut session.progress { + progress.code = if done { + "writer_processes_stopped".to_string() + } else { + "waiting_for_writer_processes".to_string() + }; + } + drop(session); + if done { + break; + } + } + Err(error) => { + let mut session = self.lock(); + session.running = false; + self.finish_error_locked(&mut session, &error); + return; + } + } + std::thread::sleep(Duration::from_millis(500)); + } + + let engine = match migration_engine(roots.clone(), &plan.selection) { + Ok(engine) => engine, + Err(error) => { + let mut session = self.lock(); + session.running = false; + self.finish_error_locked(&mut session, &error); + return; + } + }; + let coordinator = self.clone(); + let result = engine.execute_with_progress( + &plan, + &cancellation, + &NoCrashInjection, + move |progress| coordinator.record_progress(progress), + ); + + let mut session = self.lock(); + session.running = false; + match result { + Ok(report) => { + session.status = report.status; + session.report = Some(report); + session.error = None; + drop(session); + let _ = self.persist_prompt_choice(MigrationPromptChoice::MigrateNow); + } + Err(error) => { + let layout = MigrationLayout::new(&roots, &plan.run_id); + if let Ok(Some(report)) = + layout.read_json::(&layout.report_path()) + { + session.status = report.status; + session.report = Some(report); + } else if matches!(error, LegacyMigrationError::Cancelled) { + session.status = MigrationRunStatus::Cancelled; + } + self.finish_error_locked(&mut session, &error); + } + } + } + + fn finish_cancelled_before_execution(&self, plan: &MigrationPlan) { + let mut session = self.lock(); + session.running = false; + session.status = MigrationRunStatus::Cancelled; + session.progress = Some(MigrationProgressEvent { + run_id: plan.run_id.clone(), + phase: MigrationPhase::Acquire, + processed: 0, + total: plan.steps.len() as u64, + safe_to_cancel: true, + code: "migration_cancelled".to_string(), + ..MigrationProgressEvent::default() + }); + session.error = Some(CommandError::from_legacy(&LegacyMigrationError::Cancelled)); + } + + fn record_progress(&self, progress: MigrationProgressEvent) { + let mut session = self.lock(); + session.status = status_for_phase(progress.phase); + session.progress = Some(progress); + } + + fn persist_prompt_choice(&self, choice: MigrationPromptChoice) -> LegacyMigrationResult<()> { + let session = self.lock(); + let store = MigrationOnboardingStore::new(session.roots.clone()); + let request = &session.request; + let source = session.source.as_ref(); + let has_report = session.report.is_some(); + store.update(|state| { + state.format_version = CURRENT_MIGRATION_FORMAT_VERSION; + if let Some(source) = source { + state.source_fingerprint = source.source_fingerprint.clone(); + state.detected_at_ms.get_or_insert_with(now_ms); + } + state.choice = choice; + state.last_prompted_version = Some(env!("CARGO_PKG_VERSION").to_string()); + state.run_id = Some(request.run_id.clone()); + state.handled_run_id = Some(request.run_id.clone()); + if has_report { + state.last_report_run_id = Some(request.run_id.clone()); + } + })?; + Ok(()) + } + + fn restart_desktop(&self) -> LegacyMigrationResult<()> { + let run_id = self.lock().request.run_id.clone(); + let current = std::env::current_exe().map_err(|error| LegacyMigrationError::Io { + path: Path::new(DATA_MIGRATOR_BINARY_NAME).to_path_buf(), + source: error, + })?; + let executable = TrustedInstallationResolver::resolve_sibling( + ¤t, + DATA_MIGRATOR_BINARY_NAME, + DESKTOP_BINARY_NAME, + )?; + let arguments = [OsStr::new("--legacy-migration-run-id"), OsStr::new(&run_id)]; + launch_trusted_executable(&executable, &arguments)?; + Ok(()) + } + + fn finish_error_locked( + &self, + session: &mut MigratorSession, + error: &LegacyMigrationError, + ) -> CommandError { + session.running = false; + if matches!(error, LegacyMigrationError::Cancelled) { + session.status = MigrationRunStatus::Cancelled; + if let Some(progress) = &mut session.progress { + progress.safe_to_cancel = true; + progress.code = "migration_cancelled".to_string(); + } + } + let command_error = session + .report + .as_ref() + .and_then(CommandError::from_report_failure) + .unwrap_or_else(|| CommandError::from_legacy(error)); + session.error = Some(command_error.clone()); + command_error + } + + fn lock(&self) -> MutexGuard<'_, MigratorSession> { + self.session + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } +} + +fn migration_engine( + roots: MigrationRoots, + selection: &MigrationSelection, +) -> LegacyMigrationResult { + MigrationEngine::new(roots, adapters_for_groups(selection)) +} + +fn writer_processes(caller_process_id: u32) -> LegacyMigrationResult> { + blocking_writer_processes_for_product(caller_process_id, &[DESKTOP_BINARY_NAME]) +} + +fn validate_selection( + request: &MigratorHandoffRequest, + selection: &MigrationSelection, +) -> Result<(), CommandError> { + if selection.groups.is_empty() { + return Err(CommandError::new( + "empty_selection", + "Select at least one migration group.", + true, + )); + } + if request.mode == MigratorRequestMode::Execute && request.selection != *selection { + return Err(CommandError::new( + "selection_mismatch", + "The selected groups differ from the scope confirmed in OpenBitFun.", + false, + )); + } + Ok(()) +} + +fn probe_bound_source( + roots: &MigrationRoots, + request: &MigratorHandoffRequest, +) -> LegacyMigrationResult> { + let source = probe_legacy_source(roots, ProbeLimits::default())?; + if let Some(source) = &source { + if request + .source_id + .as_deref() + .is_some_and(|source_id| source_id != source.source_id) + || request + .source_fingerprint + .as_deref() + .is_some_and(|fingerprint| fingerprint != source.source_fingerprint) + { + return Err(LegacyMigrationError::InvalidRequest( + "discovered source does not match the authenticated handoff".to_string(), + )); + } + } else if request.source_id.is_some() || request.source_fingerprint.is_some() { + return Err(LegacyMigrationError::InvalidRequest( + "authenticated handoff source is no longer present".to_string(), + )); + } + Ok(source) +} + +fn snapshot(session: &MigratorSession) -> MigratorView { + let plan = session.plan.as_ref().map(redact_plan_for_ui); + let report = session.report.as_ref().map(redact_report_for_ui); + MigratorView { + delivery_profile: DeliveryProfile::DataMigrator.id().to_string(), + restart_desktop_on_finish: finish_action() == FinishAction::RestartDesktop, + protocol: MigratorProtocolCapabilities::current(), + mode: session.request.mode, + source: session.source.clone(), + selection: session.selection.clone(), + findings: session + .findings + .iter() + .cloned() + .map(redact_finding_for_ui) + .collect(), + can_execute: plan.is_some() + && session + .source + .as_ref() + .is_some_and(|source| source.supported) + && !session.running, + plan, + report, + progress: session.progress.clone(), + blockers: session.blockers.clone(), + status: session.status, + running: session.running, + recovery: session.disposition == HandoffDisposition::Recovery, + error: session.error.clone(), + } +} + +fn redact_finding_for_ui(mut finding: ScanFinding) -> ScanFinding { + finding.detail = finding.code.replace('_', " "); + finding +} + +fn redact_plan_for_ui(plan: &MigrationPlan) -> MigrationPlan { + let mut redacted = plan.clone(); + redacted.findings = redacted + .findings + .into_iter() + .map(redact_finding_for_ui) + .collect(); + for conflict in &mut redacted.conflicts { + conflict.source_summary = "Legacy item".to_string(); + conflict.target_summary = "Existing OpenBitFun item".to_string(); + } + redacted +} + +fn redact_report_for_ui(report: &MigrationRunReport) -> MigrationRunReport { + let mut redacted = report.clone(); + for diagnostic in &mut redacted.diagnostics { + diagnostic.relative_path = None; + diagnostic.message = diagnostic.code.replace('_', " "); + diagnostic.action = None; + } + for result in &mut redacted.domain_results { + for warning in &mut result.warnings { + warning.relative_path = None; + warning.message = warning.code.replace('_', " "); + warning.action = None; + } + } + redacted +} + +fn status_for_phase(phase: MigrationPhase) -> MigrationRunStatus { + match phase { + MigrationPhase::Discover => MigrationRunStatus::Discovered, + MigrationPhase::Scan => MigrationRunStatus::Scanned, + MigrationPhase::Plan => MigrationRunStatus::Planned, + MigrationPhase::Acquire => MigrationRunStatus::WaitingForProcesses, + MigrationPhase::Stage => MigrationRunStatus::Staging, + MigrationPhase::ValidateStage => MigrationRunStatus::ValidatingStage, + MigrationPhase::Commit => MigrationRunStatus::Committing, + MigrationPhase::ValidateCommit => MigrationRunStatus::ValidatingCommit, + MigrationPhase::Finalize => MigrationRunStatus::Completed, + } +} + +fn now_ms() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() + .try_into() + .unwrap_or(i64::MAX) +} + +#[cfg(test)] +mod tests { + use super::*; + use openbitfun_product_domains::legacy_migration::{ + MigratorProtocolCapability, MigratorRequestOrigin, CURRENT_MIGRATOR_PROTOCOL_VERSION, + }; + use std::collections::BTreeSet; + use std::fs; + + fn fixture_roots(root: &Path) -> MigrationRoots { + 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/skills"), + target_ssh_root: root.join("target/ssh"), + } + } + + fn handoff_request() -> MigratorHandoffRequest { + let current = now_ms(); + MigratorHandoffRequest { + protocol_version: CURRENT_MIGRATOR_PROTOCOL_VERSION, + mode: MigratorRequestMode::Onboarding, + origin: MigratorRequestOrigin::FirstLaunch, + run_id: uuid::Uuid::new_v4().to_string(), + nonce: uuid::Uuid::new_v4().to_string(), + selection: MigrationSelection::all(), + caller_process_id: u32::MAX, + product_id: "openbitfun".to_string(), + release_channel: "stable".to_string(), + created_at_ms: current, + expires_at_ms: current + 60_000, + required_capabilities: BTreeSet::from([ + MigratorProtocolCapability::ReadOnlyScan, + MigratorProtocolCapability::JournalRecovery, + ]), + ..MigratorHandoffRequest::default() + } + } + + fn write_probe_fixture(roots: &MigrationRoots) { + let config = roots.legacy_user_root.join("config"); + fs::create_dir_all(&config).unwrap(); + fs::write(config.join("app.json"), br#"{"version":"0.2.19"}"#).unwrap(); + } + + #[test] + fn bootstrap_consumes_the_real_non_agent_delivery_profile() { + let temporary = tempfile::tempdir().unwrap(); + let roots = fixture_roots(temporary.path()); + write_probe_fixture(&roots); + let request = handoff_request(); + HandoffStore::new(roots.clone(), "openbitfun", "stable") + .write_request(&request, now_ms()) + .unwrap(); + + let coordinator = + MigratorCoordinator::bootstrap_with(&request.run_id, roots, "openbitfun", "stable") + .unwrap(); + let view = coordinator.snapshot(); + + assert_eq!(view.delivery_profile, "data-migrator"); + assert!(!view.restart_desktop_on_finish); + assert_eq!(view.mode, MigratorRequestMode::Onboarding); + assert!(view.source.is_some()); + assert!(!view.recovery); + } + + #[test] + fn execute_handoff_rejects_a_scope_change() { + let mut request = handoff_request(); + request.mode = MigratorRequestMode::Execute; + let mut changed = request.selection.clone(); + changed + .groups + .remove(&openbitfun_product_domains::legacy_migration::MigrationGroupId::Memory); + + let error = validate_selection(&request, &changed).unwrap_err(); + assert_eq!(error.code, "selection_mismatch"); + } + + #[test] + fn cancelled_scan_finishes_in_an_explicit_cancelled_state() { + let temporary = tempfile::tempdir().unwrap(); + let roots = fixture_roots(temporary.path()); + write_probe_fixture(&roots); + let request = handoff_request(); + HandoffStore::new(roots.clone(), "openbitfun", "stable") + .write_request(&request, now_ms()) + .unwrap(); + let coordinator = MigratorCoordinator::bootstrap_with( + &request.run_id, + roots.clone(), + "openbitfun", + "stable", + ) + .unwrap(); + let selection = MigrationSelection::all(); + let (roots, request, cancellation) = coordinator.begin_operation(&selection).unwrap(); + cancellation.cancel(); + coordinator.scan_background(roots, request, selection, cancellation); + + let view = coordinator.snapshot(); + assert!(!view.running); + assert_eq!(view.status, MigrationRunStatus::Cancelled); + assert_eq!( + view.error.map(|error| error.code).as_deref(), + Some("cancelled") + ); + assert_eq!( + view.progress.map(|progress| progress.code).as_deref(), + Some("migration_cancelled") + ); + } + + #[test] + fn command_errors_do_not_expose_storage_paths() { + let error = LegacyMigrationError::Io { + path: Path::new("C:/Users/private/secret.json").to_path_buf(), + source: std::io::Error::new(std::io::ErrorKind::PermissionDenied, "secret"), + }; + let command = CommandError::from_legacy(&error); + let serialized = serde_json::to_string(&command).unwrap(); + + assert_eq!(command.code, "storage_failed"); + assert!(!serialized.contains("private")); + assert!(!serialized.contains("secret")); + } + + #[test] + fn report_failure_errors_use_sanitized_domain_diagnostics() { + let report = MigrationRunReport { + diagnostics: vec![ + openbitfun_product_domains::legacy_migration::MigrationDiagnostic { + code: "domain_io_permission_denied".to_string(), + severity: FindingSeverity::Blocking, + domain: Some( + openbitfun_product_domains::legacy_migration::MigrationDomainId::WorkspaceSessions, + ), + relative_path: Some("C:/Users/private/session-state.json".to_string()), + message: "A migration-owned file or directory denied access.".to_string(), + action: Some("Close programs using the data, check permissions, and retry.".to_string()), + }, + ], + ..MigrationRunReport::default() + }; + + let command = CommandError::from_report_failure(&report).unwrap(); + let serialized = serde_json::to_string(&command).unwrap(); + + assert_eq!(command.code, "domain_io_permission_denied"); + assert!(command.message.contains("check permissions")); + assert!(!serialized.contains("private")); + assert!(!serialized.contains("session-state")); + } + + #[test] + fn finish_action_preserves_release_restart_and_closes_debug() { + assert_eq!(finish_action(), FinishAction::CloseForDevRestart); + assert_eq!(finish_action_for_build(false), FinishAction::RestartDesktop); + } +} diff --git a/src/apps/data-migrator/src/commands.rs b/src/apps/data-migrator/src/commands.rs new file mode 100644 index 0000000000..be2dd043da --- /dev/null +++ b/src/apps/data-migrator/src/commands.rs @@ -0,0 +1,97 @@ +use crate::app_state::{CommandError, DiagnosticsExportView, MigratorCoordinator, MigratorView}; +use openbitfun_product_domains::legacy_migration::{MigrationPromptChoice, MigrationSelection}; +use serde::Deserialize; +use tauri::{AppHandle, State}; + +#[derive(Debug, Default, Deserialize)] +#[serde(default, deny_unknown_fields, rename_all = "camelCase")] +pub(crate) struct EmptyRequest {} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub(crate) struct SelectionRequest { + pub selection: MigrationSelection, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub(crate) struct ExecuteRequest { + pub plan_hash: String, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub(crate) struct PromptChoiceRequest { + pub choice: MigrationPromptChoice, +} + +#[tauri::command] +pub(crate) fn get_migrator_bootstrap( + state: State<'_, MigratorCoordinator>, + request: EmptyRequest, +) -> MigratorView { + let _ = request; + state.snapshot() +} + +#[tauri::command] +pub(crate) fn scan_legacy_migration( + state: State<'_, MigratorCoordinator>, + request: SelectionRequest, +) -> Result { + state.scan(request.selection) +} + +#[tauri::command] +pub(crate) fn prepare_legacy_migration( + state: State<'_, MigratorCoordinator>, + request: SelectionRequest, +) -> Result { + state.prepare(request.selection) +} + +#[tauri::command] +pub(crate) fn retry_writer_check( + state: State<'_, MigratorCoordinator>, + request: EmptyRequest, +) -> Result { + let _ = request; + state.refresh_blockers() +} + +#[tauri::command] +pub(crate) fn start_legacy_migration( + state: State<'_, MigratorCoordinator>, + request: ExecuteRequest, +) -> Result { + state.start(request.plan_hash) +} + +#[tauri::command] +pub(crate) fn cancel_legacy_migration( + state: State<'_, MigratorCoordinator>, + request: EmptyRequest, +) -> MigratorView { + let _ = request; + state.cancel() +} + +#[tauri::command] +pub(crate) fn export_migration_diagnostics( + state: State<'_, MigratorCoordinator>, + request: EmptyRequest, +) -> Result { + let _ = request; + state.export_diagnostics() +} + +#[tauri::command] +pub(crate) fn finish_legacy_migration( + app: AppHandle, + state: State<'_, MigratorCoordinator>, + request: PromptChoiceRequest, +) -> Result<(), CommandError> { + state.finish_and_restart(request.choice)?; + app.exit(0); + Ok(()) +} diff --git a/src/apps/data-migrator/src/lib.rs b/src/apps/data-migrator/src/lib.rs new file mode 100644 index 0000000000..64894deb98 --- /dev/null +++ b/src/apps/data-migrator/src/lib.rs @@ -0,0 +1,54 @@ +mod app_state; +mod commands; + +use app_state::MigratorCoordinator; +use std::fmt; +use tauri::Manager; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RunError { + Bootstrap, + EventLoop, +} + +impl fmt::Display for RunError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Bootstrap => formatter.write_str("Data Migrator bootstrap failed"), + Self::EventLoop => formatter.write_str("Data Migrator event loop failed"), + } + } +} + +impl std::error::Error for RunError {} + +pub fn run(run_id: &str) -> Result<(), RunError> { + let coordinator = MigratorCoordinator::bootstrap(run_id).map_err(|_| RunError::Bootstrap)?; + tauri::Builder::default() + .manage(coordinator) + .on_window_event(|window, event| { + if let tauri::WindowEvent::CloseRequested { api, .. } = event { + let coordinator = window.app_handle().state::(); + api.prevent_close(); + if coordinator.is_running() { + coordinator.cancel(); + return; + } + if coordinator.close_and_restart().is_ok() { + window.app_handle().exit(0); + } + } + }) + .invoke_handler(tauri::generate_handler![ + commands::get_migrator_bootstrap, + commands::scan_legacy_migration, + commands::prepare_legacy_migration, + commands::retry_writer_check, + commands::start_legacy_migration, + commands::cancel_legacy_migration, + commands::export_migration_diagnostics, + commands::finish_legacy_migration, + ]) + .run(tauri::generate_context!()) + .map_err(|_| RunError::EventLoop) +} diff --git a/src/apps/data-migrator/src/main.rs b/src/apps/data-migrator/src/main.rs new file mode 100644 index 0000000000..d73be4545e --- /dev/null +++ b/src/apps/data-migrator/src/main.rs @@ -0,0 +1,21 @@ +#![cfg_attr(target_os = "windows", windows_subsystem = "windows")] + +use std::process::ExitCode; + +fn main() -> ExitCode { + let mut arguments = std::env::args_os().skip(1); + let Some(run_id) = arguments.next() else { + return ExitCode::from(2); + }; + if arguments.next().is_some() { + return ExitCode::from(2); + } + let Some(run_id) = run_id.to_str() else { + return ExitCode::from(2); + }; + + match openbitfun_data_migrator_lib::run(run_id) { + Ok(()) => ExitCode::SUCCESS, + Err(_) => ExitCode::from(1), + } +} diff --git a/src/apps/data-migrator/tauri.conf.json b/src/apps/data-migrator/tauri.conf.json new file mode 100644 index 0000000000..c7b678c1c6 --- /dev/null +++ b/src/apps/data-migrator/tauri.conf.json @@ -0,0 +1,41 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "productName": "OpenBitFun Data Migrator", + "mainBinaryName": "openbitfun-data-migrator", + "identifier": "com.openbitfun.data-migrator", + "build": { + "frontendDist": "ui" + }, + "bundle": { + "active": true, + "targets": "all", + "publisher": "OpenBitFun Team", + "icon": [ + "../desktop/icons/openbitfun-app-icon.icns", + "../desktop/icons/openbitfun-app-icon.ico", + "../desktop/icons/openbitfun-app-icon.png" + ], + "resources": { + "../../../THIRD_PARTY_NOTICES.md": "THIRD_PARTY_NOTICES.md" + } + }, + "app": { + "windows": [ + { + "label": "migrator", + "title": "OpenBitFun Data Migrator", + "width": 860, + "height": 680, + "minWidth": 680, + "minHeight": 540, + "resizable": true, + "maximizable": true, + "center": true + } + ], + "security": { + "csp": "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src ipc: http://ipc.localhost" + }, + "withGlobalTauri": true + } +} diff --git a/src/apps/data-migrator/ui/app.js b/src/apps/data-migrator/ui/app.js new file mode 100644 index 0000000000..df1ca5b99b --- /dev/null +++ b/src/apps/data-migrator/ui/app.js @@ -0,0 +1,328 @@ +const invoke = window.__TAURI__.core.invoke; + +const translations = { + en: { + eyebrow: 'OpenBitFun maintenance', title: 'Import data from BitFun', + intro: 'Choose what to bring forward. Your original BitFun data will not be deleted.', + stepSource: 'Step 1', sourceTitle: 'Legacy source', firstLaunch: 'First launch', + choiceTitle: 'What would you like to do?', + choiceHelp: 'Migration runs only after OpenBitFun and other data writers have stopped.', + migrateNow: 'Migrate now', remindLater: 'Remind me later', doNotRemind: 'Do not remind me', + stepScope: 'Step 2', scopeTitle: 'Choose migration scope', scan: 'Scan selected data', + stepReview: 'Step 3', reviewTitle: 'Review scan', prepare: 'Run preflight plan', + stepConfirm: 'Step 4', planTitle: 'Confirm migration', retryWriters: 'Check processes again', + start: 'Start migration', stepProgress: 'Step 5', progressTitle: 'Migration progress', + phase: 'Phase', domain: 'Domain', count: 'Completed steps', + cancel: 'Cancel', stepDone: 'Result', reportTitle: 'Migration report', + reportPrivacy: 'This summary contains counts and result codes, not credentials or user content.', + exportDiagnostics: 'Export failure diagnostics', + diagnosticsExported: 'Sanitized diagnostics saved to {path}', + openDesktop: 'Open OpenBitFun', ready: 'Ready', unsupported: 'Unsupported', missing: 'Not found', + closeMigrator: 'Close Data Migrator', + devRestartHelp: 'Development build: close Data Migrator, then run pnpm run desktop:dev again.', + bootstrapPending: 'Data Migrator is still loading. Please try again.', + bootstrapFailed: 'Data Migrator could not load its authenticated migration request.', + sourceFound: 'BitFun {version} was found. The source stays read-only.', + recovery: 'A previous migration journal was found and can be resumed.', + blockers: '{count} data-writing process(es) must stop before migration can continue.', + noBlockers: 'No data-writing processes are blocking migration.', + steps: '{count} migration step(s)', conflicts: '{count} conflict(s)', + imported: 'imported', staged: 'staged', skipped: 'skipped', warnings: 'warnings', + }, + 'zh-CN': { + eyebrow: 'OpenBitFun 数据维护', title: '从 BitFun 导入数据', + intro: '选择要迁移的内容。原始 BitFun 数据不会被删除。', stepSource: '第 1 步', + sourceTitle: '旧版数据来源', firstLaunch: '首次启动', choiceTitle: '你希望如何处理?', + choiceHelp: '迁移只会在 OpenBitFun 和其他数据写入进程停止后运行。', migrateNow: '立即迁移', + remindLater: '稍后提醒', doNotRemind: '不再提醒', stepScope: '第 2 步', + scopeTitle: '选择迁移范围', scan: '扫描所选数据', stepReview: '第 3 步', + reviewTitle: '检查扫描结果', prepare: '运行迁移预检', stepConfirm: '第 4 步', + planTitle: '确认迁移', retryWriters: '重新检查进程', start: '开始迁移', + stepProgress: '第 5 步', progressTitle: '迁移进度', phase: '阶段', domain: '领域', + count: '已完成步骤', cancel: '取消', stepDone: '结果', reportTitle: '迁移报告', + reportPrivacy: '此摘要仅包含计数和结果码,不包含凭据或用户正文。', + exportDiagnostics: '导出失败诊断', diagnosticsExported: '去敏诊断已保存到 {path}', + openDesktop: '打开 OpenBitFun', + closeMigrator: '关闭数据迁移器', + devRestartHelp: '开发版本:关闭数据迁移器,然后重新运行 pnpm run desktop:dev。', + bootstrapPending: '数据迁移器仍在加载,请稍后重试。', + bootstrapFailed: '数据迁移器无法加载已认证的迁移请求。', + ready: '可迁移', unsupported: '不受支持', missing: '未发现', + sourceFound: '已发现 BitFun {version}。迁移期间来源保持只读。', + recovery: '发现上次迁移日志,可以从安全状态继续。', + blockers: '迁移前还需停止 {count} 个数据写入进程。', noBlockers: '没有进程阻止迁移。', + steps: '{count} 个迁移步骤', conflicts: '{count} 个冲突', + imported: '已导入', staged: '已暂存', skipped: '已跳过', warnings: '警告', + }, + 'zh-TW': { + eyebrow: 'OpenBitFun 資料維護', title: '從 BitFun 匯入資料', + intro: '選擇要遷移的內容。原始 BitFun 資料不會被刪除。', stepSource: '第 1 步', + sourceTitle: '舊版資料來源', firstLaunch: '首次啟動', choiceTitle: '你希望如何處理?', + choiceHelp: '遷移只會在 OpenBitFun 和其他資料寫入程序停止後執行。', migrateNow: '立即遷移', + remindLater: '稍後提醒', doNotRemind: '不再提醒', stepScope: '第 2 步', + scopeTitle: '選擇遷移範圍', scan: '掃描所選資料', stepReview: '第 3 步', + reviewTitle: '檢查掃描結果', prepare: '執行遷移預檢', stepConfirm: '第 4 步', + planTitle: '確認遷移', retryWriters: '重新檢查程序', start: '開始遷移', + stepProgress: '第 5 步', progressTitle: '遷移進度', phase: '階段', domain: '領域', + count: '已完成步驟', cancel: '取消', stepDone: '結果', reportTitle: '遷移報告', + reportPrivacy: '此摘要僅包含計數和結果碼,不包含憑據或使用者正文。', + exportDiagnostics: '匯出失敗診斷', diagnosticsExported: '去敏診斷已儲存至 {path}', + openDesktop: '開啟 OpenBitFun', + closeMigrator: '關閉資料遷移器', + devRestartHelp: '開發版本:關閉資料遷移器,然後重新執行 pnpm run desktop:dev。', + bootstrapPending: '資料遷移器仍在載入,請稍後重試。', + bootstrapFailed: '資料遷移器無法載入已驗證的遷移請求。', + ready: '可遷移', unsupported: '不支援', missing: '未發現', + sourceFound: '已發現 BitFun {version}。遷移期間來源保持唯讀。', + recovery: '發現上次遷移日誌,可以從安全狀態繼續。', + blockers: '遷移前還需停止 {count} 個資料寫入程序。', noBlockers: '沒有程序阻止遷移。', + steps: '{count} 個遷移步驟', conflicts: '{count} 個衝突', + imported: '已匯入', staged: '已暫存', skipped: '已略過', warnings: '警告', + }, +}; + +const locale = navigator.language.startsWith('zh-TW') || navigator.language.startsWith('zh-HK') + ? 'zh-TW' + : navigator.language.startsWith('zh') ? 'zh-CN' : 'en'; +const text = translations[locale]; +document.documentElement.lang = locale; +document.querySelectorAll('[data-i18n]').forEach((node) => { + node.textContent = text[node.dataset.i18n] || translations.en[node.dataset.i18n]; +}); + +const groups = [ + ['settings_and_credentials', { + en: ['Settings and credentials', 'Settings are imported; credentials that cannot be decrypted are marked for sign-in.'], + 'zh-CN': ['设置与服务凭据', '导入设置;无法解密的凭据会标记为需要重新登录。'], + 'zh-TW': ['設定與服務憑據', '匯入設定;無法解密的憑據會標記為需要重新登入。'], + }], + ['agents_skills_and_miniapps', { + en: ['Agents, Skills, and MiniApps', 'Imports user extensions and saved data from built-in MiniApps. Built-in code is provided by OpenBitFun.'], + 'zh-CN': ['Agents、Skills 与 MiniApps', '导入用户扩展和内置 MiniApps 的使用数据;内置代码由新版提供。'], + 'zh-TW': ['Agents、Skills 與 MiniApps', '匯入使用者擴充與內建 MiniApps 的使用資料;內建程式碼由新版提供。'], + }], + ['workspaces_sessions_and_tasks', { + en: ['Workspaces, sessions, and tasks', 'Imports workspaces, conversation history, and Agent task status.'], + 'zh-CN': ['工作区、会话与 Agent 任务状态', '导入工作区、会话历史与 Agent 任务状态。'], + 'zh-TW': ['工作區、工作階段與 Agent 任務狀態', '匯入工作區、會話歷史與 Agent 任務狀態。'], + }], + ['memory', { + en: ['Memory', 'Imports memory databases and memory files.'], + 'zh-CN': ['记忆', '导入记忆数据库与记忆文件。'], + 'zh-TW': ['記憶', '匯入記憶資料庫與記憶檔案。'], + }], + ['remote_connections_and_devices', { + en: ['Remote connections and devices', 'Imports remote connections and device settings. Some connections may require signing in again.'], + 'zh-CN': ['远程连接与设备', '导入远程连接与设备设置;部分连接可能需要重新登录。'], + 'zh-TW': ['遠端連線與裝置', '匯入遠端連線與裝置設定;部分連線可能需要重新登入。'], + }], +]; + +let current; +let pollTimer; + +function format(template, values) { + return Object.entries(values).reduce((value, [key, replacement]) => + value.replace(`{${key}}`, String(replacement)), template); +} + +function show(id, visible = true) { + document.getElementById(id).hidden = !visible; +} + +function setBusy(busy) { + document.querySelectorAll('button').forEach((button) => { button.disabled = busy; }); +} + +function notice(message) { + const node = document.getElementById('notice'); + node.textContent = message || ''; + node.hidden = !message; +} + +function requireBootstrap() { + if (current) return true; + notice(text.bootstrapPending); + return false; +} + +function row(title, detail) { + const item = document.createElement('div'); + item.className = 'result-row'; + const strong = document.createElement('strong'); + strong.textContent = title; + const small = document.createElement('small'); + small.textContent = detail; + item.append(strong, small); + return item; +} + +function transferLabel(result) { + return result.state === 'verified' ? text.imported : text.staged; +} + +function renderScopes(selection) { + const list = document.getElementById('scope-list'); + list.replaceChildren(); + const selected = new Set(selection?.groups?.length ? selection.groups : groups.map(([id]) => id)); + groups.forEach(([id, labels]) => { + const option = document.createElement('div'); + option.className = 'scope-option'; + const checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + checkbox.id = `scope-${id}`; + checkbox.value = id; + checkbox.checked = selected.has(id); + if (current?.mode === 'execute') checkbox.disabled = true; + const label = document.createElement('label'); + label.htmlFor = checkbox.id; + const strong = document.createElement('strong'); + const description = document.createElement('span'); + [strong.textContent, description.textContent] = labels[locale] || labels.en; + label.append(strong, description); + option.append(checkbox, label); + list.append(option); + }); +} + +function selection() { + return { + groups: [...document.querySelectorAll('#scope-list input:checked')].map((input) => input.value), + }; +} + +function render(view) { + current = view; + const source = view.source; + document.getElementById('source-badge').textContent = !source + ? text.missing : source.supported ? text.ready : text.unsupported; + document.getElementById('source-summary').textContent = source + ? format(text.sourceFound, { version: source.productVersion }) : text.missing; + document.getElementById('source-path').textContent = source?.roots?.[0]?.displayPath || ''; + notice(view.error?.message || (view.recovery ? text.recovery : '')); + + show('choice-card', view.mode === 'onboarding' && !view.findings.length && !view.plan && !view.running); + show('scope-card', Boolean(source) && (view.mode === 'execute' || view.findings.length || view.plan)); + renderScopes(view.selection); + + const findings = document.getElementById('findings'); + findings.replaceChildren(...view.findings.map((finding) => + row(finding.code, `${finding.entityCount} item(s), ${finding.logicalBytes} byte(s)`))); + show('scan-card', view.findings.length > 0 && !view.plan); + + const planSummary = document.getElementById('plan-summary'); + if (view.plan) { + planSummary.replaceChildren( + row(text.steps.replace('{count}', view.plan.steps.length), view.plan.planHash), + row(text.conflicts.replace('{count}', view.plan.conflicts.length), `${view.plan.estimatedWriteBytes} byte(s)`), + ); + } + show('plan-card', Boolean(view.plan) && !view.running && !view.report); + const blocker = document.getElementById('blockers'); + blocker.textContent = view.blockers.length + ? format(text.blockers, { count: view.blockers.length }) : text.noBlockers; + blocker.hidden = !view.blockers.length; + show('retry-writers', view.blockers.length > 0); + + const progress = view.progress; + show('progress-card', Boolean(progress) && (view.running || view.status === 'cancelled')); + if (progress) { + document.getElementById('phase').textContent = progress.phase; + document.getElementById('domain').textContent = progress.domain || '-'; + document.getElementById('count').textContent = `${progress.processed} / ${progress.total}`; + document.getElementById('progress-message').textContent = progress.code.replaceAll('_', ' '); + document.getElementById('cancel').disabled = !view.running; + } + + const reportSummary = document.getElementById('report-summary'); + if (view.report) { + reportSummary.replaceChildren(...view.report.domainResults.map((result) => + row(result.domain, `${result.imported} ${transferLabel(result)}, ${result.skipped} ${text.skipped}, ${result.warnings.filter((item) => item.severity !== 'info').length} ${text.warnings}`))); + } + show('dev-restart-help', !view.restartDesktopOnFinish); + document.getElementById('open-desktop').textContent = view.restartDesktopOnFinish + ? text.openDesktop : text.closeMigrator; + show('report-card', !view.running && (Boolean(view.report) || view.status === 'cancelled')); + const canExportDiagnostics = ['failed_recoverable', 'failed_manual_action_required'].includes(view.status); + show('export-diagnostics', canExportDiagnostics); + if (!canExportDiagnostics) { + const output = document.getElementById('diagnostics-path'); + output.textContent = ''; + output.hidden = true; + } + document.getElementById('start').disabled = !view.canExecute; + + if (view.running && !pollTimer) { + pollTimer = window.setInterval(refresh, 500); + } else if (!view.running && pollTimer) { + window.clearInterval(pollTimer); + pollTimer = undefined; + } +} + +async function call(command, request = {}) { + setBusy(true); + try { + const result = await invoke(command, { request }); + if (result) render(result); + return result; + } catch (error) { + notice(error?.message || String(error)); + return undefined; + } finally { + setBusy(false); + if (current) render(current); + } +} + +async function refresh() { + try { + render(await invoke('get_migrator_bootstrap', { request: {} })); + } catch (error) { + const message = error?.message || (typeof error === 'string' ? error : ''); + notice(message || text.bootstrapFailed); + if (pollTimer) window.clearInterval(pollTimer); + pollTimer = undefined; + } +} + +document.getElementById('migrate-now').addEventListener('click', () => { + if (!requireBootstrap()) return; + show('choice-card', false); + show('scope-card'); + renderScopes(current.selection); +}); +document.getElementById('remind-later').addEventListener('click', () => + call('finish_legacy_migration', { choice: 'remind_later' })); +document.getElementById('do-not-remind').addEventListener('click', () => + call('finish_legacy_migration', { choice: 'do_not_remind' })); +document.getElementById('scan').addEventListener('click', () => + call('scan_legacy_migration', { selection: selection() })); +document.getElementById('prepare').addEventListener('click', () => + call('prepare_legacy_migration', { selection: selection() })); +document.getElementById('retry-writers').addEventListener('click', () => + call('retry_writer_check')); +document.getElementById('start').addEventListener('click', () => + call('start_legacy_migration', { planHash: current.plan.planHash })); +document.getElementById('cancel').addEventListener('click', () => + call('cancel_legacy_migration')); +document.getElementById('export-diagnostics').addEventListener('click', async () => { + setBusy(true); + try { + const result = await invoke('export_migration_diagnostics', { request: {} }); + const output = document.getElementById('diagnostics-path'); + output.textContent = format(text.diagnosticsExported, { path: result.filePath }); + output.hidden = false; + } catch (error) { + notice(error?.message || String(error)); + } finally { + setBusy(false); + if (current) render(current); + } +}); +document.getElementById('open-desktop').addEventListener('click', () => + call('finish_legacy_migration', { choice: current.report ? 'migrate_now' : 'remind_later' })); + +refresh().then(() => { + if (current?.mode === 'execute') show('scope-card'); +}); diff --git a/src/apps/data-migrator/ui/index.html b/src/apps/data-migrator/ui/index.html new file mode 100644 index 0000000000..38dd720570 --- /dev/null +++ b/src/apps/data-migrator/ui/index.html @@ -0,0 +1,108 @@ + + + + + + OpenBitFun Data Migrator + + + + +
+
+

OpenBitFun maintenance

+

Import data from BitFun

+

+ Choose what to bring forward. Your original BitFun data will not be deleted. +

+
+ + + +
+
+
+

Step 1

+

Legacy source

+
+ Checking +
+

+

+
+ + + + + + + + + + + + +
+ + diff --git a/src/apps/data-migrator/ui/styles.css b/src/apps/data-migrator/ui/styles.css new file mode 100644 index 0000000000..226d1f27ee --- /dev/null +++ b/src/apps/data-migrator/ui/styles.css @@ -0,0 +1,224 @@ +:root { + color-scheme: light dark; + font-family: Inter, "Segoe UI", system-ui, sans-serif; + --surface: Canvas; + --surface-raised: color-mix(in srgb, Canvas 94%, CanvasText 6%); + --text: CanvasText; + --muted: color-mix(in srgb, CanvasText 64%, transparent); + --border: color-mix(in srgb, CanvasText 18%, transparent); + --accent: AccentColor; + --accent-text: AccentColorText; + --warning: color-mix(in srgb, Mark 18%, Canvas); + --focus: AccentColor; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + background: var(--surface); + color: var(--text); +} + +.shell { + width: min(760px, calc(100% - 40px)); + margin: 0 auto; + padding: 44px 0 56px; +} + +header { + margin-bottom: 26px; +} + +h1, +h2, +p { + margin-top: 0; +} + +h1 { + margin-bottom: 10px; + font-size: clamp(28px, 5vw, 42px); + line-height: 1.1; +} + +h2 { + margin-bottom: 8px; + font-size: 20px; +} + +.eyebrow, +.step { + margin-bottom: 8px; + color: var(--accent); + font-size: 12px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.lead, +.muted { + color: var(--muted); + line-height: 1.55; +} + +.card, +.notice { + margin-bottom: 16px; + padding: 22px; + border: 1px solid var(--border); + border-radius: 14px; + background: var(--surface-raised); +} + +.notice { + border-color: var(--accent); +} + +.card-heading, +.result-row, +.scope-option { + display: flex; + justify-content: space-between; + gap: 16px; +} + +.badge { + align-self: flex-start; + padding: 5px 9px; + border-radius: 999px; + background: var(--border); + font-size: 12px; + font-weight: 700; +} + +.path { + overflow-wrap: anywhere; + color: var(--muted); + font-family: ui-monospace, "Cascadia Mono", monospace; + font-size: 12px; +} + +.scope-list, +.result-list { + display: grid; + gap: 10px; + margin: 18px 0; +} + +.scope-option, +.result-row { + padding: 14px; + border: 1px solid var(--border); + border-radius: 10px; + background: var(--surface); +} + +.scope-option label { + flex: 1; + cursor: pointer; +} + +.scope-option strong, +.scope-option span { + display: block; +} + +.scope-option span, +.result-row small { + margin-top: 4px; + color: var(--muted); + line-height: 1.4; +} + +input[type="checkbox"] { + width: 20px; + height: 20px; + accent-color: var(--accent); +} + +.actions { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-top: 18px; +} + +button { + min-height: 40px; + padding: 9px 15px; + border: 1px solid var(--border); + border-radius: 9px; + background: var(--surface); + color: var(--text); + cursor: pointer; + font: inherit; + font-weight: 650; +} + +button.primary { + border-color: var(--accent); + background: var(--accent); + color: var(--accent-text); +} + +button.quiet { + border-color: transparent; + background: transparent; +} + +button:disabled { + cursor: wait; + opacity: 0.55; +} + +button:focus-visible, +input:focus-visible { + outline: 3px solid var(--focus); + outline-offset: 2px; +} + +.warning { + margin-top: 14px; + padding: 14px; + border-radius: 10px; + background: var(--warning); + line-height: 1.5; +} + +.progress-facts { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; +} + +.progress-facts div { + padding: 12px; + border: 1px solid var(--border); + border-radius: 10px; +} + +.progress-facts dt { + color: var(--muted); + font-size: 12px; +} + +.progress-facts dd { + margin: 6px 0 0; + overflow-wrap: anywhere; + font-weight: 700; +} + +@media (max-width: 680px) { + .shell { + width: min(100% - 24px, 760px); + padding-top: 24px; + } + + .progress-facts { + grid-template-columns: 1fr; + } +} diff --git a/src/apps/desktop/Cargo.toml b/src/apps/desktop/Cargo.toml index 634c939780..8b9279c212 100644 --- a/src/apps/desktop/Cargo.toml +++ b/src/apps/desktop/Cargo.toml @@ -23,7 +23,8 @@ openbitfun-core = { path = "../../crates/assembly/core", features = ["product-fu openbitfun-relay-service = { path = "../../crates/services/relay-service" } openbitfun-agent-runtime = { path = "../../crates/execution/agent-runtime", features = ["agent-runtime"] } openbitfun-runtime-ports = { path = "../../crates/contracts/runtime-ports", features = ["agent-api", "permission", "workspace-ports"] } -openbitfun-product-domains = { path = "../../crates/contracts/product-domains", features = ["appearance-market"] } +openbitfun-product-domains = { path = "../../crates/contracts/product-domains", features = ["appearance-market", "legacy-migration"] } +openbitfun-legacy-migration = { path = "../../crates/services/legacy-migration" } openbitfun-services-integrations = { path = "../../crates/services/services-integrations", features = ["canvas-runtime", "miniapp-market", "remote-ssh-concrete", "speech-realtime"] } openbitfun-core-types = { path = "../../crates/contracts/core-types" } openbitfun-agent-tools = { path = "../../crates/execution/tool-contracts", features = ["element-token"] } @@ -33,7 +34,7 @@ openbitfun-webdriver = { path = "../../crates/adapters/webdriver" } openbitfun-acp = { path = "../../crates/interfaces/acp", default-features = false, features = ["client"] } # Tauri -tauri = { workspace = true } +tauri = { workspace = true, features = ["unstable", "macos-private-api", "tray-icon"] } tauri-plugin-opener = { workspace = true } tauri-plugin-dialog = { workspace = true } tauri-plugin-fs = { workspace = true } diff --git a/src/apps/desktop/build.rs b/src/apps/desktop/build.rs index b602fe0cbf..67ac4082d4 100644 --- a/src/apps/desktop/build.rs +++ b/src/apps/desktop/build.rs @@ -1,5 +1,7 @@ fn main() { println!("cargo:rerun-if-env-changed=OPENBITFUN_RELEASE_CHANNEL"); + println!("cargo:rerun-if-env-changed=OPENBITFUN_DESKTOP_BINARY_NAME"); + println!("cargo:rerun-if-env-changed=OPENBITFUN_DATA_MIGRATOR_BINARY_NAME"); println!("cargo:rerun-if-env-changed=OPENBITFUN_UPDATER_PRIMARY_ENDPOINT"); println!("cargo:rerun-if-env-changed=OPENBITFUN_UPDATER_FALLBACK_ENDPOINT"); // The Windows primary thread keeps the Tauri event loop and native window diff --git a/src/apps/desktop/src/api/legacy_migration_api.rs b/src/apps/desktop/src/api/legacy_migration_api.rs new file mode 100644 index 0000000000..6107cf2c01 --- /dev/null +++ b/src/apps/desktop/src/api/legacy_migration_api.rs @@ -0,0 +1,685 @@ +//! Local-only product entry points for the standalone legacy Data Migrator. +//! +//! This module may inspect the retired BitFun roots and create authenticated +//! handoffs. It never writes migrated product data; only the sibling Data +//! Migrator executes a plan after Desktop has shut down. + +use openbitfun_core::legacy_migration::adapters_for_groups; +use openbitfun_core_types::product_identity::product_id; +use openbitfun_legacy_migration::{ + launch_trusted_executable, probe_legacy_source, CancellationToken, HandoffStore, + LegacyMigrationError, LegacyMigrationResult, MigrationEngine, MigrationOnboardingStore, + MigrationRoots, ProbeLimits, TrustedInstallationResolver, +}; +use openbitfun_product_domains::legacy_migration::{ + LegacySourceDescriptor, MigrationOnboardingState, MigrationPromptChoice, MigrationRunReport, + MigrationSelection, MigratorHandoffRequest, MigratorProtocolCapabilities, MigratorRequestMode, + MigratorRequestOrigin, ScanFinding, CURRENT_MIGRATION_FORMAT_VERSION, +}; +use serde::{Deserialize, Serialize}; +use std::ffi::{OsStr, OsString}; +use std::sync::OnceLock; +use std::time::{SystemTime, UNIX_EPOCH}; +use tauri::AppHandle; + +const RELEASE_CHANNEL: &str = match option_env!("OPENBITFUN_RELEASE_CHANNEL") { + Some(value) => value, + None => "stable", +}; +const DESKTOP_BINARY_NAME: &str = match option_env!("OPENBITFUN_DESKTOP_BINARY_NAME") { + Some(value) => value, + None => "openbitfun-desktop", +}; +const DATA_MIGRATOR_BINARY_NAME: &str = match option_env!("OPENBITFUN_DATA_MIGRATOR_BINARY_NAME") { + Some(value) => value, + None => "openbitfun-data-migrator", +}; +const HANDOFF_LIFETIME_MS: i64 = 10 * 60 * 1000; + +static STARTUP_MIGRATION_STATE: OnceLock = OnceLock::new(); + +#[derive(Debug, Clone, Default)] +struct StartupMigrationState { + handled_run_id: Option, + startup_error: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum StartupProbeDisposition { + Continue { + handled_run_id: Option, + startup_error: Option, + }, + MigratorLaunched, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LegacyMigrationCommandError { + pub code: String, + pub message: String, + pub recoverable: bool, +} + +impl LegacyMigrationCommandError { + fn new(code: &str, message: &str, recoverable: bool) -> Self { + Self { + code: code.to_string(), + message: message.to_string(), + recoverable, + } + } + + fn from_legacy(error: &LegacyMigrationError) -> Self { + match error { + LegacyMigrationError::UnsupportedSource(_) => Self::new( + "unsupported_source", + "The discovered BitFun data format is not supported by this Data Migrator.", + false, + ), + LegacyMigrationError::InvalidRequest(_) | LegacyMigrationError::InvalidPlan(_) => { + Self::new( + "invalid_migration_request", + "The migration request is invalid or no longer current.", + true, + ) + } + LegacyMigrationError::UntrustedExecutable(_) + | LegacyMigrationError::TrustedInstallationUnavailable(_) => Self::new( + "data_migrator_unavailable", + "The installed Data Migrator is missing or failed installation layout checks. Repair or update this OpenBitFun installation.", + false, + ), + LegacyMigrationError::LockUnavailable => Self::new( + "migration_locked", + "Another Data Migrator currently owns the migration lock.", + true, + ), + LegacyMigrationError::PathUnavailable(_) + | LegacyMigrationError::SourceEqualsTarget(_) + | LegacyMigrationError::PathEscape(_) + | LegacyMigrationError::LinkedPath(_) + | LegacyMigrationError::ResourceLimit(_) => Self::new( + "migration_storage_unavailable", + "The local migration storage or retired BitFun source failed a safety check.", + false, + ), + LegacyMigrationError::ProcessInspection(_) => Self::new( + "process_inspection_failed", + "OpenBitFun could not inspect local data-writing processes.", + true, + ), + LegacyMigrationError::Cancelled => Self::new( + "cancelled", + "The read-only migration operation was cancelled.", + true, + ), + LegacyMigrationError::InjectedCrash(_) + | LegacyMigrationError::Domain { .. } + | LegacyMigrationError::Io { .. } + | LegacyMigrationError::Json { .. } + | LegacyMigrationError::Sqlite { .. } => Self::new( + "migration_operation_failed", + "OpenBitFun could not safely inspect or prepare the local migration state.", + true, + ), + } + } + + fn from_startup_launch(error: &LegacyMigrationError) -> Self { + match error { + LegacyMigrationError::UntrustedExecutable(_) + | LegacyMigrationError::TrustedInstallationUnavailable(_) => Self::from_legacy(error), + _ => Self::new( + "data_migrator_launch_failed", + "The Data Migrator could not be started safely. OpenBitFun continued without changing legacy BitFun data.", + true, + ), + } + } +} + +impl From for LegacyMigrationCommandError { + fn from(error: LegacyMigrationError) -> Self { + Self::from_legacy(&error) + } +} + +#[derive(Debug, Default, Deserialize)] +#[serde(default, deny_unknown_fields, rename_all = "camelCase")] +pub struct EmptyLegacyMigrationRequest {} + +#[derive(Debug, Default, Deserialize)] +#[serde(default, deny_unknown_fields, rename_all = "camelCase")] +pub struct ScanLegacyMigrationRequest { + pub selection: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct PrepareLegacyMigrationRequest { + pub selection: MigrationSelection, +} + +#[derive(Debug, Default, Deserialize)] +#[serde(default, deny_unknown_fields, rename_all = "camelCase")] +pub struct GetLegacyMigrationReportRequest { + pub run_id: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct SetLegacyMigrationPromptPreferenceRequest { + pub choice: MigrationPromptChoice, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LegacyMigrationStatusView { + pub source: Option, + pub onboarding: MigrationOnboardingState, + pub latest_report: Option, + pub startup_report: Option, + pub startup_error: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LegacyMigrationScanView { + pub source: LegacySourceDescriptor, + pub selection: MigrationSelection, + pub scanned_at_ms: i64, + pub findings: Vec, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LegacyMigrationHandoffView { + pub run_id: String, + pub mode: MigratorRequestMode, +} + +pub(crate) fn run_startup_probe() -> LegacyMigrationResult { + let roots = MigrationRoots::resolve_current_user()?; + let onboarding = MigrationOnboardingStore::new(roots.clone()); + let restart_run_id = migration_restart_run_id(std::env::args_os().skip(1)); + if let Some(run_id) = restart_run_id { + if onboarding.consume_handled_run_id(&run_id)? { + return Ok(StartupProbeDisposition::Continue { + handled_run_id: Some(run_id), + startup_error: None, + }); + } + } + + let state = onboarding.load()?; + let Some(source) = probe_legacy_source(&roots, ProbeLimits::default())? else { + return Ok(StartupProbeDisposition::Continue { + handled_run_id: None, + startup_error: None, + }); + }; + if !should_offer_onboarding(&state, &source) { + return Ok(StartupProbeDisposition::Continue { + handled_run_id: None, + startup_error: None, + }); + } + + let request = new_handoff_request( + MigratorRequestMode::Onboarding, + MigratorRequestOrigin::FirstLaunch, + &source, + MigrationSelection::all(), + ); + HandoffStore::new(roots.clone(), product_id(), RELEASE_CHANNEL) + .write_request(&request, now_ms())?; + onboarding.update(|state| { + state.format_version = CURRENT_MIGRATION_FORMAT_VERSION; + if state.source_fingerprint != source.source_fingerprint { + state.choice = MigrationPromptChoice::Unset; + } + state.source_fingerprint = source.source_fingerprint.clone(); + state.detected_at_ms.get_or_insert_with(now_ms); + state.last_prompted_version = Some(env!("CARGO_PKG_VERSION").to_string()); + state.run_id = Some(request.run_id.clone()); + state.handled_run_id = None; + })?; + match launch_data_migrator(&request.run_id) { + Ok(_) => Ok(StartupProbeDisposition::MigratorLaunched), + Err(error) => Ok(continue_after_startup_launch_failure(error)), + } +} + +fn continue_after_startup_launch_failure(error: LegacyMigrationError) -> StartupProbeDisposition { + StartupProbeDisposition::Continue { + handled_run_id: None, + startup_error: Some(LegacyMigrationCommandError::from_startup_launch(&error)), + } +} + +pub(crate) fn set_startup_migration_state( + handled_run_id: Option, + startup_error: Option, +) { + let _ = STARTUP_MIGRATION_STATE.set(StartupMigrationState { + handled_run_id, + startup_error, + }); +} + +#[tauri::command] +pub fn get_legacy_migration_status( + request: EmptyLegacyMigrationRequest, +) -> Result { + let _ = request; + migration_status().map_err(Into::into) +} + +#[tauri::command] +pub async fn scan_legacy_migration( + request: ScanLegacyMigrationRequest, +) -> Result { + let selection = request.selection.unwrap_or_else(MigrationSelection::all); + if selection.groups.is_empty() { + return Err(LegacyMigrationCommandError::new( + "empty_selection", + "Select at least one migration group.", + true, + )); + } + tauri::async_runtime::spawn_blocking(move || scan_local_source(selection)) + .await + .map_err(|_| { + LegacyMigrationCommandError::new( + "scan_worker_failed", + "The local read-only migration scan stopped unexpectedly.", + true, + ) + })? + .map_err(Into::into) +} + +#[tauri::command] +pub fn prepare_legacy_migration( + app: AppHandle, + request: PrepareLegacyMigrationRequest, +) -> Result { + if request.selection.groups.is_empty() { + return Err(LegacyMigrationCommandError::new( + "empty_selection", + "Select at least one migration group.", + true, + )); + } + let handoff = + prepare_settings_handoff(request.selection).map_err(LegacyMigrationCommandError::from)?; + crate::request_desktop_exit(&app, 0, "legacy_migration_handoff"); + Ok(handoff) +} + +#[tauri::command] +pub fn get_legacy_migration_report( + request: GetLegacyMigrationReportRequest, +) -> Result, LegacyMigrationCommandError> { + let roots = + MigrationRoots::resolve_current_user().map_err(LegacyMigrationCommandError::from)?; + let store = MigrationOnboardingStore::new(roots); + let report = match request.run_id.as_deref() { + Some(run_id) => store.load_report(run_id), + None => store.load_last_report(), + } + .map_err(LegacyMigrationCommandError::from)?; + Ok(report.as_ref().map(redact_report_for_ui)) +} + +#[tauri::command] +pub fn set_legacy_migration_prompt_preference( + request: SetLegacyMigrationPromptPreferenceRequest, +) -> Result { + if !matches!( + request.choice, + MigrationPromptChoice::Unset + | MigrationPromptChoice::RemindLater + | MigrationPromptChoice::DoNotRemind + ) { + return Err(LegacyMigrationCommandError::new( + "invalid_prompt_preference", + "Only reminder preferences can be changed from OpenBitFun settings.", + false, + )); + } + let roots = + MigrationRoots::resolve_current_user().map_err(LegacyMigrationCommandError::from)?; + MigrationOnboardingStore::new(roots) + .update(|state| { + state.format_version = CURRENT_MIGRATION_FORMAT_VERSION; + state.choice = request.choice; + state.handled_run_id = None; + }) + .map_err(Into::into) +} + +fn migration_status() -> LegacyMigrationResult { + let roots = MigrationRoots::resolve_current_user()?; + let onboarding_store = MigrationOnboardingStore::new(roots.clone()); + let onboarding = onboarding_store.load()?; + let source = probe_legacy_source(&roots, ProbeLimits::default())?; + let latest_report = onboarding_store + .load_last_report()? + .as_ref() + .map(redact_report_for_ui); + let startup_report = STARTUP_MIGRATION_STATE + .get() + .and_then(|state| state.handled_run_id.as_deref()) + .map(|run_id| onboarding_store.load_report(run_id)) + .transpose()? + .flatten() + .as_ref() + .map(redact_report_for_ui); + Ok(LegacyMigrationStatusView { + source, + onboarding, + latest_report, + startup_report, + startup_error: STARTUP_MIGRATION_STATE + .get() + .and_then(|state| state.startup_error.clone()), + }) +} + +fn scan_local_source( + selection: MigrationSelection, +) -> LegacyMigrationResult { + let roots = MigrationRoots::resolve_current_user()?; + let source = probe_legacy_source(&roots, ProbeLimits::default())?.ok_or_else(|| { + LegacyMigrationError::UnsupportedSource( + "no supported legacy BitFun data was discovered".to_string(), + ) + })?; + if !source.supported { + return Err(LegacyMigrationError::UnsupportedSource( + source.product_version.clone(), + )); + } + let engine = MigrationEngine::new(roots.clone(), adapters_for_groups(&selection))?; + let findings = engine + .scan(&selection, &CancellationToken::default())? + .into_iter() + .map(|scan| scan.finding) + .collect(); + let scanned_at_ms = now_ms(); + MigrationOnboardingStore::new(roots).update(|state| { + state.format_version = CURRENT_MIGRATION_FORMAT_VERSION; + state.source_fingerprint = source.source_fingerprint.clone(); + state.detected_at_ms.get_or_insert(scanned_at_ms); + state.last_scanned_at_ms = Some(scanned_at_ms); + })?; + Ok(LegacyMigrationScanView { + source, + selection, + scanned_at_ms, + findings, + }) +} + +fn prepare_settings_handoff( + selection: MigrationSelection, +) -> LegacyMigrationResult { + let roots = MigrationRoots::resolve_current_user()?; + let source = probe_legacy_source(&roots, ProbeLimits::default())?.ok_or_else(|| { + LegacyMigrationError::UnsupportedSource( + "no supported legacy BitFun data was discovered".to_string(), + ) + })?; + if !source.supported { + return Err(LegacyMigrationError::UnsupportedSource( + source.product_version.clone(), + )); + } + let request = new_handoff_request( + MigratorRequestMode::Execute, + MigratorRequestOrigin::Settings, + &source, + selection, + ); + HandoffStore::new(roots.clone(), product_id(), RELEASE_CHANNEL) + .write_request(&request, now_ms())?; + MigrationOnboardingStore::new(roots).update(|state| { + state.format_version = CURRENT_MIGRATION_FORMAT_VERSION; + state.source_fingerprint = source.source_fingerprint.clone(); + state.detected_at_ms.get_or_insert_with(now_ms); + state.run_id = Some(request.run_id.clone()); + state.handled_run_id = None; + })?; + launch_data_migrator(&request.run_id)?; + Ok(LegacyMigrationHandoffView { + run_id: request.run_id, + mode: request.mode, + }) +} + +fn new_handoff_request( + mode: MigratorRequestMode, + origin: MigratorRequestOrigin, + source: &LegacySourceDescriptor, + selection: MigrationSelection, +) -> MigratorHandoffRequest { + let created_at_ms = now_ms(); + let capabilities = MigratorProtocolCapabilities::current(); + MigratorHandoffRequest { + protocol_version: capabilities.protocol_version, + mode, + origin, + run_id: uuid::Uuid::new_v4().to_string(), + nonce: uuid::Uuid::new_v4().to_string(), + source_id: Some(source.source_id.clone()), + source_fingerprint: Some(source.source_fingerprint.clone()), + selection, + caller_process_id: std::process::id(), + product_id: product_id().to_string(), + release_channel: RELEASE_CHANNEL.to_string(), + created_at_ms, + expires_at_ms: created_at_ms.saturating_add(HANDOFF_LIFETIME_MS), + required_capabilities: capabilities.capabilities, + } +} + +fn launch_data_migrator(run_id: &str) -> LegacyMigrationResult { + let current = std::env::current_exe().map_err(|error| LegacyMigrationError::Io { + path: DESKTOP_BINARY_NAME.into(), + source: error, + })?; + let executable = TrustedInstallationResolver::resolve_sibling( + ¤t, + DESKTOP_BINARY_NAME, + DATA_MIGRATOR_BINARY_NAME, + )?; + launch_trusted_executable(&executable, &[OsStr::new(run_id)]) +} + +fn should_offer_onboarding( + state: &MigrationOnboardingState, + source: &LegacySourceDescriptor, +) -> bool { + if !source.supported || source.already_migrated { + return false; + } + if state.source_fingerprint != source.source_fingerprint { + return true; + } + !matches!( + state.choice, + MigrationPromptChoice::DoNotRemind | MigrationPromptChoice::MigrateNow + ) +} + +fn migration_restart_run_id(arguments: impl IntoIterator) -> Option { + let mut values = arguments.into_iter(); + let mut run_id = None; + while let Some(argument) = values.next() { + if argument != OsStr::new("--legacy-migration-run-id") { + continue; + } + let candidate = values.next()?.into_string().ok()?; + if run_id.is_some() || uuid::Uuid::parse_str(&candidate).is_err() { + return None; + } + run_id = Some(candidate); + } + run_id +} + +fn redact_report_for_ui(report: &MigrationRunReport) -> MigrationRunReport { + let mut redacted = report.clone(); + for diagnostic in &mut redacted.diagnostics { + diagnostic.relative_path = None; + diagnostic.message = diagnostic.code.replace('_', " "); + diagnostic.action = None; + } + for result in &mut redacted.domain_results { + for warning in &mut result.warnings { + warning.relative_path = None; + warning.message = warning.code.replace('_', " "); + warning.action = None; + } + } + redacted +} + +fn now_ms() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() + .try_into() + .unwrap_or(i64::MAX) +} + +#[cfg(test)] +mod tests { + use super::*; + use openbitfun_product_domains::legacy_migration::MigrationRunStatus; + + fn source(fingerprint: &str) -> LegacySourceDescriptor { + LegacySourceDescriptor { + source_fingerprint: fingerprint.to_string(), + supported: true, + ..LegacySourceDescriptor::default() + } + } + + #[test] + fn only_matching_source_preferences_suppress_onboarding() { + let mut state = MigrationOnboardingState { + source_fingerprint: "source-a".to_string(), + choice: MigrationPromptChoice::DoNotRemind, + ..MigrationOnboardingState::default() + }; + assert!(!should_offer_onboarding(&state, &source("source-a"))); + assert!(should_offer_onboarding(&state, &source("source-b"))); + + state.choice = MigrationPromptChoice::RemindLater; + assert!(should_offer_onboarding(&state, &source("source-a"))); + } + + #[test] + fn completed_source_does_not_offer_onboarding_again() { + let mut completed = source("source-a"); + completed.already_migrated = true; + assert!(!should_offer_onboarding( + &MigrationOnboardingState::default(), + &completed + )); + } + + #[test] + fn restart_argument_requires_one_uuid_value() { + let run_id = uuid::Uuid::new_v4().to_string(); + assert_eq!( + migration_restart_run_id([ + OsString::from("--unrelated"), + OsString::from("value"), + OsString::from("--legacy-migration-run-id"), + OsString::from(&run_id), + ]), + Some(run_id.clone()) + ); + assert_eq!( + migration_restart_run_id([ + OsString::from("--legacy-migration-run-id"), + OsString::from("invalid"), + ]), + None + ); + assert_eq!( + migration_restart_run_id([ + OsString::from("--legacy-migration-run-id"), + OsString::from(&run_id), + OsString::from("--legacy-migration-run-id"), + OsString::from(&run_id), + ]), + None + ); + } + + #[test] + fn settings_handoff_requires_every_current_migrator_capability() { + let request = new_handoff_request( + MigratorRequestMode::Execute, + MigratorRequestOrigin::Settings, + &source("source-a"), + MigrationSelection::all(), + ); + assert_eq!( + request.required_capabilities, + MigratorProtocolCapabilities::current().capabilities + ); + assert_eq!(request.mode, MigratorRequestMode::Execute); + assert_eq!(request.origin, MigratorRequestOrigin::Settings); + } + + #[test] + fn ui_report_removes_paths_and_free_text() { + let report = MigrationRunReport { + status: MigrationRunStatus::FailedRecoverable, + diagnostics: vec![ + openbitfun_product_domains::legacy_migration::MigrationDiagnostic { + code: "legacy_item_failed".to_string(), + relative_path: Some("private/session.md".to_string()), + message: "private body".to_string(), + action: Some("private action".to_string()), + ..Default::default() + }, + ], + ..MigrationRunReport::default() + }; + let redacted = redact_report_for_ui(&report); + assert!(redacted.diagnostics[0].relative_path.is_none()); + assert_eq!(redacted.diagnostics[0].message, "legacy item failed"); + assert!(redacted.diagnostics[0].action.is_none()); + } + + #[test] + fn startup_launch_failure_continues_with_redacted_error() { + let disposition = continue_after_startup_launch_failure(LegacyMigrationError::Io { + path: r"C:\private\openbitfun-data-migrator.exe".into(), + source: std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "private operating system detail", + ), + }); + + let StartupProbeDisposition::Continue { + handled_run_id, + startup_error: Some(error), + } = disposition + else { + panic!("launch failure must allow Desktop startup with a surfaced error"); + }; + assert!(handled_run_id.is_none()); + assert_eq!(error.code, "data_migrator_launch_failed"); + assert!(error.recoverable); + assert!(!error.message.contains("private")); + } +} diff --git a/src/apps/desktop/src/api/mod.rs b/src/apps/desktop/src/api/mod.rs index 38bb045244..ab82f4c294 100644 --- a/src/apps/desktop/src/api/mod.rs +++ b/src/apps/desktop/src/api/mod.rs @@ -31,6 +31,7 @@ pub mod git_api; pub mod html_preview_api; pub mod i18n_api; pub mod insights_api; +pub mod legacy_migration_api; pub mod mcp_api; pub mod miniapp_agent_api; pub mod miniapp_api; diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index d113f3e0d9..9a5b67df6e 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -559,6 +559,20 @@ fn get_startup_native_trace( /// Tauri application entry point #[cfg_attr(mobile, tauri::mobile_entry_point)] pub async fn run() { + match api::legacy_migration_api::run_startup_probe() { + Ok(api::legacy_migration_api::StartupProbeDisposition::Continue { + handled_run_id, + startup_error, + }) => api::legacy_migration_api::set_startup_migration_state(handled_run_id, startup_error), + Ok(api::legacy_migration_api::StartupProbeDisposition::MigratorLaunched) => return, + Err(error) => { + show_fatal_startup_error(&format!( + "OpenBitFun could not safely inspect legacy migration state and cannot continue.\n\n{}\n\nRepair the installation or the migration state before retrying.", + api::legacy_migration_api::LegacyMigrationCommandError::from(error).message + )); + return; + } + } let startup_started = Instant::now(); let startup_trace_id = SystemTime::now() .duration_since(UNIX_EPOCH) @@ -1453,6 +1467,11 @@ pub async fn run() { api::agentic_api::set_session_memory_mode, webdriver_bridge_result, get_startup_native_trace, + api::legacy_migration_api::get_legacy_migration_status, + api::legacy_migration_api::scan_legacy_migration, + api::legacy_migration_api::prepare_legacy_migration, + api::legacy_migration_api::get_legacy_migration_report, + api::legacy_migration_api::set_legacy_migration_prompt_preference, api::agentic_api::list_sessions, api::agentic_api::list_pending_permission_requests, api::agentic_api::subscribe_permission_requests, diff --git a/src/crates/assembly/core/Cargo.toml b/src/crates/assembly/core/Cargo.toml index 2f7cbdd23a..9ed1273665 100644 --- a/src/crates/assembly/core/Cargo.toml +++ b/src/crates/assembly/core/Cargo.toml @@ -85,6 +85,9 @@ openbitfun-services-core = { path = "../../services/services-core", features = [ # Integration service owner crate openbitfun-services-integrations = { path = "../../services/services-integrations", optional = true } +# Offline legacy-data migration engine. Product-owned adapters are assembled in Core. +openbitfun-legacy-migration = { path = "../../services/legacy-migration", optional = true } + # Product domain owner crate openbitfun-product-domains = { path = "../../contracts/product-domains", optional = true } @@ -187,6 +190,7 @@ product-full = [ "product-capabilities", "runtime-services", "tool-packs", + "legacy-migration", ] # Core compatibility facade and concrete product Agent Runtime assembly. This # owns the existing agent/session/tool lifecycle, not app presentation or @@ -224,6 +228,7 @@ agent-runtime = [ "dep:tool-runtime", "tool-runtime/shell-analysis", "openbitfun-services-core/permission", + "openbitfun-services-core/memory-store", "openbitfun-services-core/runtime-ownership", "openbitfun-services-core/workspace-text-runtime", "openbitfun-services-core/session-git", @@ -409,6 +414,22 @@ canvas-runtime = [ "openbitfun-services-integrations/canvas-runtime", ] runtime-services = ["dep:openbitfun-runtime-services"] +legacy-migration = [ + "dep:openbitfun-agent-runtime", + "dep:openbitfun-legacy-migration", + "dep:openbitfun-product-domains", + "dep:openbitfun-services-integrations", + "dep:rusqlite", + "openbitfun-agent-runtime/definition-contracts", + "openbitfun-product-domains/legacy-migration", + "openbitfun-product-domains/miniapp", + "openbitfun-services-core/local-storage", + "openbitfun-services-core/memory-store", + "openbitfun-services-core/workspace-identity", + "openbitfun-services-integrations/miniapp-storage", + "openbitfun-services-integrations/remote-persistence", + "tokio/rt", +] announcement = ["openbitfun-services-integrations/announcement"] file-watch = ["openbitfun-services-integrations/file-watch"] git = ["openbitfun-services-integrations/git"] diff --git a/src/crates/assembly/core/src/agentic/coordination/coordination_store.rs b/src/crates/assembly/core/src/agentic/coordination/coordination_store.rs index a3ee6f4622..33e6283957 100644 --- a/src/crates/assembly/core/src/agentic/coordination/coordination_store.rs +++ b/src/crates/assembly/core/src/agentic/coordination/coordination_store.rs @@ -1,3 +1,6 @@ +use crate::service::coordination_persistence::{ + initialize_coordination_schema, validate_coordination_agent_id, +}; use crate::util::errors::{OpenBitFunError, OpenBitFunResult}; use rusqlite::{params, Connection, OptionalExtension, Transaction, TransactionBehavior}; use std::path::PathBuf; @@ -7,7 +10,6 @@ use tokio::sync::OnceCell; use tokio::task; use uuid::Uuid; -const SCHEMA_VERSION: i64 = 2; const SWARM_MAX_NODES: i64 = 128; const SWARM_MAX_DEPTH: i64 = 4; @@ -1092,23 +1094,7 @@ fn get_or_create_agent( } pub(crate) fn validate_agent_id(agent_id: &str) -> OpenBitFunResult<()> { - let valid = !agent_id.is_empty() - && agent_id.len() <= 32 - && agent_id - .bytes() - .enumerate() - .all(|(index, byte)| match byte { - b'a'..=b'z' => true, - b'0'..=b'9' | b'_' | b'-' => index > 0, - _ => false, - }); - if valid { - Ok(()) - } else { - Err(OpenBitFunError::tool( - "agent_id must match [a-z][a-z0-9_-]{0,31}".to_string(), - )) - } + validate_coordination_agent_id(agent_id) } fn open_connection(db_path: PathBuf) -> OpenBitFunResult>> { @@ -1138,23 +1124,67 @@ PRAGMA synchronous = NORMAL; "#, ) .map_err(db_error)?; - initialize_schema(&connection)?; + initialize_coordination_schema(&connection)?; Ok(Arc::new(Mutex::new(connection))) } -fn initialize_schema(connection: &Connection) -> OpenBitFunResult<()> { - let version = connection - .query_row("PRAGMA user_version", [], |row| row.get::<_, i64>(0)) - .map_err(db_error)?; - if version > SCHEMA_VERSION { - return Err(OpenBitFunError::service(format!( - "Agent coordination database schema {version} is newer than supported schema {SCHEMA_VERSION}" - ))); +fn db_error(error: rusqlite::Error) -> OpenBitFunError { + OpenBitFunError::io(format!("Agent coordination database error: {error}")) +} + +fn unix_time_ms() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64 +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::service::coordination_persistence::{ + coordination_table_has_column, initialize_coordination_schema, + }; + + fn test_tempdir() -> tempfile::TempDir { + if let Some(root) = std::env::var_os("OPENBITFUN_TEST_TMPDIR") { + let root = PathBuf::from(root); + std::fs::create_dir_all(&root).expect("create coordination test temp root"); + return tempfile::Builder::new() + .prefix("coordination-store-") + .tempdir_in(root) + .expect("coordination store temp directory"); + } + tempfile::tempdir().expect("coordination store temp directory") } - if version == SCHEMA_VERSION { - return Ok(()); + + fn test_store() -> (tempfile::TempDir, CoordinationStore) { + let root = test_tempdir(); + let store = CoordinationStore::new(root.path().join("coordination.sqlite")); + (root, store) + } + + fn registration( + parent_session_id: &str, + child_session_id: &str, + parent_dialog_turn_id: &str, + requested_agent_id: Option<&str>, + ) -> BackgroundTaskRegistration { + BackgroundTaskRegistration { + parent_session_id: parent_session_id.to_string(), + requested_agent_id: requested_agent_id.map(str::to_string), + child_session_id: child_session_id.to_string(), + parent_dialog_turn_id: parent_dialog_turn_id.to_string(), + parent_tool_call_id: format!("tool-{parent_dialog_turn_id}"), + child_dialog_turn_id: format!("turn-{child_session_id}-{parent_dialog_turn_id}"), + } } - if version == 0 { + + #[tokio::test] + async fn schema_v2_repairs_missing_delivery_columns_idempotently() { + let root = test_tempdir(); + let db_path = root.path().join("coordination.sqlite"); + let connection = Connection::open(&db_path).expect("open historical database"); connection .execute_batch( r#" @@ -1163,19 +1193,15 @@ CREATE TABLE coordination_sessions ( next_auto_agent_seq INTEGER NOT NULL DEFAULT 1, updated_at_ms INTEGER NOT NULL ); - CREATE TABLE agents ( agent_pk INTEGER PRIMARY KEY AUTOINCREMENT, parent_session_id TEXT NOT NULL, agent_id TEXT NOT NULL, child_session_id TEXT, next_bg_seq INTEGER NOT NULL DEFAULT 1, - state TEXT NOT NULL CHECK (state IN ('active', 'historical')), - created_at_ms INTEGER NOT NULL, - UNIQUE(parent_session_id, agent_id), - UNIQUE(parent_session_id, child_session_id) + state TEXT NOT NULL, + created_at_ms INTEGER NOT NULL ); - CREATE TABLE background_tasks ( task_pk INTEGER PRIMARY KEY AUTOINCREMENT, parent_session_id TEXT NOT NULL, @@ -1185,95 +1211,59 @@ CREATE TABLE background_tasks ( parent_dialog_turn_id TEXT NOT NULL, parent_tool_call_id TEXT NOT NULL, child_dialog_turn_id TEXT NOT NULL, - status TEXT NOT NULL CHECK ( - status IN ('running', 'completed', 'partial_timeout', 'failed', 'cancelled', 'interrupted') - ), + status TEXT NOT NULL, error_code TEXT, error_message TEXT, execution_owner_token TEXT NOT NULL, created_at_ms INTEGER NOT NULL, - terminal_at_ms INTEGER, - delivered_at_ms INTEGER, - delivered_parent_dialog_turn_id TEXT, - UNIQUE(parent_session_id, bg_task_id), - UNIQUE(agent_pk, bg_ordinal), - FOREIGN KEY(agent_pk) REFERENCES agents(agent_pk) ON DELETE CASCADE + terminal_at_ms INTEGER ); - -CREATE INDEX idx_background_tasks_wait - ON background_tasks(parent_session_id, delivered_at_ms, status, task_pk); -CREATE INDEX idx_background_tasks_parent_turn - ON background_tasks(parent_session_id, parent_dialog_turn_id); - -PRAGMA user_version = 1; - "#, - ) - .map_err(db_error)?; - } - if version < 2 { - connection - .execute_batch( - r#" CREATE TABLE swarm_trees ( root_session_id TEXT PRIMARY KEY, created_at_ms INTEGER NOT NULL ); - CREATE TABLE swarm_nodes ( session_id TEXT PRIMARY KEY, root_session_id TEXT NOT NULL, parent_session_id TEXT, agent_type TEXT NOT NULL, depth INTEGER NOT NULL, - created_at_ms INTEGER NOT NULL, - FOREIGN KEY(root_session_id) REFERENCES swarm_trees(root_session_id) ON DELETE CASCADE + created_at_ms INTEGER NOT NULL +); +INSERT INTO coordination_sessions VALUES ('parent', 2, 1); +INSERT INTO agents VALUES (1, 'parent', 'helper', 'child', 2, 'historical', 1); +INSERT INTO background_tasks VALUES ( + 1, 'parent', 1, 'helper_bg1', 1, 'parent-turn', 'tool-call', + 'child-turn', 'completed', NULL, NULL, 'historical-owner', 1, 2 ); - -CREATE INDEX idx_swarm_nodes_root ON swarm_nodes(root_session_id); -CREATE INDEX idx_swarm_nodes_parent ON swarm_nodes(parent_session_id); PRAGMA user_version = 2; "#, ) - .map_err(db_error)?; - } - Ok(()) -} - -fn db_error(error: rusqlite::Error) -> OpenBitFunError { - OpenBitFunError::io(format!("Agent coordination database error: {error}")) -} + .expect("seed historical schema v2"); + drop(connection); -fn unix_time_ms() -> u64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_millis() as u64 -} - -#[cfg(test)] -mod tests { - use super::*; - - fn test_store() -> (tempfile::TempDir, CoordinationStore) { - let root = tempfile::tempdir().expect("coordination store temp directory"); - let store = CoordinationStore::new(root.path().join("coordination.sqlite")); - (root, store) - } + let store = CoordinationStore::new(db_path.clone()); + let candidates = store + .wait_candidates("parent", &[]) + .await + .expect("repaired database should support current reads"); + assert_eq!(candidates.len(), 1); + assert_eq!(candidates[0].bg_task_id, "helper_bg1"); + assert!(candidates[0].delivered_at_ms.is_none()); + drop(store); - fn registration( - parent_session_id: &str, - child_session_id: &str, - parent_dialog_turn_id: &str, - requested_agent_id: Option<&str>, - ) -> BackgroundTaskRegistration { - BackgroundTaskRegistration { - parent_session_id: parent_session_id.to_string(), - requested_agent_id: requested_agent_id.map(str::to_string), - child_session_id: child_session_id.to_string(), - parent_dialog_turn_id: parent_dialog_turn_id.to_string(), - parent_tool_call_id: format!("tool-{parent_dialog_turn_id}"), - child_dialog_turn_id: format!("turn-{child_session_id}-{parent_dialog_turn_id}"), - } + let connection = Connection::open(&db_path).expect("reopen repaired database"); + initialize_coordination_schema(&connection).expect("repeated repair should be idempotent"); + assert!( + coordination_table_has_column(&connection, "background_tasks", "delivered_at_ms") + .expect("inspect delivered_at_ms") + ); + assert!(coordination_table_has_column( + &connection, + "background_tasks", + "delivered_parent_dialog_turn_id" + ) + .expect("inspect delivered_parent_dialog_turn_id")); } #[tokio::test] diff --git a/src/crates/assembly/core/src/agentic/memories/db.rs b/src/crates/assembly/core/src/agentic/memories/db.rs index b6d5fa3cac..0750b5152f 100644 --- a/src/crates/assembly/core/src/agentic/memories/db.rs +++ b/src/crates/assembly/core/src/agentic/memories/db.rs @@ -1,6 +1,14 @@ use crate::agentic::core::message::MemoryCitation; use crate::infrastructure::PathManager; use crate::util::errors::{OpenBitFunError, OpenBitFunResult}; +use openbitfun_services_core::memory_store::{ + decode_memory_job, decode_memory_record, initialize_memory_schema, upsert_memory_record, +}; +pub use openbitfun_services_core::memory_store::{ + MemoryJobRecord as MemoryJobRow, MemoryRecord as MemoryRow, +}; +#[cfg(test)] +use openbitfun_services_core::memory_store::{EXPECTED_JOBS_COLUMNS, EXPECTED_STAGE1_COLUMNS}; use rusqlite::{params, Connection, OptionalExtension, Transaction, TransactionBehavior}; use serde::{Deserialize, Serialize}; use std::path::{Path, PathBuf}; @@ -18,22 +26,6 @@ const JOB_STATUS_DONE: &str = "done"; const JOB_STATUS_ERROR: &str = "error"; const DEFAULT_RETRY_REMAINING: i64 = 3; -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct MemoryRow { - pub session_id: String, - pub workspace_path: String, - pub rollout_path: String, - pub source_updated_at_unix_secs: i64, - pub raw_memory: String, - pub rollout_summary: String, - pub rollout_slug: Option, - pub generated_at_unix_secs: i64, - pub usage_count: i64, - pub last_usage_unix_secs: Option, - pub selected_for_phase2: i64, - pub selected_for_phase2_source_updated_at: Option, -} - #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct MemoryPhase2CandidateRow { pub session_id: String, @@ -56,39 +48,6 @@ pub struct MemoryPhase2SelectionRow { pub input_watermark: i64, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct MemoryJobRow { - pub kind: String, - pub job_key: String, - pub status: String, - pub worker_id: Option, - pub ownership_token: Option, - pub started_at_unix_secs: Option, - pub finished_at_unix_secs: Option, - pub lease_until_unix_secs: Option, - pub retry_at_unix_secs: Option, - pub retry_remaining: i64, - pub last_error: Option, - pub input_watermark: Option, - pub last_success_watermark: Option, -} - -impl MemoryJobRow { - pub fn success_cooldown_until_unix_secs(&self, cooldown_seconds: i64) -> Option { - if self.kind != JOB_KIND_MEMORY_CONSOLIDATE_GLOBAL - || self.status != JOB_STATUS_DONE - || self.last_error.is_some() - || self.input_watermark.is_none() - || self.last_success_watermark != self.input_watermark - { - return None; - } - - self.finished_at_unix_secs - .map(|finished_at| finished_at.saturating_add(cooldown_seconds.max(0))) - } -} - #[derive(Debug, Clone, PartialEq, Eq)] pub enum MemoryPhase1ClaimOutcome { Claimed { ownership_token: String }, @@ -1219,108 +1178,12 @@ fn open_connection(path: &Path) -> OpenBitFunResult { } fn initialize_schema(conn: &Connection) -> OpenBitFunResult<()> { - recreate_table_if_shape_differs(conn, "stage1_outputs", EXPECTED_STAGE1_COLUMNS)?; - recreate_table_if_shape_differs(conn, "jobs", EXPECTED_JOBS_COLUMNS)?; - conn.execute_batch( - r#" - CREATE TABLE IF NOT EXISTS stage1_outputs ( - thread_id TEXT PRIMARY KEY NOT NULL, - workspace_path TEXT NOT NULL, - rollout_path TEXT NOT NULL, - source_updated_at INTEGER NOT NULL, - raw_memory TEXT NOT NULL, - rollout_summary TEXT NOT NULL, - rollout_slug TEXT, - generated_at INTEGER NOT NULL, - usage_count INTEGER, - last_usage INTEGER, - selected_for_phase2 INTEGER NOT NULL DEFAULT 0, - selected_for_phase2_source_updated_at INTEGER - ); - - CREATE INDEX IF NOT EXISTS idx_stage1_outputs_source_updated_at - ON stage1_outputs(source_updated_at DESC, thread_id DESC); - - CREATE TABLE IF NOT EXISTS jobs ( - kind TEXT NOT NULL, - job_key TEXT NOT NULL, - status TEXT NOT NULL, - worker_id TEXT, - ownership_token TEXT, - started_at INTEGER, - finished_at INTEGER, - lease_until INTEGER, - retry_at INTEGER, - retry_remaining INTEGER NOT NULL, - last_error TEXT, - input_watermark INTEGER, - last_success_watermark INTEGER, - PRIMARY KEY (kind, job_key) - ); - - CREATE INDEX IF NOT EXISTS idx_jobs_kind_status_retry_lease - ON jobs(kind, status, retry_at, lease_until); - "#, - ) - .map_err(|error| { - OpenBitFunError::io(format!("Failed to initialize memories schema: {}", error)) - })?; - Ok(()) -} - -const EXPECTED_STAGE1_COLUMNS: &[&str] = &[ - "thread_id", - "workspace_path", - "rollout_path", - "source_updated_at", - "raw_memory", - "rollout_summary", - "rollout_slug", - "generated_at", - "usage_count", - "last_usage", - "selected_for_phase2", - "selected_for_phase2_source_updated_at", -]; - -const EXPECTED_JOBS_COLUMNS: &[&str] = &[ - "kind", - "job_key", - "status", - "worker_id", - "ownership_token", - "started_at", - "finished_at", - "lease_until", - "retry_at", - "retry_remaining", - "last_error", - "input_watermark", - "last_success_watermark", -]; - -fn recreate_table_if_shape_differs( - conn: &Connection, - table_name: &str, - expected_columns: &[&str], -) -> OpenBitFunResult<()> { - let columns = table_columns(conn, table_name)?; - let expected_columns = expected_columns - .iter() - .map(|column| column.to_string()) - .collect::>(); - if !columns.is_empty() && columns != expected_columns { - conn.execute(&format!("DROP TABLE IF EXISTS {table_name}"), []) - .map_err(|error| { - OpenBitFunError::io(format!( - "Failed to drop incompatible memories table {}: {}", - table_name, error - )) - })?; - } - Ok(()) + initialize_memory_schema(conn).map_err(|error| { + OpenBitFunError::io(format!("Failed to initialize memories schema: {error}")) + }) } +#[cfg(test)] fn table_columns(conn: &Connection, table_name: &str) -> OpenBitFunResult> { let mut stmt = conn .prepare(&format!("PRAGMA table_info({table_name})")) @@ -1362,71 +1225,14 @@ fn clear_memory_state_in_conn(conn: &Connection) -> OpenBitFunResult<()> { Ok(()) } -const UPSERT_STAGE1_OUTPUT_SQL: &str = r#" - INSERT INTO stage1_outputs ( - thread_id, workspace_path, rollout_path, source_updated_at, raw_memory, - rollout_summary, rollout_slug, generated_at, usage_count, - last_usage, selected_for_phase2, selected_for_phase2_source_updated_at - ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(thread_id) DO UPDATE SET - workspace_path = excluded.workspace_path, - rollout_path = excluded.rollout_path, - source_updated_at = excluded.source_updated_at, - raw_memory = excluded.raw_memory, - rollout_summary = excluded.rollout_summary, - rollout_slug = excluded.rollout_slug, - generated_at = excluded.generated_at, - usage_count = CASE - WHEN ? != 0 THEN excluded.usage_count - ELSE stage1_outputs.usage_count - END, - last_usage = CASE - WHEN ? != 0 THEN excluded.last_usage - ELSE stage1_outputs.last_usage - END, - selected_for_phase2 = CASE - WHEN ? != 0 THEN excluded.selected_for_phase2 - ELSE stage1_outputs.selected_for_phase2 - END, - selected_for_phase2_source_updated_at = CASE - WHEN ? != 0 THEN excluded.selected_for_phase2_source_updated_at - ELSE stage1_outputs.selected_for_phase2_source_updated_at - END - WHERE excluded.source_updated_at >= stage1_outputs.source_updated_at - "#; - fn upsert_stage1_output_conn( conn: &Connection, row: &MemoryRow, overwrite_usage_and_selection: bool, ) -> OpenBitFunResult<()> { - let overwrite = if overwrite_usage_and_selection { 1 } else { 0 }; - conn.execute( - UPSERT_STAGE1_OUTPUT_SQL, - params![ - &row.session_id, - &row.workspace_path, - &row.rollout_path, - row.source_updated_at_unix_secs, - &row.raw_memory, - &row.rollout_summary, - &row.rollout_slug, - row.generated_at_unix_secs, - row.usage_count, - row.last_usage_unix_secs, - row.selected_for_phase2, - row.selected_for_phase2_source_updated_at, - overwrite, - overwrite, - overwrite, - overwrite, - ], - ) - .map_err(|error| { - OpenBitFunError::io(format!("Failed to upsert memory stage1 output: {}", error)) - })?; - Ok(()) + upsert_memory_record(conn, row, overwrite_usage_and_selection).map_err(|error| { + OpenBitFunError::io(format!("Failed to upsert memory stage1 output: {error}")) + }) } fn upsert_stage1_output_tx( @@ -1434,32 +1240,9 @@ fn upsert_stage1_output_tx( row: &MemoryRow, overwrite_usage_and_selection: bool, ) -> OpenBitFunResult<()> { - let overwrite = if overwrite_usage_and_selection { 1 } else { 0 }; - tx.execute( - UPSERT_STAGE1_OUTPUT_SQL, - params![ - &row.session_id, - &row.workspace_path, - &row.rollout_path, - row.source_updated_at_unix_secs, - &row.raw_memory, - &row.rollout_summary, - &row.rollout_slug, - row.generated_at_unix_secs, - row.usage_count, - row.last_usage_unix_secs, - row.selected_for_phase2, - row.selected_for_phase2_source_updated_at, - overwrite, - overwrite, - overwrite, - overwrite, - ], - ) - .map_err(|error| { - OpenBitFunError::io(format!("Failed to upsert memory stage1 output: {}", error)) - })?; - Ok(()) + upsert_memory_record(tx, row, overwrite_usage_and_selection).map_err(|error| { + OpenBitFunError::io(format!("Failed to upsert memory stage1 output: {error}")) + }) } fn phase1_source_needs_update_in_conn( @@ -1723,38 +1506,11 @@ fn get_job_in_tx( } fn row_to_job(row: &rusqlite::Row<'_>) -> rusqlite::Result { - Ok(MemoryJobRow { - kind: row.get(0)?, - job_key: row.get(1)?, - status: row.get(2)?, - worker_id: row.get(3)?, - ownership_token: row.get(4)?, - started_at_unix_secs: row.get(5)?, - finished_at_unix_secs: row.get(6)?, - lease_until_unix_secs: row.get(7)?, - retry_at_unix_secs: row.get(8)?, - retry_remaining: row.get(9)?, - last_error: row.get(10)?, - input_watermark: row.get(11)?, - last_success_watermark: row.get(12)?, - }) + decode_memory_job(row) } fn row_to_memory(row: &rusqlite::Row<'_>) -> rusqlite::Result { - Ok(MemoryRow { - session_id: row.get(0)?, - workspace_path: row.get(1)?, - rollout_path: row.get(2)?, - source_updated_at_unix_secs: row.get(3)?, - raw_memory: row.get(4)?, - rollout_summary: row.get(5)?, - rollout_slug: row.get(6)?, - generated_at_unix_secs: row.get(7)?, - usage_count: row.get(8)?, - last_usage_unix_secs: row.get(9)?, - selected_for_phase2: row.get(10)?, - selected_for_phase2_source_updated_at: row.get(11)?, - }) + decode_memory_record(row) } fn row_to_phase2_candidate(row: &rusqlite::Row<'_>) -> rusqlite::Result { diff --git a/src/crates/assembly/core/src/agentic/memories/workspace.rs b/src/crates/assembly/core/src/agentic/memories/workspace.rs index cec61dae46..3dd8512d6c 100644 --- a/src/crates/assembly/core/src/agentic/memories/workspace.rs +++ b/src/crates/assembly/core/src/agentic/memories/workspace.rs @@ -2,6 +2,10 @@ use crate::agentic::memories::db::MemoryRow; use crate::infrastructure::get_path_manager_arc; use crate::util::errors::{OpenBitFunError, OpenBitFunResult}; use chrono::{DateTime, Utc}; +pub use openbitfun_services_core::memory_store::{ + AD_HOC_EXTENSION_NAME, AD_HOC_NOTES_DIR_NAME, MEMORY_EXTENSIONS_DIR_NAME, MEMORY_FILE_NAME, + MEMORY_SUMMARY_FILE_NAME, +}; use openbitfun_services_core::session::MemoryWorkspaceGitError; pub use openbitfun_services_core::session::{ MemoryWorkspaceChange, MemoryWorkspaceChangeStatus, MemoryWorkspaceDiff, @@ -14,13 +18,8 @@ use uuid::Uuid; pub const MEMORY_ROOT_NAME: &str = "memories"; pub const RAW_MEMORIES_FILENAME: &str = "raw_memories.md"; -pub const MEMORY_FILE_NAME: &str = "MEMORY.md"; -pub const MEMORY_SUMMARY_FILE_NAME: &str = "memory_summary.md"; pub const PHASE2_WORKSPACE_DIFF_FILE_NAME: &str = "phase2_workspace_diff.md"; pub const ROLLOUT_SUMMARIES_DIR_NAME: &str = "rollout_summaries"; -pub const MEMORY_EXTENSIONS_DIR_NAME: &str = "extensions"; -pub const AD_HOC_EXTENSION_NAME: &str = "ad_hoc"; -pub const AD_HOC_NOTES_DIR_NAME: &str = "notes"; pub const AD_HOC_INSTRUCTIONS_FILE_NAME: &str = "instructions.md"; const AD_HOC_INSTRUCTIONS: &str = r#"# Ad-hoc notes diff --git a/src/crates/assembly/core/src/agentic/persistence/manager.rs b/src/crates/assembly/core/src/agentic/persistence/manager.rs index b9f535b1d9..319325c973 100644 --- a/src/crates/assembly/core/src/agentic/persistence/manager.rs +++ b/src/crates/assembly/core/src/agentic/persistence/manager.rs @@ -29,9 +29,9 @@ use crate::service::remote_ssh::workspace_state::{ }; use crate::service::session::{ DialogTurnData, SessionMetadata, SessionTranscriptExport, SessionTranscriptExportOptions, - SessionTurnCatalog, SessionTurnCatalogEntry, SessionTurnWindowResponse, TranscriptLineRange, - TurnRailCapsulePreview, TurnRailCapsuleSegment, SESSION_STORAGE_SCHEMA_VERSION, - SESSION_TURN_CATALOG_SCHEMA_VERSION, + SessionTurnCatalog, SessionTurnCatalogEntry, SessionTurnWindowResponse, StoredDialogTurnFile, + TranscriptLineRange, TurnRailCapsulePreview, TurnRailCapsuleSegment, + SESSION_STORAGE_SCHEMA_VERSION, SESSION_TURN_CATALOG_SCHEMA_VERSION, }; use crate::service::workspace_runtime::WorkspaceRuntimeService; use crate::util::errors::{OpenBitFunError, OpenBitFunResult}; @@ -160,13 +160,6 @@ fn current_unix_secs() -> i64 { .unwrap_or_default() } -#[derive(Debug, Clone, Serialize, Deserialize)] -struct StoredDialogTurnFile { - schema_version: u32, - #[serde(flatten)] - turn: DialogTurnData, -} - /// Legacy navigation repair reads only identity/outcome fields. In particular, /// do not flatten this DTO: serde flatten would materialize ignored messages /// and tool payloads while collecting the unknown fields. @@ -3535,10 +3528,7 @@ impl PersistenceManager { self.invalidate_session_search(workspace_path, &turn.session_id) .await; - let file = StoredDialogTurnFile { - schema_version: SESSION_STORAGE_SCHEMA_VERSION, - turn: turn.clone(), - }; + let file = StoredDialogTurnFile::new(turn.clone()); let write_started_at = Instant::now(); self.write_json_atomic( &self.turn_path(workspace_path, &turn.session_id, turn.turn_index), diff --git a/src/crates/assembly/core/src/infrastructure/app_paths/path_manager.rs b/src/crates/assembly/core/src/infrastructure/app_paths/path_manager.rs index 9fa964e3e9..56538b9ca0 100644 --- a/src/crates/assembly/core/src/infrastructure/app_paths/path_manager.rs +++ b/src/crates/assembly/core/src/infrastructure/app_paths/path_manager.rs @@ -488,7 +488,7 @@ impl PathManager { .insert(workspace_path.to_path_buf(), slug.to_string()); } - fn build_project_runtime_slug(canonical: &str) -> String { + pub(crate) fn build_project_runtime_slug(canonical: &str) -> String { let slug: String = canonical .chars() .map(|ch| { diff --git a/src/crates/assembly/core/src/legacy_migration/agent_coordination.rs b/src/crates/assembly/core/src/legacy_migration/agent_coordination.rs new file mode 100644 index 0000000000..14da87d8d0 --- /dev/null +++ b/src/crates/assembly/core/src/legacy_migration/agent_coordination.rs @@ -0,0 +1,2196 @@ +use super::common::{ + backup_domain_dir, io_error, read_bounded_json, read_optional_bounded_json, stage_domain_dir, + validate_regular_file, +}; +use super::workspace_sessions::{ + read_workspace_sessions_manifest, target_wins_session_ids, SessionImportAction, + WorkspaceSessionsManifest, +}; +use crate::service::coordination_persistence::{ + coordination_table_has_column, initialize_coordination_schema, validate_coordination_agent_id, + COORDINATION_SCHEMA_VERSION, +}; +use openbitfun_core_types::validate_session_id; +use openbitfun_legacy_migration::{ + atomic_write_bytes, atomic_write_json, snapshot_sqlite_read_only, validate_sqlite, + DomainContext, DomainScan, LegacyDomainAdapter, LegacyMigrationError, LegacyMigrationResult, + MigrationRoots, +}; +use openbitfun_product_domains::legacy_migration::{ + ConflictResolution, FindingSeverity, MigrationConflict, MigrationDomainId, + MigrationDomainResult, MigrationDomainState, ScanFinding, +}; +use rusqlite::{params, Connection, OpenFlags, OptionalExtension, Transaction}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::collections::{BTreeSet, HashMap, HashSet}; +use std::fs; +use std::path::{Path, PathBuf}; + +const COORDINATION_RELATIVE_PATH: &str = "data/agent-runtime/coordination.sqlite"; + +pub(crate) struct AgentCoordinationAdapter; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct CoordinationManifest { + source_digest: String, + target_existed: bool, + target_digest: Option, + merged_digest: String, + imported: u64, + skipped: u64, + conflicts: u64, +} + +#[derive(Debug, Clone, Default, Serialize)] +#[serde(rename_all = "camelCase")] +struct CoordinationData { + sessions: Vec, + agents: Vec, + tasks: Vec, + swarm_trees: Vec, + swarm_nodes: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +struct CoordinationSessionRow { + parent_session_id: String, + next_auto_agent_seq: i64, + updated_at_ms: i64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +struct AgentRow { + agent_pk: i64, + parent_session_id: String, + agent_id: String, + child_session_id: Option, + next_bg_seq: i64, + state: String, + created_at_ms: i64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +struct BackgroundTaskRow { + task_pk: i64, + parent_session_id: String, + agent_pk: i64, + bg_task_id: String, + bg_ordinal: i64, + parent_dialog_turn_id: String, + parent_tool_call_id: String, + child_dialog_turn_id: String, + status: String, + error_code: Option, + error_message: Option, + execution_owner_token: String, + created_at_ms: i64, + terminal_at_ms: Option, + delivered_at_ms: Option, + delivered_parent_dialog_turn_id: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +struct SwarmTreeRow { + root_session_id: String, + created_at_ms: i64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +struct SwarmNodeRow { + session_id: String, + root_session_id: String, + parent_session_id: Option, + agent_type: String, + depth: i64, + created_at_ms: i64, +} + +#[derive(Default)] +struct MergeOutcome { + imported: u64, + duplicate: u64, + target_wins: u64, + conflicts: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum AgentMergeTarget { + Imported(i64), + Existing(i64), + Rejected, +} + +impl AgentMergeTarget { + fn agent_pk(self) -> Option { + match self { + Self::Imported(agent_pk) | Self::Existing(agent_pk) => Some(agent_pk), + Self::Rejected => None, + } + } +} + +impl LegacyDomainAdapter for AgentCoordinationAdapter { + fn domain(&self) -> MigrationDomainId { + MigrationDomainId::AgentCoordination + } + + fn scan(&self, roots: &MigrationRoots) -> LegacyMigrationResult { + let source_path = source_coordination_path(roots); + validate_regular_file(&roots.legacy_user_root, &source_path).map_err(|_| { + LegacyMigrationError::UnsupportedSource( + "the selected Session group requires a readable legacy coordination.sqlite" + .to_string(), + ) + })?; + let source = load_coordination_data(&source_path, DatabaseRole::LegacySource)?; + let target_path = target_coordination_path(roots); + let target = if target_path.exists() { + validate_regular_file(&roots.target_user_root, &target_path)?; + load_coordination_data(&target_path, DatabaseRole::CurrentTarget)? + } else { + CoordinationData::default() + }; + let blocked_sessions = target_wins_session_ids(roots)?; + let conflicts = preview_conflicts(&source, &target, &blocked_sessions); + let entity_count = coordination_entity_count(&source); + let logical_bytes = fs::metadata(&source_path) + .map_err(|error| io_error(&source_path, error))? + .len(); + Ok(DomainScan { + finding: ScanFinding { + domain: self.domain(), + code: "legacy_agent_coordination_supported".to_string(), + severity: if conflicts.is_empty() { + FindingSeverity::Info + } else { + FindingSeverity::Warning + }, + entity_count, + logical_bytes, + source_schema: Some(format!( + "bitfun.agent-coordination.v{}", + schema_version(&source_path)? + )), + migratable: true, + detail: format!("{entity_count} Agent coordination records are owner-readable"), + }, + conflicts, + target_schema: Some(format!( + "openbitfun.agent-coordination.v{COORDINATION_SCHEMA_VERSION}" + )), + dependencies: vec![MigrationDomainId::WorkspaceSessions], + }) + } + + fn stage(&self, context: &DomainContext<'_>) -> LegacyMigrationResult { + let domain_root = stage_domain_dir(context, "agent-coordination"); + let staged_source = domain_root.join("source.sqlite"); + let staged_target = domain_root.join("target-before.sqlite"); + let staged_merged = domain_root.join("merged.sqlite"); + reset_stage_file(&staged_source)?; + reset_stage_file(&staged_target)?; + reset_stage_file(&staged_merged)?; + + let source_path = source_coordination_path(context.roots); + validate_regular_file(&context.roots.legacy_user_root, &source_path)?; + snapshot_sqlite_read_only(&source_path, &staged_source)?; + initialize_snapshot(&staged_source)?; + let source = load_coordination_data(&staged_source, DatabaseRole::StagedCurrent)?; + let workspace_manifest = read_workspace_sessions_manifest(context)?; + validate_source_cross_references(&source, &workspace_manifest)?; + let blocked_sessions = manifest_target_wins_session_ids(&workspace_manifest); + + let target_path = target_coordination_path(context.roots); + let target_existed = target_path.exists(); + let target_digest = if target_existed { + validate_regular_file(&context.roots.target_user_root, &target_path)?; + snapshot_sqlite_read_only(&target_path, &staged_target)?; + initialize_snapshot(&staged_target)?; + snapshot_sqlite_read_only(&staged_target, &staged_merged)?; + Some(coordination_digest(&load_coordination_data( + &staged_target, + DatabaseRole::StagedCurrent, + )?)?) + } else { + let connection = Connection::open(&staged_merged) + .map_err(|error| db_error(&staged_merged, error))?; + initialize_coordination_schema(&connection) + .map_err(|error| owner_error("initialize staged coordination database", error))?; + None + }; + + let outcome = + merge_coordination_database(&staged_source, &staged_merged, &blocked_sessions)?; + validate_sqlite(&staged_merged)?; + validate_current_database(&staged_merged)?; + let merged = load_coordination_data(&staged_merged, DatabaseRole::StagedCurrent)?; + let manifest = CoordinationManifest { + source_digest: coordination_digest(&source)?, + target_existed, + target_digest, + merged_digest: coordination_digest(&merged)?, + imported: outcome.imported, + skipped: outcome.duplicate.saturating_add(outcome.target_wins), + conflicts: outcome.target_wins, + }; + atomic_write_json(&coordination_manifest_path(context), &manifest)?; + + Ok(MigrationDomainResult { + domain: self.domain(), + state: MigrationDomainState::Staged, + imported: manifest.imported, + skipped: manifest.skipped, + conflicts: manifest.conflicts, + ..MigrationDomainResult::default() + }) + } + + fn validate_stage(&self, context: &DomainContext<'_>) -> LegacyMigrationResult<()> { + let manifest = read_coordination_manifest(context)?; + let domain_root = stage_domain_dir(context, "agent-coordination"); + let source_path = domain_root.join("source.sqlite"); + let merged_path = domain_root.join("merged.sqlite"); + validate_sqlite(&source_path)?; + validate_sqlite(&merged_path)?; + validate_current_database(&merged_path)?; + let source = load_coordination_data(&source_path, DatabaseRole::StagedCurrent)?; + if coordination_digest(&source)? != manifest.source_digest { + return Err(LegacyMigrationError::InvalidRequest( + "staged Agent coordination source changed after snapshot".to_string(), + )); + } + let merged = load_coordination_data(&merged_path, DatabaseRole::StagedCurrent)?; + if coordination_digest(&merged)? != manifest.merged_digest { + return Err(LegacyMigrationError::InvalidRequest( + "staged Agent coordination merge differs from its manifest".to_string(), + )); + } + validate_source_cross_references(&source, &read_workspace_sessions_manifest(context)?) + } + + fn commit(&self, context: &DomainContext<'_>) -> LegacyMigrationResult<()> { + let manifest = read_coordination_manifest(context)?; + let target = target_coordination_path(context.roots); + if target.exists() { + validate_regular_file(&context.roots.target_user_root, &target)?; + if validate_current_database(&target).is_ok() { + let current = load_coordination_data(&target, DatabaseRole::CurrentTarget)?; + if coordination_digest(¤t)? == manifest.merged_digest { + finalize_sqlite_file(&target)?; + return Ok(()); + } + } + } + verify_target_state(&target, &manifest)?; + let backup = backup_domain_dir(context, "agent-coordination").join("coordination.sqlite"); + if manifest.target_existed && !backup.exists() { + snapshot_sqlite_read_only(&target, &backup)?; + } + if let Some(parent) = target.parent() { + fs::create_dir_all(parent).map_err(|error| io_error(parent, error))?; + } + let staged_source = stage_domain_dir(context, "agent-coordination").join("source.sqlite"); + let blocked_sessions = + manifest_target_wins_session_ids(&read_workspace_sessions_manifest(context)?); + merge_coordination_database(&staged_source, &target, &blocked_sessions)?; + finalize_sqlite_file(&target)?; + validate_current_database(&target)?; + let merged = load_coordination_data(&target, DatabaseRole::CurrentTarget)?; + if coordination_digest(&merged)? != manifest.merged_digest { + return Err(LegacyMigrationError::InvalidRequest( + "committed Agent coordination database differs from the staged merge".to_string(), + )); + } + Ok(()) + } + + fn validate_commit(&self, context: &DomainContext<'_>) -> LegacyMigrationResult<()> { + validate_committed_coordination_cross_references(context) + } + + fn rollback_unverified(&self, context: &DomainContext<'_>) -> LegacyMigrationResult<()> { + let Some(manifest) = read_optional_bounded_json::( + &context.layout.stage_root(), + &coordination_manifest_path(context), + )? + else { + return Ok(()); + }; + let target = target_coordination_path(context.roots); + remove_sqlite_sidecars(&target)?; + let backup = backup_domain_dir(context, "agent-coordination").join("coordination.sqlite"); + if manifest.target_existed { + if backup.exists() { + let bytes = fs::read(&backup).map_err(|error| io_error(&backup, error))?; + atomic_write_bytes(&target, &bytes)?; + } + } else { + remove_file_if_present(&target)?; + } + remove_sqlite_sidecars(&target) + } +} + +pub(crate) fn validate_committed_coordination_cross_references( + context: &DomainContext<'_>, +) -> LegacyMigrationResult<()> { + let manifest = read_coordination_manifest(context)?; + let target = target_coordination_path(context.roots); + validate_sqlite(&target)?; + validate_current_database(&target)?; + let actual = load_coordination_data(&target, DatabaseRole::CurrentTarget)?; + if coordination_digest(&actual)? != manifest.merged_digest { + return Err(LegacyMigrationError::InvalidRequest( + "committed Agent coordination database changed before cross-reference validation" + .to_string(), + )); + } + let source = load_coordination_data( + &stage_domain_dir(context, "agent-coordination").join("source.sqlite"), + DatabaseRole::StagedCurrent, + )?; + validate_source_cross_references(&source, &read_workspace_sessions_manifest(context)?) +} + +fn source_coordination_path(roots: &MigrationRoots) -> PathBuf { + roots.legacy_user_root.join(COORDINATION_RELATIVE_PATH) +} + +fn target_coordination_path(roots: &MigrationRoots) -> PathBuf { + roots.target_user_root.join(COORDINATION_RELATIVE_PATH) +} + +fn coordination_manifest_path(context: &DomainContext<'_>) -> PathBuf { + stage_domain_dir(context, "agent-coordination").join("manifest.json") +} + +fn read_coordination_manifest( + context: &DomainContext<'_>, +) -> LegacyMigrationResult { + read_bounded_json( + &context.layout.stage_root(), + &coordination_manifest_path(context), + ) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum DatabaseRole { + LegacySource, + CurrentTarget, + StagedCurrent, +} + +fn load_coordination_data( + path: &Path, + role: DatabaseRole, +) -> LegacyMigrationResult { + validate_sqlite(path)?; + let connection = Connection::open_with_flags( + path, + OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX, + ) + .map_err(|error| db_error(path, error))?; + let version = connection + .query_row("PRAGMA user_version", [], |row| row.get::<_, i64>(0)) + .map_err(|error| db_error(path, error))?; + match role { + DatabaseRole::LegacySource if !(1..=COORDINATION_SCHEMA_VERSION).contains(&version) => { + return Err(LegacyMigrationError::UnsupportedSource(format!( + "legacy Agent coordination schema {version} is not supported" + ))); + } + DatabaseRole::CurrentTarget if !(0..=COORDINATION_SCHEMA_VERSION).contains(&version) => { + return Err(LegacyMigrationError::InvalidRequest(format!( + "target Agent coordination schema {version} is not supported" + ))); + } + DatabaseRole::StagedCurrent if version != COORDINATION_SCHEMA_VERSION => { + return Err(LegacyMigrationError::InvalidRequest(format!( + "staged Agent coordination schema {version} is not current" + ))); + } + _ => {} + } + if version == 0 && role == DatabaseRole::CurrentTarget { + return Ok(CoordinationData::default()); + } + validate_required_tables(&connection, path, version)?; + let delivered_columns = + coordination_table_has_column(&connection, "background_tasks", "delivered_at_ms") + .map_err(|error| owner_error("inspect coordination delivery columns", error))? + && coordination_table_has_column( + &connection, + "background_tasks", + "delivered_parent_dialog_turn_id", + ) + .map_err(|error| owner_error("inspect coordination delivery columns", error))?; + let sessions = query_rows( + &connection, + path, + "SELECT parent_session_id, next_auto_agent_seq, updated_at_ms FROM coordination_sessions ORDER BY parent_session_id", + |row| { + Ok(CoordinationSessionRow { + parent_session_id: row.get(0)?, + next_auto_agent_seq: row.get(1)?, + updated_at_ms: row.get(2)?, + }) + }, + )?; + let agents = query_rows( + &connection, + path, + "SELECT agent_pk, parent_session_id, agent_id, child_session_id, next_bg_seq, state, created_at_ms FROM agents ORDER BY agent_pk", + |row| { + Ok(AgentRow { + agent_pk: row.get(0)?, + parent_session_id: row.get(1)?, + agent_id: row.get(2)?, + child_session_id: row.get(3)?, + next_bg_seq: row.get(4)?, + state: row.get(5)?, + created_at_ms: row.get(6)?, + }) + }, + )?; + let task_sql = if delivered_columns { + "SELECT task_pk, parent_session_id, agent_pk, bg_task_id, bg_ordinal, parent_dialog_turn_id, parent_tool_call_id, child_dialog_turn_id, status, error_code, error_message, execution_owner_token, created_at_ms, terminal_at_ms, delivered_at_ms, delivered_parent_dialog_turn_id FROM background_tasks ORDER BY task_pk" + } else { + "SELECT task_pk, parent_session_id, agent_pk, bg_task_id, bg_ordinal, parent_dialog_turn_id, parent_tool_call_id, child_dialog_turn_id, status, error_code, error_message, execution_owner_token, created_at_ms, terminal_at_ms, NULL, NULL FROM background_tasks ORDER BY task_pk" + }; + let tasks = query_rows(&connection, path, task_sql, |row| { + Ok(BackgroundTaskRow { + task_pk: row.get(0)?, + parent_session_id: row.get(1)?, + agent_pk: row.get(2)?, + bg_task_id: row.get(3)?, + bg_ordinal: row.get(4)?, + parent_dialog_turn_id: row.get(5)?, + parent_tool_call_id: row.get(6)?, + child_dialog_turn_id: row.get(7)?, + status: row.get(8)?, + error_code: row.get(9)?, + error_message: row.get(10)?, + execution_owner_token: row.get(11)?, + created_at_ms: row.get(12)?, + terminal_at_ms: row.get(13)?, + delivered_at_ms: row.get(14)?, + delivered_parent_dialog_turn_id: row.get(15)?, + }) + })?; + let (swarm_trees, swarm_nodes) = if version >= 2 { + ( + query_rows( + &connection, + path, + "SELECT root_session_id, created_at_ms FROM swarm_trees ORDER BY root_session_id", + |row| { + Ok(SwarmTreeRow { + root_session_id: row.get(0)?, + created_at_ms: row.get(1)?, + }) + }, + )?, + query_rows( + &connection, + path, + "SELECT session_id, root_session_id, parent_session_id, agent_type, depth, created_at_ms FROM swarm_nodes ORDER BY depth, session_id", + |row| { + Ok(SwarmNodeRow { + session_id: row.get(0)?, + root_session_id: row.get(1)?, + parent_session_id: row.get(2)?, + agent_type: row.get(3)?, + depth: row.get(4)?, + created_at_ms: row.get(5)?, + }) + }, + )?, + ) + } else { + (Vec::new(), Vec::new()) + }; + let data = CoordinationData { + sessions, + agents, + tasks, + swarm_trees, + swarm_nodes, + }; + validate_coordination_data(&data)?; + Ok(data) +} + +fn query_rows( + connection: &Connection, + path: &Path, + sql: &str, + mapper: F, +) -> LegacyMigrationResult> +where + F: FnMut(&rusqlite::Row<'_>) -> rusqlite::Result, +{ + let mut statement = connection + .prepare(sql) + .map_err(|error| db_error(path, error))?; + let rows = statement + .query_map([], mapper) + .map_err(|error| db_error(path, error))?; + rows.collect::>>() + .map_err(|error| db_error(path, error)) +} + +fn validate_required_tables( + connection: &Connection, + path: &Path, + version: i64, +) -> LegacyMigrationResult<()> { + let mut expected = vec![ + ( + "coordination_sessions", + vec!["parent_session_id", "next_auto_agent_seq", "updated_at_ms"], + ), + ( + "agents", + vec![ + "agent_pk", + "parent_session_id", + "agent_id", + "child_session_id", + "next_bg_seq", + "state", + "created_at_ms", + ], + ), + ( + "background_tasks", + vec![ + "task_pk", + "parent_session_id", + "agent_pk", + "bg_task_id", + "bg_ordinal", + "parent_dialog_turn_id", + "parent_tool_call_id", + "child_dialog_turn_id", + "status", + "error_code", + "error_message", + "execution_owner_token", + "created_at_ms", + "terminal_at_ms", + ], + ), + ]; + if version >= 2 { + expected.extend([ + ("swarm_trees", vec!["root_session_id", "created_at_ms"]), + ( + "swarm_nodes", + vec![ + "session_id", + "root_session_id", + "parent_session_id", + "agent_type", + "depth", + "created_at_ms", + ], + ), + ]); + } + for (table, columns) in expected { + let actual = table_columns(connection, path, table)?; + if actual.is_empty() || columns.iter().any(|column| !actual.contains(*column)) { + return Err(LegacyMigrationError::UnsupportedSource(format!( + "Agent coordination table {table} does not match the supported schema" + ))); + } + } + Ok(()) +} + +fn table_columns( + connection: &Connection, + path: &Path, + table: &str, +) -> LegacyMigrationResult> { + let mut statement = connection + .prepare(&format!("PRAGMA table_info({table})")) + .map_err(|error| db_error(path, error))?; + let rows = statement + .query_map([], |row| row.get::<_, String>(1)) + .map_err(|error| db_error(path, error))?; + rows.collect::>>() + .map_err(|error| db_error(path, error)) +} + +fn validate_coordination_data(data: &CoordinationData) -> LegacyMigrationResult<()> { + let mut session_keys = HashSet::new(); + for row in &data.sessions { + validate_session(&row.parent_session_id)?; + if row.next_auto_agent_seq < 1 || row.updated_at_ms < 0 { + return unsupported("coordination Session counters or timestamps are invalid"); + } + if !session_keys.insert(row.parent_session_id.as_str()) { + return unsupported("coordination Session identities are not unique"); + } + } + + let mut agent_pks = HashSet::new(); + let mut agent_ids = HashSet::new(); + let mut child_ids = HashSet::new(); + for row in &data.agents { + validate_session(&row.parent_session_id)?; + if let Some(child) = row.child_session_id.as_deref() { + validate_session(child)?; + if !child_ids.insert((row.parent_session_id.as_str(), child)) { + return unsupported("Agent child Session identities are not unique per parent"); + } + } + if row.agent_pk <= 0 + || row.next_bg_seq < 1 + || row.created_at_ms < 0 + || !matches!(row.state.as_str(), "active" | "historical") + { + return unsupported("Agent coordination row contains invalid owner fields"); + } + validate_coordination_agent_id(&row.agent_id) + .map_err(|error| owner_error("validate legacy agent id", error))?; + if !agent_pks.insert(row.agent_pk) + || !agent_ids.insert((row.parent_session_id.as_str(), row.agent_id.as_str())) + { + return unsupported("Agent coordination identities are not unique"); + } + } + + let agents = data + .agents + .iter() + .map(|row| (row.agent_pk, row)) + .collect::>(); + let mut task_pks = HashSet::new(); + let mut task_ids = HashSet::new(); + let mut task_ordinals = HashSet::new(); + for row in &data.tasks { + validate_session(&row.parent_session_id)?; + let Some(agent) = agents.get(&row.agent_pk) else { + return unsupported("background Task references a missing Agent"); + }; + if agent.parent_session_id != row.parent_session_id { + return unsupported("background Task parent Session differs from its Agent"); + } + if row.task_pk <= 0 + || row.bg_task_id.is_empty() + || row.bg_ordinal < 1 + || row.parent_dialog_turn_id.is_empty() + || row.parent_tool_call_id.is_empty() + || row.child_dialog_turn_id.is_empty() + || row.execution_owner_token.is_empty() + || row.created_at_ms < 0 + || row.terminal_at_ms.is_some_and(|value| value < 0) + || row.delivered_at_ms.is_some_and(|value| value < 0) + || !matches!( + row.status.as_str(), + "running" + | "completed" + | "partial_timeout" + | "failed" + | "cancelled" + | "interrupted" + ) + { + return unsupported("background Task row contains invalid owner fields"); + } + if !task_pks.insert(row.task_pk) + || !task_ids.insert((row.parent_session_id.as_str(), row.bg_task_id.as_str())) + || !task_ordinals.insert((row.agent_pk, row.bg_ordinal)) + { + return unsupported("background Task identities are not unique"); + } + } + + let tree_ids = data + .swarm_trees + .iter() + .map(|row| row.root_session_id.as_str()) + .collect::>(); + if tree_ids.len() != data.swarm_trees.len() { + return unsupported("Swarm tree identities are not unique"); + } + for row in &data.swarm_trees { + validate_session(&row.root_session_id)?; + if row.created_at_ms < 0 { + return unsupported("Swarm tree timestamp is invalid"); + } + } + let nodes = data + .swarm_nodes + .iter() + .map(|row| (row.session_id.as_str(), row)) + .collect::>(); + if nodes.len() != data.swarm_nodes.len() { + return unsupported("Swarm node identities are not unique"); + } + for row in &data.swarm_nodes { + validate_session(&row.session_id)?; + validate_session(&row.root_session_id)?; + if !tree_ids.contains(row.root_session_id.as_str()) + || row.agent_type.trim().is_empty() + || row.depth < 0 + || row.created_at_ms < 0 + { + return unsupported("Swarm node contains invalid owner fields"); + } + match row.parent_session_id.as_deref() { + None if row.session_id == row.root_session_id && row.depth == 0 => {} + Some(parent_id) => { + let Some(parent) = nodes.get(parent_id) else { + return unsupported("Swarm node references a missing parent node"); + }; + if parent.root_session_id != row.root_session_id + || parent.depth.saturating_add(1) != row.depth + { + return unsupported("Swarm node lineage is inconsistent"); + } + } + _ => return unsupported("Swarm root node shape is invalid"), + } + } + Ok(()) +} + +fn validate_source_cross_references( + data: &CoordinationData, + manifest: &WorkspaceSessionsManifest, +) -> LegacyMigrationResult<()> { + let sessions = manifest + .sessions + .iter() + .map(|entry| (entry.session_id.as_str(), entry)) + .collect::>(); + let mut turns = HashMap::<&str, BTreeSet<&str>>::new(); + for entry in &manifest.sessions { + turns + .entry(entry.session_id.as_str()) + .or_default() + .extend(entry.turn_ids.iter().map(String::as_str)); + } + for entry in &manifest.runtime_events { + turns + .entry(entry.session_id.as_str()) + .or_default() + .extend(entry.turn_ids.iter().map(String::as_str)); + } + let require_session = |session_id: &str| -> LegacyMigrationResult<()> { + if sessions.contains_key(session_id) { + Ok(()) + } else { + unsupported(format!( + "Agent coordination references missing Session {session_id}" + )) + } + }; + let require_turn = |session_id: &str, turn_id: &str| -> LegacyMigrationResult<()> { + if turns + .get(session_id) + .is_some_and(|values| values.contains(turn_id)) + { + Ok(()) + } else { + unsupported(format!( + "Agent coordination references missing Turn {turn_id} in Session {session_id}" + )) + } + }; + for row in &data.sessions { + require_session(&row.parent_session_id)?; + } + let agents = data + .agents + .iter() + .map(|row| (row.agent_pk, row)) + .collect::>(); + for row in &data.agents { + require_session(&row.parent_session_id)?; + if let Some(child_id) = row.child_session_id.as_deref() { + require_session(child_id)?; + let relationship = sessions[child_id].relationship.as_ref().ok_or_else(|| { + LegacyMigrationError::UnsupportedSource(format!( + "Agent child Session {child_id} has no parent relationship" + )) + })?; + if relationship.parent_session_id.as_deref() != Some(&row.parent_session_id) { + return unsupported(format!( + "Agent child Session {child_id} has a different persisted parent" + )); + } + } + } + for row in &data.tasks { + let agent = agents[&row.agent_pk]; + let child_session_id = agent.child_session_id.as_deref().ok_or_else(|| { + LegacyMigrationError::UnsupportedSource(format!( + "background Task {} references an Agent without a child Session", + row.bg_task_id + )) + })?; + require_turn(&row.parent_session_id, &row.parent_dialog_turn_id)?; + require_turn(child_session_id, &row.child_dialog_turn_id)?; + if let Some(delivered_turn) = row.delivered_parent_dialog_turn_id.as_deref() { + require_turn(&row.parent_session_id, delivered_turn)?; + } + } + for row in &data.swarm_trees { + require_session(&row.root_session_id)?; + } + for row in &data.swarm_nodes { + require_session(&row.session_id)?; + require_session(&row.root_session_id)?; + if let Some(parent) = row.parent_session_id.as_deref() { + require_session(parent)?; + } + } + Ok(()) +} + +fn merge_coordination_database( + source_path: &Path, + target_path: &Path, + blocked_sessions: &BTreeSet, +) -> LegacyMigrationResult { + let source = load_coordination_data(source_path, DatabaseRole::StagedCurrent)?; + let mut connection = + Connection::open(target_path).map_err(|error| db_error(target_path, error))?; + initialize_coordination_schema(&connection) + .map_err(|error| owner_error("initialize target coordination database", error))?; + connection + .execute_batch("PRAGMA foreign_keys = ON;") + .map_err(|error| db_error(target_path, error))?; + let transaction = connection + .transaction() + .map_err(|error| db_error(target_path, error))?; + let mut outcome = MergeOutcome::default(); + merge_coordination_sessions( + &transaction, + &source.sessions, + blocked_sessions, + &mut outcome, + target_path, + )?; + let agent_map = merge_agents( + &transaction, + &source.agents, + blocked_sessions, + &mut outcome, + target_path, + )?; + merge_background_tasks( + &transaction, + &source.tasks, + &agent_map, + blocked_sessions, + &mut outcome, + target_path, + )?; + merge_swarm_trees( + &transaction, + &source.swarm_trees, + blocked_sessions, + &mut outcome, + target_path, + )?; + merge_swarm_nodes( + &transaction, + &source.swarm_nodes, + blocked_sessions, + &mut outcome, + target_path, + )?; + transaction + .commit() + .map_err(|error| db_error(target_path, error))?; + Ok(outcome) +} + +fn merge_coordination_sessions( + transaction: &Transaction<'_>, + rows: &[CoordinationSessionRow], + blocked_sessions: &BTreeSet, + outcome: &mut MergeOutcome, + path: &Path, +) -> LegacyMigrationResult<()> { + for row in rows { + if blocked_sessions.contains(&row.parent_session_id) { + record_target_win( + outcome, + "coordination_session_data_target_wins", + &row.parent_session_id, + ); + continue; + } + let existing = transaction + .query_row( + "SELECT parent_session_id, next_auto_agent_seq, updated_at_ms FROM coordination_sessions WHERE parent_session_id = ?1", + params![row.parent_session_id], + |record| Ok(CoordinationSessionRow { + parent_session_id: record.get(0)?, + next_auto_agent_seq: record.get(1)?, + updated_at_ms: record.get(2)?, + }), + ) + .optional() + .map_err(|error| db_error(path, error))?; + match existing { + Some(existing) if existing == *row => outcome.duplicate += 1, + Some(_) => record_target_win( + outcome, + "coordination_session_target_wins", + &row.parent_session_id, + ), + None => { + transaction.execute( + "INSERT INTO coordination_sessions (parent_session_id, next_auto_agent_seq, updated_at_ms) VALUES (?1, ?2, ?3)", + params![row.parent_session_id, row.next_auto_agent_seq, row.updated_at_ms], + ).map_err(|error| db_error(path, error))?; + outcome.imported += 1; + } + } + } + Ok(()) +} + +fn merge_agents( + transaction: &Transaction<'_>, + rows: &[AgentRow], + blocked_sessions: &BTreeSet, + outcome: &mut MergeOutcome, + path: &Path, +) -> LegacyMigrationResult> { + let mut mapping = HashMap::new(); + for row in rows { + if blocked_sessions.contains(&row.parent_session_id) + || row + .child_session_id + .as_ref() + .is_some_and(|session_id| blocked_sessions.contains(session_id)) + { + mapping.insert(row.agent_pk, AgentMergeTarget::Rejected); + record_target_win( + outcome, + "coordination_agent_session_target_wins", + &format!("{}:{}", row.parent_session_id, row.agent_id), + ); + continue; + } + let mut candidates = BTreeSet::new(); + if let Some(pk) = transaction + .query_row( + "SELECT agent_pk FROM agents WHERE parent_session_id = ?1 AND agent_id = ?2", + params![row.parent_session_id, row.agent_id], + |record| record.get::<_, i64>(0), + ) + .optional() + .map_err(|error| db_error(path, error))? + { + candidates.insert(pk); + } + if let Some(child) = row.child_session_id.as_deref() { + if let Some(pk) = transaction.query_row( + "SELECT agent_pk FROM agents WHERE parent_session_id = ?1 AND child_session_id = ?2", + params![row.parent_session_id, child], + |record| record.get::<_, i64>(0), + ).optional().map_err(|error| db_error(path, error))? { + candidates.insert(pk); + } + } + if candidates.is_empty() { + transaction.execute( + "INSERT INTO agents (parent_session_id, agent_id, child_session_id, next_bg_seq, state, created_at_ms) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + params![row.parent_session_id, row.agent_id, row.child_session_id, row.next_bg_seq, row.state, row.created_at_ms], + ).map_err(|error| db_error(path, error))?; + let pk = transaction.last_insert_rowid(); + mapping.insert(row.agent_pk, AgentMergeTarget::Imported(pk)); + outcome.imported += 1; + continue; + } + if candidates.len() == 1 { + let pk = *candidates.iter().next().expect("one candidate exists"); + let existing = load_agent_by_pk(transaction, path, pk)?; + if agent_logically_equal(&existing, row) { + mapping.insert(row.agent_pk, AgentMergeTarget::Existing(pk)); + outcome.duplicate += 1; + continue; + } + } + mapping.insert(row.agent_pk, AgentMergeTarget::Rejected); + record_target_win( + outcome, + "coordination_agent_target_wins", + &format!("{}:{}", row.parent_session_id, row.agent_id), + ); + } + Ok(mapping) +} + +fn merge_background_tasks( + transaction: &Transaction<'_>, + rows: &[BackgroundTaskRow], + agent_map: &HashMap, + blocked_sessions: &BTreeSet, + outcome: &mut MergeOutcome, + path: &Path, +) -> LegacyMigrationResult<()> { + for row in rows { + if blocked_sessions.contains(&row.parent_session_id) { + record_target_win( + outcome, + "coordination_task_session_target_wins", + &row.bg_task_id, + ); + continue; + } + let Some(agent_target) = agent_map.get(&row.agent_pk).copied() else { + return unsupported("background Task Agent mapping was not constructed"); + }; + let Some(agent_pk) = agent_target.agent_pk() else { + record_target_win( + outcome, + "coordination_task_agent_target_wins", + &row.bg_task_id, + ); + continue; + }; + let by_id = transaction.query_row( + "SELECT task_pk FROM background_tasks WHERE parent_session_id = ?1 AND bg_task_id = ?2", + params![row.parent_session_id, row.bg_task_id], + |record| record.get::<_, i64>(0), + ).optional().map_err(|error| db_error(path, error))?; + let by_ordinal = transaction + .query_row( + "SELECT task_pk FROM background_tasks WHERE agent_pk = ?1 AND bg_ordinal = ?2", + params![agent_pk, row.bg_ordinal], + |record| record.get::<_, i64>(0), + ) + .optional() + .map_err(|error| db_error(path, error))?; + let candidates = [by_id, by_ordinal] + .into_iter() + .flatten() + .collect::>(); + if candidates.is_empty() { + transaction.execute( + "INSERT INTO background_tasks (parent_session_id, agent_pk, bg_task_id, bg_ordinal, parent_dialog_turn_id, parent_tool_call_id, child_dialog_turn_id, status, error_code, error_message, execution_owner_token, created_at_ms, terminal_at_ms, delivered_at_ms, delivered_parent_dialog_turn_id) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)", + params![row.parent_session_id, agent_pk, row.bg_task_id, row.bg_ordinal, row.parent_dialog_turn_id, row.parent_tool_call_id, row.child_dialog_turn_id, row.status, row.error_code, row.error_message, row.execution_owner_token, row.created_at_ms, row.terminal_at_ms, row.delivered_at_ms, row.delivered_parent_dialog_turn_id], + ).map_err(|error| db_error(path, error))?; + outcome.imported += 1; + } else if candidates.len() == 1 { + let existing = load_task_by_pk( + transaction, + path, + *candidates.iter().next().expect("one candidate exists"), + )?; + if task_logically_equal(&existing, row, agent_pk) { + outcome.duplicate += 1; + } else { + record_target_win(outcome, "coordination_task_target_wins", &row.bg_task_id); + } + } else { + record_target_win(outcome, "coordination_task_target_wins", &row.bg_task_id); + } + } + Ok(()) +} + +fn merge_swarm_trees( + transaction: &Transaction<'_>, + rows: &[SwarmTreeRow], + blocked_sessions: &BTreeSet, + outcome: &mut MergeOutcome, + path: &Path, +) -> LegacyMigrationResult<()> { + for row in rows { + if blocked_sessions.contains(&row.root_session_id) { + record_target_win( + outcome, + "coordination_swarm_tree_session_target_wins", + &row.root_session_id, + ); + continue; + } + let existing = transaction + .query_row( + "SELECT root_session_id, created_at_ms FROM swarm_trees WHERE root_session_id = ?1", + params![row.root_session_id], + |record| { + Ok(SwarmTreeRow { + root_session_id: record.get(0)?, + created_at_ms: record.get(1)?, + }) + }, + ) + .optional() + .map_err(|error| db_error(path, error))?; + match existing { + Some(existing) if existing == *row => outcome.duplicate += 1, + Some(_) => record_target_win( + outcome, + "coordination_swarm_tree_target_wins", + &row.root_session_id, + ), + None => { + transaction + .execute( + "INSERT INTO swarm_trees (root_session_id, created_at_ms) VALUES (?1, ?2)", + params![row.root_session_id, row.created_at_ms], + ) + .map_err(|error| db_error(path, error))?; + outcome.imported += 1; + } + } + } + Ok(()) +} + +fn merge_swarm_nodes( + transaction: &Transaction<'_>, + rows: &[SwarmNodeRow], + blocked_sessions: &BTreeSet, + outcome: &mut MergeOutcome, + path: &Path, +) -> LegacyMigrationResult<()> { + for row in rows { + if blocked_sessions.contains(&row.session_id) + || blocked_sessions.contains(&row.root_session_id) + || row + .parent_session_id + .as_ref() + .is_some_and(|session_id| blocked_sessions.contains(session_id)) + { + record_target_win( + outcome, + "coordination_swarm_node_session_target_wins", + &row.session_id, + ); + continue; + } + let existing = transaction.query_row( + "SELECT session_id, root_session_id, parent_session_id, agent_type, depth, created_at_ms FROM swarm_nodes WHERE session_id = ?1", + params![row.session_id], + |record| Ok(SwarmNodeRow { session_id: record.get(0)?, root_session_id: record.get(1)?, parent_session_id: record.get(2)?, agent_type: record.get(3)?, depth: record.get(4)?, created_at_ms: record.get(5)? }), + ).optional().map_err(|error| db_error(path, error))?; + match existing { + Some(existing) if existing == *row => outcome.duplicate += 1, + Some(_) => record_target_win( + outcome, + "coordination_swarm_node_target_wins", + &row.session_id, + ), + None => { + transaction.execute( + "INSERT INTO swarm_nodes (session_id, root_session_id, parent_session_id, agent_type, depth, created_at_ms) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + params![row.session_id, row.root_session_id, row.parent_session_id, row.agent_type, row.depth, row.created_at_ms], + ).map_err(|error| db_error(path, error))?; + outcome.imported += 1; + } + } + } + Ok(()) +} + +fn load_agent_by_pk( + transaction: &Transaction<'_>, + path: &Path, + agent_pk: i64, +) -> LegacyMigrationResult { + transaction.query_row( + "SELECT agent_pk, parent_session_id, agent_id, child_session_id, next_bg_seq, state, created_at_ms FROM agents WHERE agent_pk = ?1", + params![agent_pk], + |record| Ok(AgentRow { agent_pk: record.get(0)?, parent_session_id: record.get(1)?, agent_id: record.get(2)?, child_session_id: record.get(3)?, next_bg_seq: record.get(4)?, state: record.get(5)?, created_at_ms: record.get(6)? }), + ).map_err(|error| db_error(path, error)) +} + +fn load_task_by_pk( + transaction: &Transaction<'_>, + path: &Path, + task_pk: i64, +) -> LegacyMigrationResult { + transaction.query_row( + "SELECT task_pk, parent_session_id, agent_pk, bg_task_id, bg_ordinal, parent_dialog_turn_id, parent_tool_call_id, child_dialog_turn_id, status, error_code, error_message, execution_owner_token, created_at_ms, terminal_at_ms, delivered_at_ms, delivered_parent_dialog_turn_id FROM background_tasks WHERE task_pk = ?1", + params![task_pk], + |record| Ok(BackgroundTaskRow { task_pk: record.get(0)?, parent_session_id: record.get(1)?, agent_pk: record.get(2)?, bg_task_id: record.get(3)?, bg_ordinal: record.get(4)?, parent_dialog_turn_id: record.get(5)?, parent_tool_call_id: record.get(6)?, child_dialog_turn_id: record.get(7)?, status: record.get(8)?, error_code: record.get(9)?, error_message: record.get(10)?, execution_owner_token: record.get(11)?, created_at_ms: record.get(12)?, terminal_at_ms: record.get(13)?, delivered_at_ms: record.get(14)?, delivered_parent_dialog_turn_id: record.get(15)? }), + ).map_err(|error| db_error(path, error)) +} + +fn agent_logically_equal(left: &AgentRow, right: &AgentRow) -> bool { + left.parent_session_id == right.parent_session_id + && left.agent_id == right.agent_id + && left.child_session_id == right.child_session_id + && left.next_bg_seq == right.next_bg_seq + && left.state == right.state + && left.created_at_ms == right.created_at_ms +} + +fn task_logically_equal( + left: &BackgroundTaskRow, + right: &BackgroundTaskRow, + agent_pk: i64, +) -> bool { + left.parent_session_id == right.parent_session_id + && left.agent_pk == agent_pk + && left.bg_task_id == right.bg_task_id + && left.bg_ordinal == right.bg_ordinal + && left.parent_dialog_turn_id == right.parent_dialog_turn_id + && left.parent_tool_call_id == right.parent_tool_call_id + && left.child_dialog_turn_id == right.child_dialog_turn_id + && left.status == right.status + && left.error_code == right.error_code + && left.error_message == right.error_message + && left.execution_owner_token == right.execution_owner_token + && left.created_at_ms == right.created_at_ms + && left.terminal_at_ms == right.terminal_at_ms + && left.delivered_at_ms == right.delivered_at_ms + && left.delivered_parent_dialog_turn_id == right.delivered_parent_dialog_turn_id +} + +fn preview_conflicts( + source: &CoordinationData, + target: &CoordinationData, + blocked_sessions: &BTreeSet, +) -> Vec { + let mut conflicts = Vec::new(); + let target_sessions = target + .sessions + .iter() + .map(|row| (&row.parent_session_id, row)) + .collect::>(); + for row in &source.sessions { + if blocked_sessions.contains(&row.parent_session_id) { + conflicts.push(conflict( + "coordination_session_data_target_wins", + &row.parent_session_id, + )); + } else if target_sessions + .get(&row.parent_session_id) + .is_some_and(|existing| *existing != row) + { + conflicts.push(conflict( + "coordination_session_target_wins", + &row.parent_session_id, + )); + } + } + + let mut agent_map = HashMap::new(); + for row in &source.agents { + if blocked_sessions.contains(&row.parent_session_id) + || row + .child_session_id + .as_ref() + .is_some_and(|session_id| blocked_sessions.contains(session_id)) + { + agent_map.insert(row.agent_pk, AgentMergeTarget::Rejected); + conflicts.push(conflict( + "coordination_agent_session_target_wins", + &format!("{}:{}", row.parent_session_id, row.agent_id), + )); + continue; + } + + let candidates = target + .agents + .iter() + .filter(|existing| { + existing.parent_session_id == row.parent_session_id + && (existing.agent_id == row.agent_id + || (row.child_session_id.is_some() + && existing.child_session_id == row.child_session_id)) + }) + .collect::>(); + match candidates.as_slice() { + [] => { + agent_map.insert(row.agent_pk, AgentMergeTarget::Imported(row.agent_pk)); + } + [existing] if agent_logically_equal(existing, row) => { + agent_map.insert(row.agent_pk, AgentMergeTarget::Existing(existing.agent_pk)); + } + _ => { + agent_map.insert(row.agent_pk, AgentMergeTarget::Rejected); + conflicts.push(conflict( + "coordination_agent_target_wins", + &format!("{}:{}", row.parent_session_id, row.agent_id), + )); + } + } + } + + for row in &source.tasks { + if blocked_sessions.contains(&row.parent_session_id) { + conflicts.push(conflict( + "coordination_task_session_target_wins", + &row.bg_task_id, + )); + continue; + } + let Some(agent_target) = agent_map.get(&row.agent_pk).copied() else { + continue; + }; + let agent_pk = match agent_target { + AgentMergeTarget::Rejected => { + conflicts.push(conflict( + "coordination_task_agent_target_wins", + &row.bg_task_id, + )); + continue; + } + AgentMergeTarget::Imported(_) => None, + AgentMergeTarget::Existing(agent_pk) => Some(agent_pk), + }; + let candidates = target + .tasks + .iter() + .filter(|existing| { + (existing.parent_session_id == row.parent_session_id + && existing.bg_task_id == row.bg_task_id) + || agent_pk.is_some_and(|agent_pk| { + existing.agent_pk == agent_pk && existing.bg_ordinal == row.bg_ordinal + }) + }) + .collect::>(); + let duplicate = match (candidates.as_slice(), agent_pk) { + ([existing], Some(agent_pk)) => task_logically_equal(existing, row, agent_pk), + _ => false, + }; + if !candidates.is_empty() && !duplicate { + conflicts.push(conflict("coordination_task_target_wins", &row.bg_task_id)); + } + } + + let target_trees = target + .swarm_trees + .iter() + .map(|row| (&row.root_session_id, row)) + .collect::>(); + for row in &source.swarm_trees { + if blocked_sessions.contains(&row.root_session_id) { + conflicts.push(conflict( + "coordination_swarm_tree_session_target_wins", + &row.root_session_id, + )); + } else if target_trees + .get(&row.root_session_id) + .is_some_and(|existing| *existing != row) + { + conflicts.push(conflict( + "coordination_swarm_tree_target_wins", + &row.root_session_id, + )); + } + } + + let target_nodes = target + .swarm_nodes + .iter() + .map(|row| (&row.session_id, row)) + .collect::>(); + for row in &source.swarm_nodes { + if blocked_sessions.contains(&row.session_id) + || blocked_sessions.contains(&row.root_session_id) + || row + .parent_session_id + .as_ref() + .is_some_and(|session_id| blocked_sessions.contains(session_id)) + { + conflicts.push(conflict( + "coordination_swarm_node_session_target_wins", + &row.session_id, + )); + } else if target_nodes + .get(&row.session_id) + .is_some_and(|existing| *existing != row) + { + conflicts.push(conflict( + "coordination_swarm_node_target_wins", + &row.session_id, + )); + } + } + conflicts +} + +fn record_target_win(outcome: &mut MergeOutcome, code: &'static str, logical_id: &str) { + outcome.target_wins = outcome.target_wins.saturating_add(1); + outcome.conflicts.push(conflict(code, logical_id)); +} + +fn conflict(code: &'static str, logical_id: &str) -> MigrationConflict { + MigrationConflict { + domain: MigrationDomainId::AgentCoordination, + code: code.to_string(), + source_summary: format!("legacy Agent coordination record {logical_id}"), + target_summary: format!("current Agent coordination record {logical_id}"), + resolution: ConflictResolution::TargetWins, + } +} + +fn verify_target_state(path: &Path, manifest: &CoordinationManifest) -> LegacyMigrationResult<()> { + if path.exists() != manifest.target_existed { + return Err(LegacyMigrationError::InvalidRequest(format!( + "target changed after staging: {}", + path.display() + ))); + } + if let Some(expected) = manifest.target_digest.as_deref() { + let actual = + coordination_digest(&load_coordination_data(path, DatabaseRole::CurrentTarget)?)?; + if actual != expected { + return Err(LegacyMigrationError::InvalidRequest(format!( + "target changed after staging: {}", + path.display() + ))); + } + } + Ok(()) +} + +fn initialize_snapshot(path: &Path) -> LegacyMigrationResult<()> { + let connection = Connection::open(path).map_err(|error| db_error(path, error))?; + initialize_coordination_schema(&connection) + .map_err(|error| owner_error("upgrade staged coordination snapshot", error)) +} + +fn finalize_sqlite_file(path: &Path) -> LegacyMigrationResult<()> { + let connection = Connection::open(path).map_err(|error| db_error(path, error))?; + connection + .execute_batch("PRAGMA wal_checkpoint(TRUNCATE); PRAGMA journal_mode = DELETE;") + .map_err(|error| db_error(path, error))?; + drop(connection); + remove_sqlite_sidecars(path) +} + +fn validate_current_database(path: &Path) -> LegacyMigrationResult<()> { + let connection = Connection::open_with_flags( + path, + OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX, + ) + .map_err(|error| db_error(path, error))?; + let version = connection + .query_row("PRAGMA user_version", [], |row| row.get::<_, i64>(0)) + .map_err(|error| db_error(path, error))?; + if version != COORDINATION_SCHEMA_VERSION { + return Err(LegacyMigrationError::InvalidRequest(format!( + "Agent coordination database is schema {version}, expected {COORDINATION_SCHEMA_VERSION}" + ))); + } + validate_required_tables(&connection, path, version)?; + for column in ["delivered_at_ms", "delivered_parent_dialog_turn_id"] { + if !coordination_table_has_column(&connection, "background_tasks", column) + .map_err(|error| owner_error("validate coordination schema", error))? + { + return Err(LegacyMigrationError::InvalidRequest(format!( + "Agent coordination database is missing {column}" + ))); + } + } + let foreign_key_error: Option = connection + .query_row("PRAGMA foreign_key_check", [], |row| row.get(0)) + .optional() + .map_err(|error| db_error(path, error))?; + if foreign_key_error.is_some() { + return Err(LegacyMigrationError::InvalidRequest( + "Agent coordination foreign-key validation failed".to_string(), + )); + } + Ok(()) +} + +fn schema_version(path: &Path) -> LegacyMigrationResult { + let connection = Connection::open_with_flags( + path, + OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX, + ) + .map_err(|error| db_error(path, error))?; + connection + .query_row("PRAGMA user_version", [], |row| row.get(0)) + .map_err(|error| db_error(path, error)) +} + +fn coordination_digest(data: &CoordinationData) -> LegacyMigrationResult { + serde_json::to_vec(data) + .map(|bytes| format!("sha256:{}", hex::encode(Sha256::digest(bytes)))) + .map_err(|error| { + LegacyMigrationError::InvalidRequest(format!( + "failed to hash Agent coordination data: {error}" + )) + }) +} + +fn coordination_entity_count(data: &CoordinationData) -> u64 { + [ + data.sessions.len(), + data.agents.len(), + data.tasks.len(), + data.swarm_trees.len(), + data.swarm_nodes.len(), + ] + .into_iter() + .fold(0u64, |total, count| total.saturating_add(count as u64)) +} + +fn manifest_target_wins_session_ids(manifest: &WorkspaceSessionsManifest) -> BTreeSet { + manifest + .sessions + .iter() + .filter(|entry| entry.action == SessionImportAction::TargetWins) + .map(|entry| entry.session_id.clone()) + .collect() +} + +fn validate_session(session_id: &str) -> LegacyMigrationResult<()> { + validate_session_id(session_id).map_err(|error| { + LegacyMigrationError::UnsupportedSource(format!( + "Agent coordination contains an invalid Session id: {error}" + )) + }) +} + +fn reset_stage_file(path: &Path) -> LegacyMigrationResult<()> { + remove_file_if_present(path)?; + remove_sqlite_sidecars(path) +} + +fn remove_sqlite_sidecars(path: &Path) -> LegacyMigrationResult<()> { + for suffix in ["-wal", "-shm"] { + let sidecar = PathBuf::from(format!("{}{suffix}", path.display())); + remove_file_if_present(&sidecar)?; + } + Ok(()) +} + +fn remove_file_if_present(path: &Path) -> LegacyMigrationResult<()> { + match fs::remove_file(path) { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(io_error(path, error)), + } +} + +fn unsupported(detail: impl Into) -> LegacyMigrationResult { + Err(LegacyMigrationError::UnsupportedSource(detail.into())) +} + +fn owner_error(context: &str, error: impl std::fmt::Display) -> LegacyMigrationError { + LegacyMigrationError::InvalidRequest(format!("{context}: {error}")) +} + +fn db_error(path: &Path, error: rusqlite::Error) -> LegacyMigrationError { + LegacyMigrationError::InvalidRequest(format!( + "Agent coordination database error at {}: {error}", + path.display() + )) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::legacy_migration::adapters_for_groups; + use crate::service::session_projection_format::validate_runtime_event_log; + use crate::service::workspace::persistence::{ + validate_workspace_persistence_data, WorkspacePersistenceData, + }; + use openbitfun_legacy_migration::{ + probe_legacy_source, CancellationToken, CrashInjector, CrashPoint, MigrationEngine, + NoCrashInjection, ProbeLimits, + }; + use openbitfun_product_domains::legacy_migration::{ + MigrationGroupId, MigrationRunStatus, MigrationSelection, + }; + use openbitfun_services_core::session::OfflineSessionImportStore; + use std::io::Read; + use std::sync::atomic::{AtomicBool, Ordering}; + + struct CrashOnce { + point: CrashPoint, + fired: AtomicBool, + } + + impl CrashInjector for CrashOnce { + fn should_crash(&self, point: CrashPoint) -> bool { + point == self.point && !self.fired.swap(true, Ordering::AcqRel) + } + } + + #[test] + fn session_group_migrates_owner_data_wal_and_relationship_closure() { + let temp = test_tempdir("session-group"); + let roots = fixture_roots(temp.path()); + copy_fixture(&roots); + let wal_connection = materialize_coordination(&roots, true); + let source_hash = hash_source_roots(&roots); + let selection = session_selection(); + assert_eq!( + selection.expanded_domains(), + [ + MigrationDomainId::WorkspaceSessions, + MigrationDomainId::AgentCoordination, + MigrationDomainId::CrossReferenceRepair, + ] + ); + + let source = probe_legacy_source(&roots, ProbeLimits::default()) + .unwrap() + .unwrap(); + let engine = MigrationEngine::new(roots.clone(), adapters_for_groups(&selection)).unwrap(); + let plan = engine + .plan(&source, selection.clone(), &CancellationToken::default()) + .unwrap(); + let report = engine + .execute(&plan, &CancellationToken::default(), &NoCrashInjection) + .unwrap(); + assert_eq!(report.status, MigrationRunStatus::Completed); + assert!(report + .domain_results + .iter() + .all(|result| result.state == MigrationDomainState::Verified)); + + let workspace_path = roots.target_user_root.join("data/workspace_data.json"); + let workspace: WorkspacePersistenceData = + serde_json::from_slice(&fs::read(workspace_path).unwrap()).unwrap(); + validate_workspace_persistence_data( + &workspace, + &roots.target_user_root.join("data/miniapps"), + ) + .unwrap(); + assert_eq!(workspace.workspaces.len(), 1); + assert_eq!( + workspace.current_workspace_id, + workspace.opened_workspace_ids.first().cloned() + ); + + let sessions_root = roots + .target_home_root + .join("projects/c--fixture-workspace/sessions"); + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_time() + .build() + .unwrap(); + let store = OfflineSessionImportStore::new(&sessions_root); + let parent = runtime + .block_on(store.load_bundle("session-1")) + .unwrap() + .unwrap(); + let child = runtime + .block_on(store.load_bundle("session-child-1")) + .unwrap() + .unwrap(); + assert_eq!(parent.turns[0].turn_id, "turn-1"); + assert_eq!( + child + .metadata + .relationship + .as_ref() + .and_then(|relationship| relationship.parent_session_id.as_deref()), + Some("session-1") + ); + assert!(!sessions_root + .join("session-1/request-traces/trace.json") + .exists()); + assert!(!roots + .target_user_root + .join("data/agent-runtime/ownership") + .exists()); + assert!(!roots + .target_user_root + .join("data/agent-runtime/ipc-v17") + .exists()); + + let event_path = roots + .target_home_root + .join("runtime-events/session-1.jsonl"); + let event_summary = validate_runtime_event_log(&event_path, "session-1").unwrap(); + assert!(event_summary.turn_ids.contains("turn-runtime-1")); + + let target_coordination = target_coordination_path(&roots); + let coordination = + load_coordination_data(&target_coordination, DatabaseRole::CurrentTarget).unwrap(); + assert_eq!(coordination.sessions.len(), 2); + assert!(coordination + .sessions + .iter() + .any(|row| row.parent_session_id == "session-child-1")); + assert_eq!(coordination.agents.len(), 1); + assert_eq!(coordination.tasks.len(), 1); + assert_eq!(coordination.swarm_nodes.len(), 2); + assert!(!PathBuf::from(format!("{}-wal", target_coordination.display())).exists()); + assert!(!PathBuf::from(format!("{}-shm", target_coordination.display())).exists()); + let report_json = serde_json::to_string(&report).unwrap(); + assert!(!report_json.contains("Synthetic parent request")); + assert!(!report_json.contains("Synthetic delegated request")); + assert_eq!(hash_source_roots(&roots), source_hash); + + let second_source = probe_legacy_source(&roots, ProbeLimits::default()) + .unwrap() + .unwrap(); + let second_plan = engine + .plan(&second_source, selection, &CancellationToken::default()) + .unwrap(); + engine + .execute( + &second_plan, + &CancellationToken::default(), + &NoCrashInjection, + ) + .unwrap(); + let repeated = + load_coordination_data(&target_coordination, DatabaseRole::CurrentTarget).unwrap(); + assert_eq!( + coordination_entity_count(&repeated), + coordination_entity_count(&coordination) + ); + assert_eq!(hash_source_roots(&roots), source_hash); + drop(wal_connection); + } + + #[test] + fn orphaned_session_relationship_is_preserved_without_blocking_import() { + let temp = test_tempdir("orphaned-session-relationship"); + let roots = fixture_roots(temp.path()); + copy_fixture(&roots); + let metadata_path = roots + .legacy_home_root + .join("projects/c--fixture-workspace/sessions/session-1/metadata.json"); + let mut metadata: serde_json::Value = + serde_json::from_slice(&fs::read(&metadata_path).unwrap()).unwrap(); + metadata["relationship"] = serde_json::json!({ + "kind": "subagent", + "parentSessionId": "missing-parent-session", + "parentDialogTurnId": "missing-parent-turn" + }); + atomic_write_json(&metadata_path, &metadata).unwrap(); + drop(materialize_coordination(&roots, false)); + let source_hash = hash_source_roots(&roots); + let selection = session_selection(); + let source = probe_legacy_source(&roots, ProbeLimits::default()) + .unwrap() + .unwrap(); + let engine = MigrationEngine::new(roots.clone(), adapters_for_groups(&selection)).unwrap(); + let plan = engine + .plan(&source, selection, &CancellationToken::default()) + .unwrap(); + + let report = engine + .execute(&plan, &CancellationToken::default(), &NoCrashInjection) + .unwrap(); + + assert_eq!(report.status, MigrationRunStatus::Completed); + let sessions_result = report + .domain_results + .iter() + .find(|result| result.domain == MigrationDomainId::WorkspaceSessions) + .unwrap(); + assert_eq!(sessions_result.state, MigrationDomainState::Verified); + assert!(sessions_result + .warnings + .iter() + .any(|warning| warning.code == "session_parent_not_present" + && warning.severity == FindingSeverity::Info)); + + let sessions_root = roots + .target_home_root + .join("projects/c--fixture-workspace/sessions"); + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_time() + .build() + .unwrap(); + let imported = runtime + .block_on(OfflineSessionImportStore::new(sessions_root).load_bundle("session-1")) + .unwrap() + .unwrap(); + assert_eq!( + imported + .metadata + .relationship + .as_ref() + .and_then(|relationship| relationship.parent_session_id.as_deref()), + Some("missing-parent-session") + ); + assert_eq!(hash_source_roots(&roots), source_hash); + } + + #[test] + fn missing_or_unknown_coordination_schema_blocks_the_session_plan() { + let temp = test_tempdir("coordination-schema"); + let roots = fixture_roots(temp.path()); + copy_fixture(&roots); + let selection = session_selection(); + let engine = MigrationEngine::new(roots.clone(), adapters_for_groups(&selection)).unwrap(); + let source = probe_legacy_source(&roots, ProbeLimits::default()) + .unwrap() + .unwrap(); + let missing = engine.plan(&source, selection.clone(), &CancellationToken::default()); + assert!(missing + .unwrap_err() + .to_string() + .contains("requires a readable legacy coordination.sqlite")); + + let connection = materialize_coordination(&roots, false); + connection.pragma_update(None, "user_version", 99).unwrap(); + drop(connection); + let source = probe_legacy_source(&roots, ProbeLimits::default()) + .unwrap() + .unwrap(); + let unknown = engine.plan(&source, selection, &CancellationToken::default()); + assert!(unknown + .unwrap_err() + .to_string() + .contains("schema 99 is not supported")); + } + + #[test] + fn target_session_and_coordination_records_win_without_cross_attaching_source_tasks() { + let temp = test_tempdir("coordination-target-wins"); + let roots = fixture_roots(temp.path()); + copy_fixture(&roots); + let source_connection = materialize_coordination(&roots, false); + drop(source_connection); + + let source_parent = roots + .legacy_home_root + .join("projects/c--fixture-workspace/sessions/session-1"); + let target_parent = roots + .target_home_root + .join("projects/c--fixture-workspace/sessions/session-1"); + copy_directory(&source_parent, &target_parent).unwrap(); + let metadata_path = target_parent.join("metadata.json"); + let mut metadata: serde_json::Value = + serde_json::from_slice(&fs::read(&metadata_path).unwrap()).unwrap(); + metadata["sessionName"] = serde_json::Value::String("Current target Session".to_string()); + atomic_write_json(&metadata_path, &metadata).unwrap(); + + let target_coordination = target_coordination_path(&roots); + fs::create_dir_all(target_coordination.parent().unwrap()).unwrap(); + let target_connection = Connection::open(&target_coordination).unwrap(); + initialize_coordination_schema(&target_connection).unwrap(); + target_connection.execute( + "INSERT INTO coordination_sessions (parent_session_id, next_auto_agent_seq, updated_at_ms) VALUES ('session-1', 99, 99)", + [], + ).unwrap(); + drop(target_connection); + + let selection = session_selection(); + let source = probe_legacy_source(&roots, ProbeLimits::default()) + .unwrap() + .unwrap(); + let engine = MigrationEngine::new(roots.clone(), adapters_for_groups(&selection)).unwrap(); + let plan = engine + .plan(&source, selection, &CancellationToken::default()) + .unwrap(); + let coordination_conflicts = plan + .conflicts + .iter() + .filter(|entry| entry.domain == MigrationDomainId::AgentCoordination) + .collect::>(); + assert_eq!(coordination_conflicts.len(), 6); + assert!(coordination_conflicts + .iter() + .any(|entry| entry.code == "coordination_session_data_target_wins")); + assert!(coordination_conflicts + .iter() + .any(|entry| entry.code == "coordination_agent_session_target_wins")); + assert!(coordination_conflicts + .iter() + .any(|entry| entry.code == "coordination_task_session_target_wins")); + assert!(coordination_conflicts + .iter() + .any(|entry| entry.code == "coordination_swarm_tree_session_target_wins")); + assert_eq!( + coordination_conflicts + .iter() + .filter(|entry| entry.code == "coordination_swarm_node_session_target_wins") + .count(), + 2 + ); + let report = engine + .execute(&plan, &CancellationToken::default(), &NoCrashInjection) + .unwrap(); + assert_eq!( + report + .domain_results + .iter() + .find(|result| result.domain == MigrationDomainId::AgentCoordination) + .unwrap() + .conflicts, + coordination_conflicts.len() as u64 + ); + + let target = + load_coordination_data(&target_coordination, DatabaseRole::CurrentTarget).unwrap(); + assert_eq!(target.sessions.len(), 1); + assert_eq!(target.sessions[0].next_auto_agent_seq, 99); + assert!(target.agents.is_empty()); + assert!(target.tasks.is_empty()); + assert!(target.swarm_trees.is_empty()); + assert!(target.swarm_nodes.is_empty()); + let stored: serde_json::Value = + serde_json::from_slice(&fs::read(metadata_path).unwrap()).unwrap(); + assert_eq!(stored["sessionName"], "Current target Session"); + } + + #[test] + fn preview_reports_task_and_swarm_conflicts_before_stage() { + let temp = test_tempdir("coordination-conflict-preview"); + let roots = fixture_roots(temp.path()); + copy_fixture(&roots); + let connection = materialize_coordination(&roots, false); + drop(connection); + let source = load_coordination_data( + &source_coordination_path(&roots), + DatabaseRole::LegacySource, + ) + .unwrap(); + let mut target = source.clone(); + target.tasks[0].status = "failed".to_string(); + target.swarm_trees[0].created_at_ms += 1; + target.swarm_nodes[1].created_at_ms += 1; + + let conflicts = preview_conflicts(&source, &target, &BTreeSet::new()); + assert_eq!(conflicts.len(), 3); + assert!(conflicts + .iter() + .any(|entry| entry.code == "coordination_task_target_wins")); + assert!(conflicts + .iter() + .any(|entry| entry.code == "coordination_swarm_tree_target_wins")); + assert!(conflicts + .iter() + .any(|entry| entry.code == "coordination_swarm_node_target_wins")); + } + + #[test] + fn committed_session_domains_resume_idempotently_after_crash() { + for (label, crash_point) in [ + ( + "workspace-commit-recovery", + CrashPoint::AfterCommit(MigrationDomainId::WorkspaceSessions), + ), + ( + "coordination-commit-recovery", + CrashPoint::AfterCommit(MigrationDomainId::AgentCoordination), + ), + ] { + let temp = test_tempdir(label); + let roots = fixture_roots(temp.path()); + copy_fixture(&roots); + let connection = materialize_coordination(&roots, false); + drop(connection); + let source_hash = hash_source_roots(&roots); + let selection = session_selection(); + let source = probe_legacy_source(&roots, ProbeLimits::default()) + .unwrap() + .unwrap(); + let engine = + MigrationEngine::new(roots.clone(), adapters_for_groups(&selection)).unwrap(); + let plan = engine + .plan(&source, selection, &CancellationToken::default()) + .unwrap(); + let crash = CrashOnce { + point: crash_point, + fired: AtomicBool::new(false), + }; + + let error = engine + .execute(&plan, &CancellationToken::default(), &crash) + .unwrap_err(); + assert!(matches!( + error, + LegacyMigrationError::InjectedCrash(actual) if actual == crash_point + )); + let report = engine + .execute(&plan, &CancellationToken::default(), &NoCrashInjection) + .unwrap(); + assert!(matches!( + report.status, + MigrationRunStatus::Completed | MigrationRunStatus::CompletedWithWarnings + )); + assert!(report + .domain_results + .iter() + .all(|result| result.state == MigrationDomainState::Verified)); + assert_eq!(hash_source_roots(&roots), source_hash); + } + } + + #[test] + fn failed_and_cancelled_runs_leave_the_legacy_source_bytes_unchanged() { + let temp = test_tempdir("coordination-source-read-only"); + let roots = fixture_roots(temp.path()); + copy_fixture(&roots); + let connection = materialize_coordination(&roots, false); + connection + .execute( + "UPDATE background_tasks SET parent_dialog_turn_id = 'missing-turn'", + [], + ) + .unwrap(); + drop(connection); + let source_hash = hash_source_roots(&roots); + let selection = session_selection(); + let engine = MigrationEngine::new(roots.clone(), adapters_for_groups(&selection)).unwrap(); + let source = probe_legacy_source(&roots, ProbeLimits::default()) + .unwrap() + .unwrap(); + let plan = engine + .plan(&source, selection.clone(), &CancellationToken::default()) + .unwrap(); + let failed = engine.execute(&plan, &CancellationToken::default(), &NoCrashInjection); + assert!(failed + .unwrap_err() + .to_string() + .contains("references missing Turn missing-turn")); + assert_eq!(hash_source_roots(&roots), source_hash); + + let cancellation = CancellationToken::default(); + cancellation.cancel(); + let cancelled = engine.plan(&source, selection, &cancellation); + assert!(matches!(cancelled, Err(LegacyMigrationError::Cancelled))); + assert_eq!(hash_source_roots(&roots), source_hash); + } + + fn session_selection() -> MigrationSelection { + MigrationSelection { + groups: BTreeSet::from([MigrationGroupId::WorkspacesSessionsAndTasks]), + } + } + + fn fixture_roots(root: &Path) -> MigrationRoots { + let legacy_user_root = root.join("legacy-user"); + MigrationRoots { + legacy_skills_root: legacy_user_root.join("skills"), + legacy_user_root, + legacy_home_root: root.join("legacy-home"), + 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-skills"), + target_ssh_root: root.join("target-ssh"), + } + } + + fn copy_fixture(roots: &MigrationRoots) { + let fixture = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../services/legacy-migration/tests/fixtures/v0.2.19"); + copy_directory(&fixture.join("user-root"), &roots.legacy_user_root).unwrap(); + copy_directory(&fixture.join("home"), &roots.legacy_home_root).unwrap(); + copy_directory(&fixture.join("ssh"), &roots.legacy_ssh_root).unwrap(); + // The archived fixture uses a Windows path; identity validation needs + // an absolute path in the platform running this test. + let workspace_path = roots.legacy_user_root.join("data/workspace_data.json"); + let mut workspace_data: serde_json::Value = + serde_json::from_slice(&fs::read(&workspace_path).unwrap()).unwrap(); + workspace_data["workspaces"]["workspace-1"]["rootPath"] = + serde_json::json!(roots.legacy_home_root.join("fixture-workspace")); + atomic_write_json(&workspace_path, &workspace_data).unwrap(); + } + + fn materialize_coordination(roots: &MigrationRoots, with_wal_row: bool) -> Connection { + let sql_path = roots + .legacy_user_root + .join("data/agent-runtime/coordination.sql"); + let database_path = source_coordination_path(roots); + let connection = Connection::open(&database_path).unwrap(); + connection + .execute_batch(&fs::read_to_string(sql_path).unwrap()) + .unwrap(); + if with_wal_row { + connection + .pragma_update(None, "journal_mode", "WAL") + .unwrap(); + connection + .execute( + "INSERT INTO coordination_sessions (parent_session_id, next_auto_agent_seq, updated_at_ms) VALUES ('session-child-1', 1, 2)", + [], + ) + .unwrap(); + assert!(PathBuf::from(format!("{}-wal", database_path.display())).exists()); + } + connection + } + + fn copy_directory(source: &Path, target: &Path) -> std::io::Result<()> { + fs::create_dir_all(target)?; + for entry in fs::read_dir(source)? { + let entry = entry?; + let source_path = entry.path(); + let target_path = target.join(entry.file_name()); + if entry.file_type()?.is_dir() { + copy_directory(&source_path, &target_path)?; + } else { + fs::copy(source_path, target_path)?; + } + } + Ok(()) + } + + fn hash_source_roots(roots: &MigrationRoots) -> String { + let mut entries = Vec::new(); + for root in [ + &roots.legacy_user_root, + &roots.legacy_home_root, + &roots.legacy_ssh_root, + ] { + collect_source_files(root, root, &mut entries); + } + entries.sort_by(|left, right| left.0.cmp(&right.0)); + let mut hasher = Sha256::new(); + for (path, bytes) in entries { + hasher.update(path.to_string_lossy().replace('\\', "/").as_bytes()); + hasher.update([0]); + hasher.update(bytes); + hasher.update([0]); + } + hex::encode(hasher.finalize()) + } + + fn collect_source_files(root: &Path, path: &Path, entries: &mut Vec<(PathBuf, Vec)>) { + if path.is_file() { + if path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.ends_with("-shm")) + { + return; + } + let mut bytes = Vec::new(); + fs::File::open(path) + .unwrap() + .read_to_end(&mut bytes) + .unwrap(); + entries.push((path.strip_prefix(root).unwrap().to_path_buf(), bytes)); + return; + } + let mut children = fs::read_dir(path) + .unwrap() + .map(|entry| entry.unwrap().path()) + .collect::>(); + children.sort(); + for child in children { + collect_source_files(root, &child, entries); + } + } + + fn test_tempdir(label: &str) -> tempfile::TempDir { + let root = std::env::var_os("OPENBITFUN_TEST_TMPDIR") + .map(PathBuf::from) + .unwrap_or_else(std::env::temp_dir); + fs::create_dir_all(&root).unwrap(); + tempfile::Builder::new() + .prefix(&format!("openbitfun-migration-{label}-")) + .tempdir_in(root) + .unwrap() + } +} diff --git a/src/crates/assembly/core/src/legacy_migration/common.rs b/src/crates/assembly/core/src/legacy_migration/common.rs new file mode 100644 index 0000000000..e0357e47eb --- /dev/null +++ b/src/crates/assembly/core/src/legacy_migration/common.rs @@ -0,0 +1,124 @@ +use openbitfun_legacy_migration::{ + atomic_write_bytes, LegacyMigrationError, LegacyMigrationResult, +}; +use serde::de::DeserializeOwned; +use std::fs; +use std::path::{Path, PathBuf}; + +pub(crate) const MAX_JSON_BYTES: u64 = 16 * 1024 * 1024; + +pub(crate) fn read_bounded_json( + root: &Path, + path: &Path, +) -> LegacyMigrationResult { + validate_regular_file(root, path)?; + let metadata = fs::metadata(path).map_err(|error| io_error(path, error))?; + if metadata.len() > MAX_JSON_BYTES { + return Err(LegacyMigrationError::ResourceLimit(format!( + "JSON file exceeds {} bytes: {}", + MAX_JSON_BYTES, + relative_display(root, path) + ))); + } + let bytes = fs::read(path).map_err(|error| io_error(path, error))?; + serde_json::from_slice(&bytes).map_err(|error| { + LegacyMigrationError::InvalidRequest(format!( + "invalid JSON at {}: {error}", + relative_display(root, path) + )) + }) +} + +pub(crate) fn read_optional_bounded_json( + root: &Path, + path: &Path, +) -> LegacyMigrationResult> { + match fs::symlink_metadata(path) { + Ok(_) => read_bounded_json(root, path).map(Some), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(io_error(path, error)), + } +} + +pub(crate) fn validate_regular_file(root: &Path, path: &Path) -> LegacyMigrationResult<()> { + let metadata = fs::symlink_metadata(path).map_err(|error| io_error(path, error))?; + if metadata.file_type().is_symlink() || is_reparse_point(&metadata) { + return Err(LegacyMigrationError::LinkedPath(path.to_path_buf())); + } + if !metadata.is_file() { + return Err(LegacyMigrationError::InvalidRequest(format!( + "expected a regular file at {}", + relative_display(root, path) + ))); + } + let canonical_root = fs::canonicalize(root).map_err(|error| io_error(root, error))?; + let canonical_path = fs::canonicalize(path).map_err(|error| io_error(path, error))?; + if !canonical_path.starts_with(&canonical_root) { + return Err(LegacyMigrationError::PathEscape(path.to_path_buf())); + } + Ok(()) +} + +#[cfg(windows)] +fn is_reparse_point(metadata: &fs::Metadata) -> bool { + use std::os::windows::fs::MetadataExt; + const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0400; + metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 +} + +#[cfg(not(windows))] +fn is_reparse_point(_metadata: &fs::Metadata) -> bool { + false +} + +pub(crate) fn backup_file_once(target: &Path, backup: &Path) -> LegacyMigrationResult<()> { + if backup.exists() || !target.exists() { + return Ok(()); + } + let bytes = fs::read(target).map_err(|error| io_error(target, error))?; + atomic_write_bytes(backup, &bytes) +} + +pub(crate) fn restore_unverified_file( + target: &Path, + backup: &Path, + target_existed: bool, +) -> LegacyMigrationResult<()> { + if backup.exists() { + let bytes = fs::read(backup).map_err(|error| io_error(backup, error))?; + return atomic_write_bytes(target, &bytes); + } + if !target_existed { + match fs::remove_file(target) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(io_error(target, error)), + } + } + Ok(()) +} + +pub(crate) fn stage_domain_dir( + context: &openbitfun_legacy_migration::DomainContext<'_>, + name: &str, +) -> PathBuf { + context.layout.stage_root().join(name) +} + +pub(crate) fn backup_domain_dir( + context: &openbitfun_legacy_migration::DomainContext<'_>, + name: &str, +) -> PathBuf { + context.layout.backup_root().join(name) +} + +pub(crate) fn relative_display(root: &Path, path: &Path) -> String { + path.strip_prefix(root) + .unwrap_or(path) + .to_string_lossy() + .replace('\\', "/") +} + +pub(crate) fn io_error(path: &Path, error: std::io::Error) -> LegacyMigrationError { + LegacyMigrationError::InvalidRequest(format!("I/O failed at {}: {error}", path.display())) +} diff --git a/src/crates/assembly/core/src/legacy_migration/extensions.rs b/src/crates/assembly/core/src/legacy_migration/extensions.rs new file mode 100644 index 0000000000..05a5e27f8e --- /dev/null +++ b/src/crates/assembly/core/src/legacy_migration/extensions.rs @@ -0,0 +1,1824 @@ +use super::common::{read_bounded_json, stage_domain_dir}; +use openbitfun_agent_runtime::custom_agent::{custom_agent_read_markdown_str, CustomAgentLevel}; +use openbitfun_agent_runtime::skills::{SkillData, SkillLocation, OPENBITFUN_SYSTEM_SKILL_DIR}; +use openbitfun_legacy_migration::{ + atomic_write_bytes, atomic_write_json, DomainContext, DomainScan, LegacyDomainAdapter, + LegacyMigrationError, LegacyMigrationResult, MigrationRoots, +}; +use openbitfun_product_domains::legacy_migration::{ + ConflictResolution, FindingSeverity, MigrationConflict, MigrationDiagnostic, MigrationDomainId, + MigrationDomainResult, MigrationDomainState, ScanFinding, +}; +use openbitfun_product_domains::miniapp::builtin::{BUILTIN_APPS, BUILTIN_INSTALL_MARKER}; +use openbitfun_product_domains::miniapp::storage::{ + build_import_bundle_plan, COMPILED_HTML, ESM_DEPS_JSON, META_JSON, PACKAGE_JSON, + REQUIRED_SOURCE_FILES, SOURCE_DIR, STORAGE_JSON, +}; +use openbitfun_product_domains::miniapp::types::MiniAppMeta; +use openbitfun_services_integrations::miniapp::storage::MiniAppStorage; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::io::{BufReader, Read}; +use std::path::{Path, PathBuf}; + +const MAX_FILES_PER_EXTENSION: usize = 32_768; +const MAX_EXTENSION_BYTES: u64 = 4 * 1024 * 1024 * 1024; + +pub(crate) struct SkillsAdapter; +pub(crate) struct MiniappsAdapter; +pub(crate) struct AgentsAdapter; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +enum ImportAction { + Import, + Remap, + Duplicate, + BuiltinStorage, + TargetWins, + Skip, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ImportEntry { + source_id: String, + target_id: String, + action: ImportAction, + content_hash: String, +} + +#[derive(Debug, Default, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +struct ImportManifest { + entries: Vec, + skipped_paths: Vec, +} + +#[derive(Debug)] +struct PlannedTree { + source_id: String, + target_id: String, + action: ImportAction, + content_hash: String, + source_path: PathBuf, + files: Vec, + skipped_paths: Vec, +} + +#[derive(Debug)] +struct PlannedAgent { + source_id: String, + target_id: String, + action: ImportAction, + content_hash: String, + source_path: PathBuf, + staged_content: Vec, +} + +enum PlannedMiniAppFile { + Source(PathBuf), + Generated(Vec), +} + +impl LegacyDomainAdapter for SkillsAdapter { + fn domain(&self) -> MigrationDomainId { + MigrationDomainId::Skills + } + + fn scan(&self, roots: &MigrationRoots) -> LegacyMigrationResult { + let planned = plan_skills(roots)?; + Ok(scan_from_trees( + self.domain(), + "legacy_skills_supported", + "agent-skill.v1", + "openbitfun.agent-skill.current", + &planned, + )) + } + + fn stage(&self, context: &DomainContext<'_>) -> LegacyMigrationResult { + let planned = plan_skills(context.roots)?; + let output = stage_domain_dir(context, "skills").join("output"); + let mut manifest = ImportManifest::default(); + for item in &planned { + if matches!(item.action, ImportAction::Import | ImportAction::Remap) { + copy_declared_tree( + &item.source_path, + &output.join(&item.target_id), + &item.files, + )?; + } + manifest.entries.push(import_entry(item)); + manifest.skipped_paths.extend(item.skipped_paths.clone()); + } + atomic_write_json(&skills_manifest_path(context), &manifest)?; + result_from_manifest(self.domain(), &manifest) + } + + fn validate_stage(&self, context: &DomainContext<'_>) -> LegacyMigrationResult<()> { + let manifest = read_manifest(context, "skills")?; + for entry in imported_entries(&manifest) { + let root = stage_domain_dir(context, "skills") + .join("output") + .join(&entry.target_id); + let skill_path = root.join("SKILL.md"); + let content = + fs::read_to_string(&skill_path).map_err(|error| io(&skill_path, error))?; + SkillData::from_markdown( + skill_path.to_string_lossy().to_string(), + &content, + SkillLocation::User, + false, + ) + .map_err(|error| { + LegacyMigrationError::InvalidRequest(format!( + "staged Skill {} failed current owner parsing: {error}", + entry.target_id + )) + })?; + require_hash(&root, &entry.content_hash)?; + } + Ok(()) + } + + fn commit(&self, context: &DomainContext<'_>) -> LegacyMigrationResult<()> { + let manifest = read_manifest(context, "skills")?; + let target_root = &context.roots.target_skills_root; + for entry in imported_entries(&manifest) { + let staged = stage_domain_dir(context, "skills") + .join("output") + .join(&entry.target_id); + install_directory_idempotent( + &staged, + &target_root.join(&entry.target_id), + &entry.content_hash, + &context.plan.run_id, + )?; + } + Ok(()) + } + + fn validate_commit(&self, context: &DomainContext<'_>) -> LegacyMigrationResult<()> { + let manifest = read_manifest(context, "skills")?; + for entry in imported_entries(&manifest) { + let root = context.roots.target_skills_root.join(&entry.target_id); + require_hash(&root, &entry.content_hash)?; + let path = root.join("SKILL.md"); + let content = fs::read_to_string(&path).map_err(|error| io(&path, error))?; + SkillData::from_markdown( + path.to_string_lossy().to_string(), + &content, + SkillLocation::User, + false, + ) + .map_err(|error| { + LegacyMigrationError::InvalidRequest(format!( + "committed Skill {} failed current owner parsing: {error}", + entry.target_id + )) + })?; + } + Ok(()) + } + + fn rollback_unverified(&self, context: &DomainContext<'_>) -> LegacyMigrationResult<()> { + rollback_imported_directories(context, "skills", &context.roots.target_skills_root) + } +} + +impl LegacyDomainAdapter for MiniappsAdapter { + fn domain(&self) -> MigrationDomainId { + MigrationDomainId::Miniapps + } + + fn scan(&self, roots: &MigrationRoots) -> LegacyMigrationResult { + let planned = plan_miniapps(roots)?; + Ok(scan_from_trees( + self.domain(), + "legacy_miniapps_supported", + "miniapp.flat.v1", + "openbitfun.miniapp.current", + &planned, + )) + } + + fn stage(&self, context: &DomainContext<'_>) -> LegacyMigrationResult { + let planned = plan_miniapps(context.roots)?; + let domain_root = stage_domain_dir(context, "miniapps"); + let output = domain_root.join("output"); + let mut manifest = ImportManifest::default(); + for item in &planned { + match item.action { + ImportAction::Import | ImportAction::Remap => { + let staged = output.join(&item.target_id); + write_miniapp_output(&item.source_path, &item.target_id, &staged)?; + require_hash(&staged, &item.content_hash)?; + } + ImportAction::BuiltinStorage => { + let source = item.source_path.join(STORAGE_JSON); + let value: serde_json::Value = read_bounded_json(&item.source_path, &source)?; + atomic_write_json( + &domain_root + .join("builtin-storage") + .join(&item.target_id) + .join(STORAGE_JSON), + &value, + )?; + } + ImportAction::Duplicate | ImportAction::TargetWins | ImportAction::Skip => {} + } + manifest.entries.push(import_entry(item)); + manifest.skipped_paths.extend(item.skipped_paths.clone()); + } + atomic_write_json(&miniapps_manifest_path(context), &manifest)?; + result_from_manifest(self.domain(), &manifest) + } + + fn validate_stage(&self, context: &DomainContext<'_>) -> LegacyMigrationResult<()> { + let manifest = read_manifest(context, "miniapps")?; + let storage = MiniAppStorage::new(PathBuf::new()); + for entry in imported_entries(&manifest) { + let root = stage_domain_dir(context, "miniapps") + .join("output") + .join(&entry.target_id); + let meta = storage + .read_import_meta_json_offline(&root) + .map_err(|error| LegacyMigrationError::InvalidRequest(error.to_string()))?; + let parsed: MiniAppMeta = serde_json::from_str(&meta).map_err(|error| { + LegacyMigrationError::InvalidRequest(format!( + "staged MiniApp {} failed current owner parsing: {error}", + entry.target_id + )) + })?; + if parsed.id != entry.target_id { + return Err(LegacyMigrationError::InvalidRequest(format!( + "staged MiniApp id mismatch: expected {}, found {}", + entry.target_id, parsed.id + ))); + } + require_hash(&root, &entry.content_hash)?; + } + for entry in manifest + .entries + .iter() + .filter(|entry| entry.action == ImportAction::BuiltinStorage) + { + let path = stage_domain_dir(context, "miniapps") + .join("builtin-storage") + .join(&entry.target_id) + .join(STORAGE_JSON); + let _: serde_json::Value = read_bounded_json(&context.layout.stage_root(), &path)?; + } + Ok(()) + } + + fn commit(&self, context: &DomainContext<'_>) -> LegacyMigrationResult<()> { + let manifest = read_manifest(context, "miniapps")?; + let target_root = target_miniapps_root(context.roots); + for entry in imported_entries(&manifest) { + let staged = stage_domain_dir(context, "miniapps") + .join("output") + .join(&entry.target_id); + install_directory_idempotent( + &staged, + &target_root.join(&entry.target_id), + &entry.content_hash, + &context.plan.run_id, + )?; + } + for entry in manifest + .entries + .iter() + .filter(|entry| entry.action == ImportAction::BuiltinStorage) + { + let staged = stage_domain_dir(context, "miniapps") + .join("builtin-storage") + .join(&entry.target_id) + .join(STORAGE_JSON); + let target = target_root.join(&entry.target_id).join(STORAGE_JSON); + if target.exists() { + if hash_file(&target)? != hash_file(&staged)? { + return Err(LegacyMigrationError::InvalidRequest(format!( + "target MiniApp storage changed after planning: {}", + target.display() + ))); + } + } else { + let bytes = fs::read(&staged).map_err(|error| io(&staged, error))?; + atomic_write_bytes(&target, &bytes)?; + } + } + Ok(()) + } + + fn validate_commit(&self, context: &DomainContext<'_>) -> LegacyMigrationResult<()> { + let manifest = read_manifest(context, "miniapps")?; + let target_root = target_miniapps_root(context.roots); + let storage = MiniAppStorage::new(PathBuf::new()); + for entry in imported_entries(&manifest) { + let root = target_root.join(&entry.target_id); + let meta = storage + .read_import_meta_json_offline(&root) + .map_err(|error| LegacyMigrationError::InvalidRequest(error.to_string()))?; + let parsed: MiniAppMeta = serde_json::from_str(&meta).map_err(|error| { + LegacyMigrationError::InvalidRequest(format!( + "committed MiniApp {} failed current owner parsing: {error}", + entry.target_id + )) + })?; + if parsed.id != entry.target_id { + return Err(LegacyMigrationError::InvalidRequest(format!( + "committed MiniApp id mismatch for {}", + entry.target_id + ))); + } + require_hash(&root, &entry.content_hash)?; + } + for entry in manifest + .entries + .iter() + .filter(|entry| entry.action == ImportAction::BuiltinStorage) + { + let path = target_root.join(&entry.target_id).join(STORAGE_JSON); + let _: serde_json::Value = read_bounded_json(&target_root, &path)?; + } + Ok(()) + } + + fn rollback_unverified(&self, context: &DomainContext<'_>) -> LegacyMigrationResult<()> { + rollback_imported_directories(context, "miniapps", &target_miniapps_root(context.roots))?; + rollback_builtin_storage(context) + } +} + +impl LegacyDomainAdapter for AgentsAdapter { + fn domain(&self) -> MigrationDomainId { + MigrationDomainId::Agents + } + + fn scan(&self, roots: &MigrationRoots) -> LegacyMigrationResult { + let planned = plan_agents(roots)?; + let conflicts = planned + .iter() + .filter_map(|item| { + conflict_for_action(self.domain(), &item.source_id, &item.target_id, item.action) + }) + .collect(); + Ok(DomainScan { + finding: ScanFinding { + domain: self.domain(), + code: "legacy_agents_supported".to_string(), + severity: FindingSeverity::Info, + entity_count: planned.len() as u64, + logical_bytes: planned + .iter() + .map(|item| fs::metadata(&item.source_path).map_or(0, |meta| meta.len())) + .sum(), + source_schema: Some("custom-agent.v1".to_string()), + migratable: true, + detail: + "Legacy user Agent definitions are parsed by the current owner before import." + .to_string(), + }, + conflicts, + target_schema: Some("openbitfun.custom-agent.current".to_string()), + dependencies: vec![MigrationDomainId::Skills, MigrationDomainId::Miniapps], + }) + } + + fn stage(&self, context: &DomainContext<'_>) -> LegacyMigrationResult { + let planned = plan_agents(context.roots)?; + let output = stage_domain_dir(context, "agents").join("output"); + let mut manifest = ImportManifest::default(); + for item in planned { + if matches!(item.action, ImportAction::Import | ImportAction::Remap) { + let path = output.join(format!("{}.md", safe_component(&item.target_id))); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|error| io(parent, error))?; + } + atomic_write_bytes(&path, &item.staged_content)?; + manifest.entries.push(ImportEntry { + source_id: item.source_id, + target_id: item.target_id, + action: item.action, + content_hash: item.content_hash, + }); + } else { + manifest.entries.push(ImportEntry { + source_id: item.source_id, + target_id: item.target_id, + action: item.action, + content_hash: item.content_hash, + }); + } + } + atomic_write_json(&agents_manifest_path(context), &manifest)?; + result_from_manifest(self.domain(), &manifest) + } + + fn validate_stage(&self, context: &DomainContext<'_>) -> LegacyMigrationResult<()> { + validate_agent_manifest(context, true) + } + + fn commit(&self, context: &DomainContext<'_>) -> LegacyMigrationResult<()> { + let manifest = read_manifest(context, "agents")?; + let target_root = context.roots.target_user_root.join("agents"); + for entry in imported_entries(&manifest) { + let name = format!("{}.md", safe_component(&entry.target_id)); + let staged = stage_domain_dir(context, "agents") + .join("output") + .join(&name); + let target = target_root.join(&name); + install_file_idempotent(&staged, &target, &entry.content_hash)?; + } + Ok(()) + } + + fn validate_commit(&self, context: &DomainContext<'_>) -> LegacyMigrationResult<()> { + validate_agent_manifest(context, false) + } + + fn rollback_unverified(&self, context: &DomainContext<'_>) -> LegacyMigrationResult<()> { + let manifest = read_manifest(context, "agents")?; + let target_root = context.roots.target_user_root.join("agents"); + for entry in imported_entries(&manifest) { + let target = target_root.join(format!("{}.md", safe_component(&entry.target_id))); + if target.exists() && hash_file(&target)? == entry.content_hash { + fs::remove_file(&target).map_err(|error| io(&target, error))?; + } + } + Ok(()) + } +} + +fn plan_skills(roots: &MigrationRoots) -> LegacyMigrationResult> { + let source_root = legacy_skills_root(roots); + let target_root = &roots.target_skills_root; + let mut planned = Vec::new(); + for source in direct_child_directories(&source_root)? { + let source_id = file_name(&source)?; + if source_id == OPENBITFUN_SYSTEM_SKILL_DIR { + continue; + } + let skill_file = source.join("SKILL.md"); + let content = fs::read_to_string(&skill_file).map_err(|error| io(&skill_file, error))?; + SkillData::from_markdown( + skill_file.to_string_lossy().to_string(), + &content, + SkillLocation::User, + false, + ) + .map_err(|error| { + LegacyMigrationError::InvalidRequest(format!( + "legacy Skill {source_id} failed current owner parsing: {error}" + )) + })?; + let mut files = Vec::new(); + collect_regular_files(&source, &source, &mut files)?; + enforce_tree_limits(&source, &files)?; + let content_hash = hash_file_set(&source, &files)?; + let target = target_root.join(&source_id); + let (target_id, action) = resolve_directory_conflict(&source_id, &content_hash, &target)?; + planned.push(PlannedTree { + source_id, + target_id, + action, + content_hash, + source_path: source, + files, + skipped_paths: Vec::new(), + }); + } + Ok(planned) +} + +fn plan_miniapps(roots: &MigrationRoots) -> LegacyMigrationResult> { + let source_root = roots.legacy_user_root.join("data").join("miniapps"); + let target_root = target_miniapps_root(roots); + let builtin_ids = BUILTIN_APPS + .iter() + .map(|app| app.id) + .collect::>(); + let mut planned = Vec::new(); + for source in direct_child_directories(&source_root)? { + let directory_id = file_name(&source)?; + let meta_path = source.join(META_JSON); + let raw_meta: serde_json::Value = read_bounded_json(&source, &meta_path)?; + let declared_id = raw_meta + .get("id") + .and_then(serde_json::Value::as_str) + .unwrap_or_default(); + let source_id = if declared_id.trim().is_empty() { + directory_id.clone() + } else { + declared_id.to_string() + }; + let current_builtin_id = if builtin_ids.contains(source_id.as_str()) { + Some(source_id.clone()) + } else if builtin_ids.contains(directory_id.as_str()) { + Some(directory_id.clone()) + } else { + None + }; + let is_builtin = + current_builtin_id.is_some() || source.join(BUILTIN_INSTALL_MARKER).exists(); + let mut files = Vec::new(); + collect_regular_files(&source, &source, &mut files)?; + enforce_tree_limits(&source, &files)?; + let source_hash = hash_file_set(&source, &files)?; + if is_builtin { + let target_id = current_builtin_id + .clone() + .unwrap_or_else(|| remapped_id(&source_id, &source_hash)); + let target_storage = target_root.join(&target_id).join(STORAGE_JSON); + planned.push(PlannedTree { + source_id, + target_id, + action: if current_builtin_id.is_none() || !source.join(STORAGE_JSON).exists() { + ImportAction::Skip + } else if target_storage.exists() { + ImportAction::TargetWins + } else { + ImportAction::BuiltinStorage + }, + content_hash: source_hash, + source_path: source, + files, + skipped_paths: Vec::new(), + }); + continue; + } + build_import_bundle_plan( + &source_id, + &fs::read_to_string(&meta_path).map_err(|error| io(&meta_path, error))?, + 0, + ) + .map_err(|error| { + LegacyMigrationError::InvalidRequest(format!( + "legacy MiniApp {source_id} failed current owner conversion: {error}" + )) + })?; + let (target_id, action, content_hash) = if is_safe_component(&source_id) { + resolve_miniapp_conflict(&source, &source_id, &source_hash, &target_root)? + } else { + resolve_remapped_miniapp_conflict(&source, &source_id, &source_hash, &target_root)? + }; + planned.push(PlannedTree { + source_id, + target_id, + action, + content_hash, + source_path: source, + files, + skipped_paths: Vec::new(), + }); + } + Ok(planned) +} + +fn plan_agents(roots: &MigrationRoots) -> LegacyMigrationResult> { + let source_root = roots.legacy_user_root.join("agents"); + let target_root = roots.target_user_root.join("agents"); + let mut target_by_id = BTreeMap::new(); + for path in direct_markdown_files(&target_root)? { + let content = fs::read_to_string(&path).map_err(|error| io(&path, error))?; + if let Ok(parsed) = custom_agent_read_markdown_str(&content, CustomAgentLevel::User) { + target_by_id.insert( + parsed.definition.id.to_ascii_lowercase(), + content.into_bytes(), + ); + } + } + let mut planned = Vec::new(); + for source_path in direct_markdown_files(&source_root)? { + let content = fs::read_to_string(&source_path).map_err(|error| io(&source_path, error))?; + let parsed = + custom_agent_read_markdown_str(&content, CustomAgentLevel::User).map_err(|error| { + LegacyMigrationError::InvalidRequest(format!( + "legacy Agent {} failed current owner parsing: {error}", + source_path.display() + )) + })?; + let source_id = parsed.definition.id.clone(); + let source_bytes = content.into_bytes(); + let source_hash = hash_bytes(&source_bytes); + let (target_id, action, staged_content) = + match target_by_id.get(&source_id.to_ascii_lowercase()) { + Some(existing) if existing == &source_bytes => { + (source_id.clone(), ImportAction::Duplicate, source_bytes) + } + Some(_) => { + let target_id = remapped_id(&source_id, &source_hash); + let remapped = rewrite_agent_id(&source_bytes, &target_id)?; + (target_id, ImportAction::Remap, remapped) + } + None => (source_id.clone(), ImportAction::Import, source_bytes), + }; + let content_hash = hash_bytes(&staged_content); + planned.push(PlannedAgent { + source_id, + target_id, + action, + content_hash, + source_path, + staged_content, + }); + } + Ok(planned) +} + +fn write_miniapp_output( + source: &Path, + target_id: &str, + output: &Path, +) -> LegacyMigrationResult<()> { + for (relative, file) in miniapp_output_plan(source, target_id)? { + let target = output.join(relative); + match file { + PlannedMiniAppFile::Source(source) => copy_regular_file(&source, &target)?, + PlannedMiniAppFile::Generated(bytes) => atomic_write_bytes(&target, &bytes)?, + } + } + Ok(()) +} + +fn miniapp_output_plan( + source: &Path, + target_id: &str, +) -> LegacyMigrationResult> { + let raw_meta = fs::read_to_string(source.join(META_JSON)) + .map_err(|error| io(&source.join(META_JSON), error))?; + let owner_plan = build_import_bundle_plan(target_id, &raw_meta, 0).map_err(|error| { + LegacyMigrationError::InvalidRequest(format!( + "legacy MiniApp {target_id} failed current owner conversion: {error}" + )) + })?; + let mut files = BTreeMap::new(); + files.insert( + PathBuf::from(META_JSON), + PlannedMiniAppFile::Generated(merge_miniapp_meta(&raw_meta, &owner_plan.meta_json)?), + ); + files.insert( + PathBuf::from(COMPILED_HTML), + PlannedMiniAppFile::Generated(owner_plan.compiled_html.into_bytes()), + ); + + for name in REQUIRED_SOURCE_FILES { + let destination = PathBuf::from(SOURCE_DIR).join(name); + if let Some(path) = + first_regular_file(&[source.join(SOURCE_DIR).join(name), source.join(name)]) + { + files.insert(destination, PlannedMiniAppFile::Source(path)); + } else { + files.insert(destination, PlannedMiniAppFile::Generated(Vec::new())); + } + } + + let esm_destination = PathBuf::from(SOURCE_DIR).join(ESM_DEPS_JSON); + if let Some(path) = first_regular_file(&[ + source.join(SOURCE_DIR).join(ESM_DEPS_JSON), + source.join(ESM_DEPS_JSON), + ]) { + files.insert(esm_destination, PlannedMiniAppFile::Source(path)); + } else { + files.insert( + esm_destination, + PlannedMiniAppFile::Generated(owner_plan.esm_dependencies_json.into_bytes()), + ); + } + + for (name, fallback) in [ + (PACKAGE_JSON, owner_plan.package_json.into_bytes()), + (STORAGE_JSON, owner_plan.storage_json.into_bytes()), + ] { + let path = source.join(name); + if path.is_file() { + files.insert(PathBuf::from(name), PlannedMiniAppFile::Source(path)); + } else { + files.insert(PathBuf::from(name), PlannedMiniAppFile::Generated(fallback)); + } + } + + let mut source_files = Vec::new(); + collect_regular_files(source, source, &mut source_files)?; + enforce_tree_limits(source, &source_files)?; + for path in source_files { + let relative = path + .strip_prefix(source) + .map_err(|_| LegacyMigrationError::PathEscape(path.clone()))? + .to_path_buf(); + if miniapp_owner_path(&relative) { + continue; + } + files + .entry(relative) + .or_insert(PlannedMiniAppFile::Source(path)); + } + Ok(files) +} + +fn miniapp_owner_path(relative: &Path) -> bool { + if relative == Path::new(META_JSON) + || relative == Path::new(COMPILED_HTML) + || relative == Path::new(PACKAGE_JSON) + || relative == Path::new(STORAGE_JSON) + || relative == Path::new(BUILTIN_INSTALL_MARKER) + || relative == Path::new(ESM_DEPS_JSON) + || REQUIRED_SOURCE_FILES + .iter() + .any(|name| relative == Path::new(name)) + { + return true; + } + REQUIRED_SOURCE_FILES + .iter() + .chain(std::iter::once(&ESM_DEPS_JSON)) + .any(|name| relative == Path::new(SOURCE_DIR).join(name)) +} + +fn first_regular_file(candidates: &[PathBuf]) -> Option { + candidates.iter().find(|path| path.is_file()).cloned() +} + +fn merge_miniapp_meta(raw: &str, normalized: &str) -> LegacyMigrationResult> { + let mut raw_value: serde_json::Value = serde_json::from_str(raw).map_err(|error| { + LegacyMigrationError::InvalidRequest(format!("invalid legacy MiniApp meta: {error}")) + })?; + let normalized_value: serde_json::Value = + serde_json::from_str(normalized).map_err(|error| { + LegacyMigrationError::InvalidRequest(format!( + "invalid normalized MiniApp meta: {error}" + )) + })?; + overlay_current_fields(&mut raw_value, &normalized_value); + serde_json::to_vec_pretty(&raw_value).map_err(|error| { + LegacyMigrationError::InvalidRequest(format!( + "failed to serialize normalized MiniApp meta: {error}" + )) + }) +} + +fn overlay_current_fields(target: &mut serde_json::Value, current: &serde_json::Value) { + match (target, current) { + (serde_json::Value::Object(target), serde_json::Value::Object(current)) => { + for (name, current_value) in current { + if let Some(target_value) = target.get_mut(name) { + overlay_current_fields(target_value, current_value); + } else { + target.insert(name.clone(), current_value.clone()); + } + } + } + (target, current) => *target = current.clone(), + } +} + +fn rewrite_agent_id(content: &[u8], target_id: &str) -> LegacyMigrationResult> { + let text = std::str::from_utf8(content).map_err(|error| { + LegacyMigrationError::InvalidRequest(format!("legacy Agent is not UTF-8: {error}")) + })?; + let mut output = String::with_capacity(text.len() + target_id.len()); + let mut in_frontmatter = false; + let mut replaced = false; + for (index, line) in text.split_inclusive('\n').enumerate() { + let newline = if line.ends_with("\r\n") { + "\r\n" + } else if line.ends_with('\n') { + "\n" + } else { + "" + }; + let content = line.trim_end_matches(['\r', '\n']); + if index == 0 && content.trim() == "---" { + in_frontmatter = true; + output.push_str(line); + continue; + } + if in_frontmatter && content.trim() == "---" { + in_frontmatter = false; + } + if in_frontmatter && !replaced { + let trimmed = content.trim_start(); + if trimmed.starts_with("id:") { + let indentation = &content[..content.len() - trimmed.len()]; + output.push_str(indentation); + output.push_str("id: "); + output.push_str(target_id); + output.push_str(newline); + replaced = true; + continue; + } + } + output.push_str(line); + } + if !replaced { + return Err(LegacyMigrationError::InvalidRequest( + "legacy Agent frontmatter does not contain an id field".to_string(), + )); + } + let parsed = + custom_agent_read_markdown_str(&output, CustomAgentLevel::User).map_err(|error| { + LegacyMigrationError::InvalidRequest(format!( + "remapped Agent failed current owner parsing: {error}" + )) + })?; + if parsed.definition.id != target_id { + return Err(LegacyMigrationError::InvalidRequest( + "remapped Agent id was not applied".to_string(), + )); + } + Ok(output.into_bytes()) +} + +fn validate_agent_manifest(context: &DomainContext<'_>, staged: bool) -> LegacyMigrationResult<()> { + let manifest = read_manifest(context, "agents")?; + let root = if staged { + stage_domain_dir(context, "agents").join("output") + } else { + context.roots.target_user_root.join("agents") + }; + for entry in imported_entries(&manifest) { + let path = root.join(format!("{}.md", safe_component(&entry.target_id))); + let content = fs::read_to_string(&path).map_err(|error| io(&path, error))?; + let parsed = + custom_agent_read_markdown_str(&content, CustomAgentLevel::User).map_err(|error| { + LegacyMigrationError::InvalidRequest(format!( + "Agent {} failed current owner parsing: {error}", + entry.target_id + )) + })?; + if parsed.definition.id != entry.target_id + || hash_bytes(content.as_bytes()) != entry.content_hash + { + return Err(LegacyMigrationError::InvalidRequest(format!( + "Agent {} does not match its staged identity", + entry.target_id + ))); + } + } + Ok(()) +} + +fn scan_from_trees( + domain: MigrationDomainId, + code: &str, + source_schema: &str, + target_schema: &str, + planned: &[PlannedTree], +) -> DomainScan { + DomainScan { + finding: ScanFinding { + domain, + code: code.to_string(), + severity: FindingSeverity::Info, + entity_count: planned.len() as u64, + logical_bytes: planned + .iter() + .map(|item| { + item.files + .iter() + .filter_map(|path| fs::metadata(path).ok()) + .map(|metadata| metadata.len()) + .sum::() + }) + .sum(), + source_schema: Some(source_schema.to_string()), + migratable: true, + detail: "Complete user extension trees are staged; only required current-format fields are normalized." + .to_string(), + }, + conflicts: planned + .iter() + .filter_map(|item| { + conflict_for_action(domain, &item.source_id, &item.target_id, item.action) + }) + .collect(), + target_schema: Some(target_schema.to_string()), + dependencies: match domain { + MigrationDomainId::Miniapps => vec![MigrationDomainId::Skills], + _ => Vec::new(), + }, + } +} + +fn conflict_for_action( + domain: MigrationDomainId, + source_id: &str, + target_id: &str, + action: ImportAction, +) -> Option { + let (code, resolution) = match action { + ImportAction::Remap => ("extension_id_remapped", ConflictResolution::SourceRemapped), + ImportAction::Duplicate => ( + "extension_duplicate_skipped", + ConflictResolution::DuplicateSkipped, + ), + ImportAction::BuiltinStorage => return None, + ImportAction::TargetWins => ( + "builtin_storage_target_preserved", + ConflictResolution::TargetWins, + ), + ImportAction::Skip => ("extension_item_skipped", ConflictResolution::ItemSkipped), + ImportAction::Import => return None, + }; + Some(MigrationConflict { + domain, + code: code.to_string(), + source_summary: format!("legacy extension id {source_id}"), + target_summary: format!("OpenBitFun extension id {target_id}"), + resolution, + }) +} + +fn result_from_manifest( + domain: MigrationDomainId, + manifest: &ImportManifest, +) -> LegacyMigrationResult { + let imported = manifest + .entries + .iter() + .filter(|entry| { + matches!( + entry.action, + ImportAction::Import | ImportAction::Remap | ImportAction::BuiltinStorage + ) + }) + .count() as u64; + let skipped = manifest + .entries + .iter() + .filter(|entry| { + matches!( + entry.action, + ImportAction::Duplicate | ImportAction::TargetWins | ImportAction::Skip + ) + }) + .count() as u64 + + manifest.skipped_paths.len() as u64; + let conflicts = manifest + .entries + .iter() + .filter(|entry| { + !matches!( + entry.action, + ImportAction::Import | ImportAction::BuiltinStorage + ) + }) + .count() as u64; + let warnings = manifest + .skipped_paths + .iter() + .map(|path| MigrationDiagnostic { + code: "extension_path_not_declared".to_string(), + severity: FindingSeverity::Warning, + domain: Some(domain), + relative_path: Some(path.clone()), + message: "The path is outside the current owner's import contract.".to_string(), + action: Some("Review the source extension manually.".to_string()), + }) + .collect(); + Ok(MigrationDomainResult { + domain, + state: MigrationDomainState::Staged, + imported, + skipped, + conflicts, + warnings, + ..MigrationDomainResult::default() + }) +} + +fn import_entry(item: &PlannedTree) -> ImportEntry { + ImportEntry { + source_id: item.source_id.clone(), + target_id: item.target_id.clone(), + action: item.action, + content_hash: item.content_hash.clone(), + } +} + +fn imported_entries(manifest: &ImportManifest) -> impl Iterator { + manifest + .entries + .iter() + .filter(|entry| matches!(entry.action, ImportAction::Import | ImportAction::Remap)) +} + +fn read_manifest(context: &DomainContext<'_>, name: &str) -> LegacyMigrationResult { + read_bounded_json( + &context.layout.stage_root(), + &stage_domain_dir(context, name).join("manifest.json"), + ) +} + +fn skills_manifest_path(context: &DomainContext<'_>) -> PathBuf { + stage_domain_dir(context, "skills").join("manifest.json") +} + +fn miniapps_manifest_path(context: &DomainContext<'_>) -> PathBuf { + stage_domain_dir(context, "miniapps").join("manifest.json") +} + +fn agents_manifest_path(context: &DomainContext<'_>) -> PathBuf { + stage_domain_dir(context, "agents").join("manifest.json") +} + +fn legacy_skills_root(roots: &MigrationRoots) -> PathBuf { + if roots.legacy_skills_root.exists() { + roots.legacy_skills_root.clone() + } else { + roots.legacy_user_root.join("skills") + } +} + +fn target_miniapps_root(roots: &MigrationRoots) -> PathBuf { + roots.target_user_root.join("data").join("miniapps") +} + +fn direct_child_directories(root: &Path) -> LegacyMigrationResult> { + if !root.exists() { + return Ok(Vec::new()); + } + reject_link(root)?; + let mut output = Vec::new(); + for entry in fs::read_dir(root).map_err(|error| io(root, error))? { + let entry = entry.map_err(|error| io(root, error))?; + let path = entry.path(); + reject_link(&path)?; + if entry + .file_type() + .map_err(|error| io(&path, error))? + .is_dir() + { + output.push(path); + } + } + output.sort(); + Ok(output) +} + +fn direct_markdown_files(root: &Path) -> LegacyMigrationResult> { + if !root.exists() { + return Ok(Vec::new()); + } + reject_link(root)?; + let mut output = Vec::new(); + for entry in fs::read_dir(root).map_err(|error| io(root, error))? { + let entry = entry.map_err(|error| io(root, error))?; + let path = entry.path(); + reject_link(&path)?; + if entry + .file_type() + .map_err(|error| io(&path, error))? + .is_file() + && path.extension().and_then(|value| value.to_str()) == Some("md") + { + output.push(path); + } + } + output.sort(); + Ok(output) +} + +fn collect_regular_files( + declared_root: &Path, + current: &Path, + output: &mut Vec, +) -> LegacyMigrationResult<()> { + reject_link(current)?; + for entry in fs::read_dir(current).map_err(|error| io(current, error))? { + let entry = entry.map_err(|error| io(current, error))?; + let path = entry.path(); + reject_link(&path)?; + let kind = entry.file_type().map_err(|error| io(&path, error))?; + if kind.is_dir() { + collect_regular_files(declared_root, &path, output)?; + } else if kind.is_file() { + output.push(path); + } + if output.len() > MAX_FILES_PER_EXTENSION { + return Err(LegacyMigrationError::ResourceLimit(format!( + "extension exceeds {MAX_FILES_PER_EXTENSION} files: {}", + declared_root.display() + ))); + } + } + Ok(()) +} + +fn enforce_tree_limits(root: &Path, files: &[PathBuf]) -> LegacyMigrationResult<()> { + if files.len() > MAX_FILES_PER_EXTENSION { + return Err(LegacyMigrationError::ResourceLimit(format!( + "extension exceeds {MAX_FILES_PER_EXTENSION} files: {}", + root.display() + ))); + } + let mut total = 0u64; + for path in files { + let bytes = fs::metadata(path).map_err(|error| io(path, error))?.len(); + total = total.saturating_add(bytes); + } + if total > MAX_EXTENSION_BYTES { + return Err(LegacyMigrationError::ResourceLimit(format!( + "extension exceeds {MAX_EXTENSION_BYTES} bytes: {}", + root.display() + ))); + } + Ok(()) +} + +fn copy_declared_tree( + source_root: &Path, + target_root: &Path, + files: &[PathBuf], +) -> LegacyMigrationResult<()> { + for source in files { + let relative = source + .strip_prefix(source_root) + .map_err(|_| LegacyMigrationError::PathEscape(source.to_path_buf()))?; + let target = target_root.join(relative); + copy_regular_file(source, &target)?; + } + Ok(()) +} + +fn copy_regular_file(source: &Path, target: &Path) -> LegacyMigrationResult<()> { + reject_link(source)?; + if !source.is_file() { + return Err(LegacyMigrationError::InvalidRequest(format!( + "expected a regular extension file: {}", + source.display() + ))); + } + let parent = target.parent().ok_or_else(|| { + LegacyMigrationError::InvalidRequest(format!( + "extension target has no parent: {}", + target.display() + )) + })?; + fs::create_dir_all(parent).map_err(|error| io(parent, error))?; + fs::copy(source, target).map_err(|error| io(target, error))?; + Ok(()) +} + +fn copy_directory(source: &Path, target: &Path) -> LegacyMigrationResult<()> { + let mut files = Vec::new(); + collect_regular_files(source, source, &mut files)?; + enforce_tree_limits(source, &files)?; + copy_declared_tree(source, target, &files) +} + +fn resolve_directory_conflict( + source_id: &str, + source_hash: &str, + target: &Path, +) -> LegacyMigrationResult<(String, ImportAction)> { + if !target.exists() { + return Ok((source_id.to_string(), ImportAction::Import)); + } + reject_link(target)?; + let target_hash = hash_tree(target)?; + if target_hash == source_hash { + Ok((source_id.to_string(), ImportAction::Duplicate)) + } else { + Ok((remapped_id(source_id, source_hash), ImportAction::Remap)) + } +} + +fn resolve_miniapp_conflict( + source: &Path, + source_id: &str, + source_hash: &str, + target_root: &Path, +) -> LegacyMigrationResult<(String, ImportAction, String)> { + let content_hash = hash_miniapp_output(source, source_id)?; + let target = target_root.join(source_id); + if !target.exists() { + return Ok((source_id.to_string(), ImportAction::Import, content_hash)); + } + reject_link(&target)?; + if hash_tree(&target)? == content_hash { + return Ok((source_id.to_string(), ImportAction::Duplicate, content_hash)); + } + let remapped = remapped_id(source_id, source_hash); + let remapped_hash = hash_miniapp_output(source, &remapped)?; + let remapped_target = target_root.join(&remapped); + if remapped_target.exists() { + reject_link(&remapped_target)?; + if hash_tree(&remapped_target)? == remapped_hash { + return Ok((remapped, ImportAction::Duplicate, remapped_hash)); + } + return Err(LegacyMigrationError::InvalidRequest(format!( + "remapped MiniApp target already contains different data: {}", + remapped_target.display() + ))); + } + Ok((remapped, ImportAction::Remap, remapped_hash)) +} + +fn resolve_remapped_miniapp_conflict( + source: &Path, + source_id: &str, + source_hash: &str, + target_root: &Path, +) -> LegacyMigrationResult<(String, ImportAction, String)> { + let remapped = remapped_id(source_id, source_hash); + let content_hash = hash_miniapp_output(source, &remapped)?; + let target = target_root.join(&remapped); + if !target.exists() { + return Ok((remapped, ImportAction::Remap, content_hash)); + } + reject_link(&target)?; + if hash_tree(&target)? == content_hash { + Ok((remapped, ImportAction::Duplicate, content_hash)) + } else { + Err(LegacyMigrationError::InvalidRequest(format!( + "remapped MiniApp target already contains different data: {}", + target.display() + ))) + } +} + +fn hash_miniapp_output(source: &Path, target_id: &str) -> LegacyMigrationResult { + hash_planned_files(&miniapp_output_plan(source, target_id)?) +} + +fn install_directory_idempotent( + staged: &Path, + target: &Path, + expected_hash: &str, + run_id: &str, +) -> LegacyMigrationResult<()> { + if target.exists() { + if hash_tree(target)? == expected_hash { + return Ok(()); + } + return Err(LegacyMigrationError::InvalidRequest(format!( + "target changed after planning: {}", + target.display() + ))); + } + let parent = target.parent().ok_or_else(|| { + LegacyMigrationError::InvalidRequest(format!("target has no parent: {}", target.display())) + })?; + fs::create_dir_all(parent).map_err(|error| io(parent, error))?; + let temp = parent.join(format!( + ".migration-{}-{}", + safe_component(run_id), + target.file_name().unwrap_or_default().to_string_lossy() + )); + if temp.exists() { + fs::remove_dir_all(&temp).map_err(|error| io(&temp, error))?; + } + copy_directory(staged, &temp)?; + fs::rename(&temp, target).map_err(|error| io(target, error)) +} + +fn install_file_idempotent( + staged: &Path, + target: &Path, + expected_hash: &str, +) -> LegacyMigrationResult<()> { + if target.exists() { + if hash_file(target)? == expected_hash { + return Ok(()); + } + return Err(LegacyMigrationError::InvalidRequest(format!( + "target changed after planning: {}", + target.display() + ))); + } + let bytes = fs::read(staged).map_err(|error| io(staged, error))?; + atomic_write_bytes(target, &bytes) +} + +fn rollback_imported_directories( + context: &DomainContext<'_>, + name: &str, + target_root: &Path, +) -> LegacyMigrationResult<()> { + let manifest_path = stage_domain_dir(context, name).join("manifest.json"); + if !manifest_path.exists() { + return Ok(()); + } + let manifest = read_manifest(context, name)?; + for entry in imported_entries(&manifest) { + let target = target_root.join(&entry.target_id); + if target.exists() { + if hash_tree(&target)? == entry.content_hash { + fs::remove_dir_all(&target).map_err(|error| io(&target, error))?; + } + } + } + Ok(()) +} + +fn rollback_builtin_storage(context: &DomainContext<'_>) -> LegacyMigrationResult<()> { + let manifest_path = stage_domain_dir(context, "miniapps").join("manifest.json"); + if !manifest_path.exists() { + return Ok(()); + } + let manifest = read_manifest(context, "miniapps")?; + let target_root = target_miniapps_root(context.roots); + for entry in manifest + .entries + .iter() + .filter(|entry| entry.action == ImportAction::BuiltinStorage) + { + let staged = stage_domain_dir(context, "miniapps") + .join("builtin-storage") + .join(&entry.target_id) + .join(STORAGE_JSON); + let target = target_root.join(&entry.target_id).join(STORAGE_JSON); + remove_file_if_matches(&staged, &target)?; + } + Ok(()) +} + +fn remove_file_if_matches(staged: &Path, target: &Path) -> LegacyMigrationResult<()> { + if staged.exists() && target.exists() && hash_file(staged)? == hash_file(target)? { + fs::remove_file(target).map_err(|error| io(target, error))?; + } + Ok(()) +} + +fn require_hash(root: &Path, expected: &str) -> LegacyMigrationResult<()> { + let actual = hash_tree(root)?; + if actual != expected { + return Err(LegacyMigrationError::InvalidRequest(format!( + "staged extension hash mismatch at {}", + root.display() + ))); + } + Ok(()) +} + +fn hash_tree(root: &Path) -> LegacyMigrationResult { + let mut files = Vec::new(); + collect_regular_files(root, root, &mut files)?; + enforce_tree_limits(root, &files)?; + hash_file_set(root, &files) +} + +fn hash_file_set(root: &Path, files: &[PathBuf]) -> LegacyMigrationResult { + let mut relative = files + .iter() + .map(|path| { + path.strip_prefix(root) + .map(|relative| (relative.to_path_buf(), path.clone())) + .map_err(|_| LegacyMigrationError::PathEscape(path.clone())) + }) + .collect::>>()?; + relative.sort_by(|left, right| left.0.cmp(&right.0)); + let mut hasher = Sha256::new(); + for (relative, path) in relative { + hasher.update(relative.to_string_lossy().replace('\\', "/").as_bytes()); + hasher.update([0]); + hash_file_contents(&path, &mut hasher)?; + hasher.update([0]); + } + Ok(format!("sha256:{}", hex::encode(hasher.finalize()))) +} + +fn hash_file(path: &Path) -> LegacyMigrationResult { + let mut hasher = Sha256::new(); + hash_file_contents(path, &mut hasher)?; + Ok(format!("sha256:{}", hex::encode(hasher.finalize()))) +} + +fn hash_planned_files( + files: &BTreeMap, +) -> LegacyMigrationResult { + let mut hasher = Sha256::new(); + for (relative, file) in files { + hasher.update(relative.to_string_lossy().replace('\\', "/").as_bytes()); + hasher.update([0]); + match file { + PlannedMiniAppFile::Source(path) => hash_file_contents(path, &mut hasher)?, + PlannedMiniAppFile::Generated(bytes) => hasher.update(bytes), + } + hasher.update([0]); + } + Ok(format!("sha256:{}", hex::encode(hasher.finalize()))) +} + +fn hash_file_contents(path: &Path, hasher: &mut Sha256) -> LegacyMigrationResult<()> { + let file = fs::File::open(path).map_err(|error| io(path, error))?; + let mut reader = BufReader::new(file); + let mut buffer = [0u8; 64 * 1024]; + loop { + let read = reader.read(&mut buffer).map_err(|error| io(path, error))?; + if read == 0 { + break; + } + hasher.update(&buffer[..read]); + } + Ok(()) +} + +fn hash_bytes(bytes: &[u8]) -> String { + format!("sha256:{}", hex::encode(Sha256::digest(bytes))) +} + +fn remapped_id(source_id: &str, content_hash: &str) -> String { + let suffix = content_hash + .strip_prefix("sha256:") + .unwrap_or(content_hash) + .chars() + .take(8) + .collect::(); + let base = safe_component(source_id); + let base = if base.is_empty() { + "legacy-item".to_string() + } else { + base + }; + format!("{base}-from-legacy-{suffix}") +} + +fn is_safe_component(value: &str) -> bool { + if value.is_empty() || safe_component(value) != value { + return false; + } + let stem = value + .split('.') + .next() + .unwrap_or_default() + .to_ascii_uppercase(); + !matches!( + stem.as_str(), + "CON" + | "PRN" + | "AUX" + | "NUL" + | "COM1" + | "COM2" + | "COM3" + | "COM4" + | "COM5" + | "COM6" + | "COM7" + | "COM8" + | "COM9" + | "LPT1" + | "LPT2" + | "LPT3" + | "LPT4" + | "LPT5" + | "LPT6" + | "LPT7" + | "LPT8" + | "LPT9" + ) +} + +fn safe_component(value: &str) -> String { + let normalized = value + .chars() + .map(|character| { + if character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.') { + character + } else { + '-' + } + }) + .collect::(); + normalized.trim_matches(['.', '-']).to_string() +} + +fn file_name(path: &Path) -> LegacyMigrationResult { + path.file_name() + .and_then(|value| value.to_str()) + .map(str::to_string) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + LegacyMigrationError::InvalidRequest(format!( + "path has no valid UTF-8 file name: {}", + path.display() + )) + }) +} + +fn reject_link(path: &Path) -> LegacyMigrationResult<()> { + let metadata = fs::symlink_metadata(path).map_err(|error| io(path, error))?; + if metadata.file_type().is_symlink() || is_reparse_point(&metadata) { + return Err(LegacyMigrationError::LinkedPath(path.to_path_buf())); + } + Ok(()) +} + +#[cfg(windows)] +fn is_reparse_point(metadata: &fs::Metadata) -> bool { + use std::os::windows::fs::MetadataExt; + metadata.file_attributes() & 0x0400 != 0 +} + +#[cfg(not(windows))] +fn is_reparse_point(_metadata: &fs::Metadata) -> bool { + false +} + +fn io(path: &Path, error: std::io::Error) -> LegacyMigrationError { + LegacyMigrationError::InvalidRequest(format!("I/O failed at {}: {error}", path.display())) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::legacy_migration::adapters_for_groups; + use openbitfun_legacy_migration::{ + probe_legacy_source, CancellationToken, MigrationEngine, NoCrashInjection, ProbeLimits, + }; + use openbitfun_product_domains::legacy_migration::{MigrationGroupId, MigrationSelection}; + use std::collections::BTreeSet; + + #[test] + fn extension_group_preserves_user_trees_and_excludes_builtin_code() { + let temp = test_tempdir("extensions"); + let roots = fixture_roots(temp.path()); + copy_fixture(&roots); + let unknown = roots + .legacy_skills_root + .join("user-skill") + .join("undeclared.txt"); + atomic_write_bytes(&unknown, b"private fixture content that must be preserved").unwrap(); + let skill_readme = roots.legacy_skills_root.join("user-skill/README.md"); + atomic_write_bytes(&skill_readme, b"User-maintained Skill notes.").unwrap(); + let skill_image = roots + .legacy_skills_root + .join("user-skill/assets/reference.bin"); + atomic_write_bytes(&skill_image, b"fixture-image").unwrap(); + + let source_agent = roots.legacy_user_root.join("agents/researcher.md"); + let agent_text = fs::read_to_string(&source_agent).unwrap().replace( + "schema_version: 1", + "schema_version: 1\nfuture_field: keep-me\n# user comment", + ); + atomic_write_bytes(&source_agent, agent_text.as_bytes()).unwrap(); + + let custom_miniapp = roots.legacy_user_root.join("data/miniapps/custom-notes"); + atomic_write_bytes( + &custom_miniapp.join("source/assets/icon.svg"), + b"preserved", + ) + .unwrap(); + atomic_write_bytes(&custom_miniapp.join("assets/legacy.bin"), b"legacy-asset").unwrap(); + let mut meta_value: serde_json::Value = + serde_json::from_slice(&fs::read(custom_miniapp.join(META_JSON)).unwrap()).unwrap(); + meta_value["futureField"] = serde_json::json!({"nested": true}); + atomic_write_json(&custom_miniapp.join(META_JSON), &meta_value).unwrap(); + let source_hashes = [ + hash_tree(&roots.legacy_user_root).unwrap(), + hash_tree(&roots.legacy_home_root).unwrap(), + hash_tree(&roots.legacy_ssh_root).unwrap(), + ]; + let source = probe_legacy_source(&roots, ProbeLimits::default()) + .unwrap() + .unwrap(); + let selection = MigrationSelection { + groups: BTreeSet::from([MigrationGroupId::AgentsSkillsAndMiniapps]), + }; + let engine = MigrationEngine::new(roots.clone(), adapters_for_groups(&selection)).unwrap(); + let plan = engine + .plan(&source, selection.clone(), &CancellationToken::default()) + .unwrap(); + let report = engine + .execute(&plan, &CancellationToken::default(), &NoCrashInjection) + .unwrap(); + + assert!(roots + .target_skills_root + .join("user-skill/SKILL.md") + .exists()); + assert!(!roots + .target_skills_root + .join(OPENBITFUN_SYSTEM_SKILL_DIR) + .exists()); + assert!(roots + .target_skills_root + .join("user-skill/undeclared.txt") + .exists()); + assert!(roots + .target_skills_root + .join("user-skill/README.md") + .exists()); + assert!(roots + .target_skills_root + .join("user-skill/assets/reference.bin") + .exists()); + assert!(!report + .domain_results + .iter() + .flat_map(|result| &result.warnings) + .any(|warning| warning.code == "extension_path_not_declared")); + assert!(!serde_json::to_string(&report) + .unwrap() + .contains("private fixture content")); + let custom = target_miniapps_root(&roots).join("custom-notes"); + assert!(custom.join("source/index.html").exists()); + assert_eq!( + fs::read(custom.join("source/assets/icon.svg")).unwrap(), + b"preserved" + ); + assert_eq!( + fs::read(custom.join("assets/legacy.bin")).unwrap(), + b"legacy-asset" + ); + let imported_meta: serde_json::Value = + serde_json::from_slice(&fs::read(custom.join(META_JSON)).unwrap()).unwrap(); + assert_eq!(imported_meta["futureField"]["nested"], true); + assert!(custom.join(COMPILED_HTML).exists()); + assert!(!target_miniapps_root(&roots) + .join("builtin-gomoku/index.html") + .exists()); + assert!(target_miniapps_root(&roots) + .join("builtin-gomoku/storage.json") + .exists()); + let agent_path = roots.target_user_root.join("agents/researcher.md"); + let agent = custom_agent_read_markdown_str( + &fs::read_to_string(&agent_path).unwrap(), + CustomAgentLevel::User, + ) + .unwrap(); + assert_eq!(agent.definition.id, "researcher"); + assert_eq!( + fs::read(&agent_path).unwrap(), + fs::read(&source_agent).unwrap() + ); + + let second_source = probe_legacy_source(&roots, ProbeLimits::default()) + .unwrap() + .unwrap(); + let second = engine + .plan(&second_source, selection, &CancellationToken::default()) + .unwrap(); + assert!(second + .conflicts + .iter() + .any(|conflict| conflict.resolution == ConflictResolution::TargetWins)); + assert!(second + .conflicts + .iter() + .any(|conflict| conflict.resolution == ConflictResolution::DuplicateSkipped)); + engine + .execute(&second, &CancellationToken::default(), &NoCrashInjection) + .unwrap(); + assert_eq!( + direct_child_directories(&roots.target_skills_root) + .unwrap() + .len(), + 1 + ); + assert_eq!( + direct_markdown_files(&roots.target_user_root.join("agents")) + .unwrap() + .len(), + 1 + ); + assert_eq!( + [ + hash_tree(&roots.legacy_user_root).unwrap(), + hash_tree(&roots.legacy_home_root).unwrap(), + hash_tree(&roots.legacy_ssh_root).unwrap(), + ], + source_hashes + ); + } + + #[test] + fn conflicting_skill_is_remapped_without_overwriting_target() { + let temp = test_tempdir("skill-conflict"); + let roots = fixture_roots(temp.path()); + copy_fixture(&roots); + let target = roots.target_skills_root.join("user-skill/SKILL.md"); + atomic_write_bytes( + &target, + b"---\nname: user-skill\ndescription: target\n---\n\nTarget body.\n", + ) + .unwrap(); + let source = probe_legacy_source(&roots, ProbeLimits::default()) + .unwrap() + .unwrap(); + let selection = MigrationSelection { + groups: BTreeSet::from([MigrationGroupId::AgentsSkillsAndMiniapps]), + }; + let engine = MigrationEngine::new(roots.clone(), adapters_for_groups(&selection)).unwrap(); + let plan = engine + .plan(&source, selection, &CancellationToken::default()) + .unwrap(); + assert!(plan + .conflicts + .iter() + .any(|conflict| conflict.resolution == ConflictResolution::SourceRemapped)); + engine + .execute(&plan, &CancellationToken::default(), &NoCrashInjection) + .unwrap(); + assert!(fs::read_to_string(&target).unwrap().contains("Target body")); + assert_eq!( + direct_child_directories(&roots.target_skills_root) + .unwrap() + .len(), + 2 + ); + } + + #[test] + fn unsafe_miniapp_id_is_remapped_before_target_path_construction() { + let temp = test_tempdir("path"); + let roots = fixture_roots(temp.path()); + copy_fixture(&roots); + let meta = roots + .legacy_user_root + .join("data/miniapps/custom-notes/meta.json"); + let mut meta_value: serde_json::Value = + serde_json::from_slice(&fs::read(&meta).unwrap()).unwrap(); + meta_value["id"] = serde_json::Value::String("../..".to_string()); + atomic_write_json(&meta, &meta_value).unwrap(); + let source = probe_legacy_source(&roots, ProbeLimits::default()) + .unwrap() + .unwrap(); + let selection = MigrationSelection { + groups: BTreeSet::from([MigrationGroupId::AgentsSkillsAndMiniapps]), + }; + let engine = MigrationEngine::new(roots.clone(), adapters_for_groups(&selection)).unwrap(); + let plan = engine + .plan(&source, selection, &CancellationToken::default()) + .unwrap(); + assert!(plan.conflicts.iter().any(|conflict| { + conflict.domain == MigrationDomainId::Miniapps + && conflict.resolution == ConflictResolution::SourceRemapped + })); + engine + .execute(&plan, &CancellationToken::default(), &NoCrashInjection) + .unwrap(); + assert!(!roots.target_user_root.join(META_JSON).exists()); + assert!(!roots.target_user_root.join(SOURCE_DIR).exists()); + assert!(direct_child_directories(&target_miniapps_root(&roots)) + .unwrap() + .iter() + .any(|path| file_name(path) + .unwrap() + .starts_with("legacy-item-from-legacy-"))); + } + + #[test] + fn rollback_removes_only_matching_builtin_storage() { + let temp = test_tempdir("builtin-storage-rollback"); + let staged = temp.path().join("staged/storage.json"); + let target = temp.path().join("target/storage.json"); + atomic_write_bytes(&staged, br#"{"value":"legacy"}"#).unwrap(); + atomic_write_bytes(&target, br#"{"value":"legacy"}"#).unwrap(); + remove_file_if_matches(&staged, &target).unwrap(); + assert!(!target.exists()); + + atomic_write_bytes(&target, br#"{"value":"target"}"#).unwrap(); + remove_file_if_matches(&staged, &target).unwrap(); + assert_eq!(fs::read(&target).unwrap(), br#"{"value":"target"}"#); + } + + #[test] + fn remapping_agent_changes_only_the_frontmatter_id_line() { + let source = b"---\r\nid: researcher\r\nname: Researcher\r\ndescription: Test agent\r\nfuture_field: keep-me\r\n# user comment\r\nkind: subagent\r\ntools: []\r\nreadonly: true\r\nschema_version: 1\r\n---\r\n\r\nKeep this body byte-for-byte.\r\n"; + let remapped = rewrite_agent_id(source, "researcher-from-legacy-deadbeef").unwrap(); + let text = String::from_utf8(remapped).unwrap(); + assert!(text.contains("id: researcher-from-legacy-deadbeef\r\n")); + assert!(text.contains("future_field: keep-me\r\n# user comment\r\n")); + assert!(text.ends_with("Keep this body byte-for-byte.\r\n")); + } + + fn fixture_roots(root: &Path) -> MigrationRoots { + let legacy_user_root = root.join("legacy-user"); + MigrationRoots { + legacy_skills_root: legacy_user_root.join("skills"), + legacy_user_root, + legacy_home_root: root.join("legacy-home"), + 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-skills"), + target_ssh_root: root.join("target-ssh"), + } + } + + fn copy_fixture(roots: &MigrationRoots) { + let fixture = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../services/legacy-migration/tests/fixtures/v0.2.19"); + copy_directory(&fixture.join("user-root"), &roots.legacy_user_root).unwrap(); + copy_directory(&fixture.join("home"), &roots.legacy_home_root).unwrap(); + copy_directory(&fixture.join("ssh"), &roots.legacy_ssh_root).unwrap(); + } + + fn test_tempdir(label: &str) -> tempfile::TempDir { + let root = std::env::var_os("OPENBITFUN_TEST_TMPDIR") + .map(PathBuf::from) + .unwrap_or_else(std::env::temp_dir); + fs::create_dir_all(&root).unwrap(); + tempfile::Builder::new() + .prefix(&format!("openbitfun-migration-{label}-")) + .tempdir_in(root) + .unwrap() + } +} diff --git a/src/crates/assembly/core/src/legacy_migration/memory.rs b/src/crates/assembly/core/src/legacy_migration/memory.rs new file mode 100644 index 0000000000..b740ebd1d2 --- /dev/null +++ b/src/crates/assembly/core/src/legacy_migration/memory.rs @@ -0,0 +1,2124 @@ +use super::common::{ + backup_domain_dir, io_error, read_bounded_json, read_optional_bounded_json, stage_domain_dir, + validate_regular_file, +}; +use openbitfun_legacy_migration::{ + atomic_write_bytes, atomic_write_json, snapshot_sqlite_read_only, validate_sqlite, + DomainContext, DomainScan, LegacyDomainAdapter, LegacyMigrationError, LegacyMigrationResult, + MigrationRoots, +}; +use openbitfun_product_domains::legacy_migration::{ + ConflictResolution, FindingSeverity, MigrationConflict, MigrationDomainId, + MigrationDomainResult, MigrationDomainState, ScanFinding, +}; +use openbitfun_services_core::memory_store::{ + classify_memory_workspace_file, initialize_memory_schema, read_memory_store_snapshot, + upsert_memory_record, MemoryRecord, MemoryStoreSnapshot, MemoryWorkspaceFileKind, + MEMORY_STORE_SCHEMA, +}; +use rusqlite::{Connection, OpenFlags}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::path::{Component, Path, PathBuf}; +use uuid::Uuid; + +const STRUCTURED_MEMORY_RELATIVE_PATH: &str = "data/memories/memories.sqlite"; +const LEGACY_STRUCTURED_MEMORY_SCHEMA: &str = "bitfun.memory.stage1.v1"; +const FILE_MEMORY_SCHEMA: &str = "openbitfun.memory-files.v1"; +const MAX_MEMORY_FILE_COUNT: usize = 4_096; +const MAX_MEMORY_FILE_BYTES: u64 = 64 * 1024 * 1024; +const MAX_MEMORY_TOTAL_BYTES: u64 = 512 * 1024 * 1024; + +pub(crate) struct StructuredMemoryAdapter; +pub(crate) struct FileMemoryAdapter; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct StructuredMemoryManifest { + source_present: bool, + source_digest: Option, + target_existed: bool, + target_digest: Option, + merged_digest: Option, + imported: u64, + skipped: u64, + conflicts: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct StructuredMemoryCommitReceipt { + target_existed: bool, + merged_digest: String, +} + +#[derive(Debug, Default)] +struct StructuredMergeOutcome { + imports: Vec, + duplicate: u64, + target_wins: u64, + conflicts: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +enum FileMemoryAction { + Import, + Duplicate, + TargetWins, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct FileMemoryManifestEntry { + source_relative: String, + target_relative: String, + action: FileMemoryAction, + expected_hash: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct FileMemoryCommitReceipt { + target_relative: String, + expected_hash: String, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +struct FileMemoryManifest { + entries: Vec, + imported: u64, + skipped: u64, + conflicts: u64, +} + +struct PlannedMemoryFile { + source_relative: PathBuf, + target_relative: PathBuf, + source_path: PathBuf, + action: FileMemoryAction, + expected_hash: String, +} + +struct FileMemoryPlan { + files: Vec, + conflicts: Vec, + logical_bytes: u64, +} + +struct MemoryFileFact { + relative: PathBuf, + path: PathBuf, + hash: String, + bytes: u64, + kind: MemoryWorkspaceFileKind, +} + +impl LegacyDomainAdapter for StructuredMemoryAdapter { + fn domain(&self) -> MigrationDomainId { + MigrationDomainId::StructuredMemory + } + + fn scan(&self, roots: &MigrationRoots) -> LegacyMigrationResult { + let source_path = source_structured_memory_path(roots); + if !path_entry_exists(&source_path)? { + return Ok(empty_domain_scan( + self.domain(), + "legacy_structured_memory_absent", + LEGACY_STRUCTURED_MEMORY_SCHEMA, + MEMORY_STORE_SCHEMA, + "No legacy structured Memory database was found.", + )); + } + let source = read_consistent_scan_snapshot( + roots, + &roots.legacy_user_root, + &source_path, + "structured-memory-source", + MemoryDatabaseRole::LegacySource, + )?; + let target_path = target_structured_memory_path(roots); + let target = if path_entry_exists(&target_path)? { + read_consistent_scan_snapshot( + roots, + &roots.target_user_root, + &target_path, + "structured-memory-target", + MemoryDatabaseRole::CurrentTarget, + )? + } else { + MemoryStoreSnapshot::default() + }; + let outcome = preview_structured_merge(&source.records, &target.records); + let logical_bytes = sqlite_family_size(&source_path)?; + Ok(DomainScan { + finding: ScanFinding { + domain: self.domain(), + code: "legacy_structured_memory_supported".to_string(), + severity: if outcome.conflicts.is_empty() { + FindingSeverity::Info + } else { + FindingSeverity::Warning + }, + entity_count: source.records.len() as u64, + logical_bytes, + source_schema: Some(LEGACY_STRUCTURED_MEMORY_SCHEMA.to_string()), + migratable: true, + detail: format!( + "{} structured Memory records are readable by the current persistence owner", + source.records.len() + ), + }, + conflicts: outcome.conflicts, + target_schema: Some(MEMORY_STORE_SCHEMA.to_string()), + dependencies: Vec::new(), + }) + } + + fn stage(&self, context: &DomainContext<'_>) -> LegacyMigrationResult { + let domain_root = stage_domain_dir(context, "structured-memory"); + reset_directory(&domain_root)?; + let source_path = source_structured_memory_path(context.roots); + if !path_entry_exists(&source_path)? { + let manifest = StructuredMemoryManifest { + source_present: false, + source_digest: None, + target_existed: path_entry_exists(&target_structured_memory_path(context.roots))?, + target_digest: None, + merged_digest: None, + imported: 0, + skipped: 0, + conflicts: 0, + }; + atomic_write_json(&structured_manifest_path(context), &manifest)?; + return Ok(MigrationDomainResult { + domain: self.domain(), + state: MigrationDomainState::Staged, + ..MigrationDomainResult::default() + }); + } + + let staged_source = domain_root.join("source.sqlite"); + let staged_target = domain_root.join("target-before.sqlite"); + let staged_merged = domain_root.join("merged.sqlite"); + validate_sqlite_family(&context.roots.legacy_user_root, &source_path)?; + snapshot_sqlite_read_only(&source_path, &staged_source)?; + let source = read_memory_snapshot(&staged_source, MemoryDatabaseRole::LegacySource)?; + + let target_path = target_structured_memory_path(context.roots); + let target_existed = path_entry_exists(&target_path)?; + let (target, target_digest) = if target_existed { + validate_sqlite_family(&context.roots.target_user_root, &target_path)?; + snapshot_sqlite_read_only(&target_path, &staged_target)?; + let target = read_memory_snapshot(&staged_target, MemoryDatabaseRole::CurrentTarget)?; + snapshot_sqlite_read_only(&staged_target, &staged_merged)?; + let digest = memory_snapshot_digest(&target)?; + (target, Some(digest)) + } else { + let connection = Connection::open(&staged_merged) + .map_err(|error| db_error(&staged_merged, error))?; + initialize_memory_schema(&connection).map_err(owner_error)?; + (MemoryStoreSnapshot::default(), None) + }; + + let outcome = preview_structured_merge(&source.records, &target.records); + { + let mut connection = Connection::open(&staged_merged) + .map_err(|error| db_error(&staged_merged, error))?; + let transaction = connection + .transaction() + .map_err(|error| db_error(&staged_merged, error))?; + for record in &outcome.imports { + upsert_memory_record(&transaction, record, false).map_err(owner_error)?; + } + transaction + .commit() + .map_err(|error| db_error(&staged_merged, error))?; + } + finalize_sqlite_file(&staged_merged)?; + let merged = read_memory_snapshot(&staged_merged, MemoryDatabaseRole::StagedCurrent)?; + let manifest = StructuredMemoryManifest { + source_present: true, + source_digest: Some(memory_snapshot_digest(&source)?), + target_existed, + target_digest, + merged_digest: Some(memory_snapshot_digest(&merged)?), + imported: outcome.imports.len() as u64, + skipped: outcome.duplicate.saturating_add(outcome.target_wins), + conflicts: outcome.conflicts.len() as u64, + }; + atomic_write_json(&structured_manifest_path(context), &manifest)?; + Ok(MigrationDomainResult { + domain: self.domain(), + state: MigrationDomainState::Staged, + imported: manifest.imported, + skipped: manifest.skipped, + conflicts: manifest.conflicts, + ..MigrationDomainResult::default() + }) + } + + fn validate_stage(&self, context: &DomainContext<'_>) -> LegacyMigrationResult<()> { + let manifest = read_structured_manifest(context)?; + if !manifest.source_present { + return Ok(()); + } + let domain_root = stage_domain_dir(context, "structured-memory"); + let source = read_memory_snapshot( + &domain_root.join("source.sqlite"), + MemoryDatabaseRole::LegacySource, + )?; + if Some(memory_snapshot_digest(&source)?) != manifest.source_digest { + return Err(LegacyMigrationError::InvalidRequest( + "staged structured Memory source differs from its manifest".to_string(), + )); + } + let merged = read_memory_snapshot( + &domain_root.join("merged.sqlite"), + MemoryDatabaseRole::StagedCurrent, + )?; + if Some(memory_snapshot_digest(&merged)?) != manifest.merged_digest { + return Err(LegacyMigrationError::InvalidRequest( + "staged structured Memory merge differs from its manifest".to_string(), + )); + } + Ok(()) + } + + fn commit(&self, context: &DomainContext<'_>) -> LegacyMigrationResult<()> { + let manifest = read_structured_manifest(context)?; + if !manifest.source_present { + return Ok(()); + } + let expected = manifest.merged_digest.as_deref().ok_or_else(|| { + LegacyMigrationError::InvalidRequest( + "structured Memory manifest is missing its merged digest".to_string(), + ) + })?; + let target = target_structured_memory_path(context.roots); + let existing_receipt = read_structured_commit_receipt(context)?; + if path_entry_exists(&target)? + && existing_receipt.as_ref().is_some_and(|receipt| { + receipt.target_existed == manifest.target_existed + && receipt.merged_digest == expected + }) + { + validate_sqlite_family(&context.roots.target_user_root, &target)?; + if let Ok(current) = read_memory_snapshot(&target, MemoryDatabaseRole::CurrentTarget) { + if memory_snapshot_digest(¤t)? == expected { + finalize_sqlite_file(&target)?; + return Ok(()); + } + } + } + verify_structured_target_state(&target, &manifest)?; + atomic_write_json( + &structured_commit_receipt_path(context), + &StructuredMemoryCommitReceipt { + target_existed: manifest.target_existed, + merged_digest: expected.to_string(), + }, + )?; + let backup = backup_domain_dir(context, "structured-memory").join("memories.sqlite"); + if manifest.target_existed && !path_entry_exists(&backup)? { + snapshot_sqlite_read_only(&target, &backup)?; + } + if let Some(parent) = target.parent() { + fs::create_dir_all(parent).map_err(|error| io_error(parent, error))?; + } + remove_sqlite_sidecars(&target)?; + let merged = stage_domain_dir(context, "structured-memory").join("merged.sqlite"); + let bytes = fs::read(&merged).map_err(|error| io_error(&merged, error))?; + atomic_write_bytes(&target, &bytes)?; + finalize_sqlite_file(&target)?; + let current = read_memory_snapshot(&target, MemoryDatabaseRole::CurrentTarget)?; + if memory_snapshot_digest(¤t)? != expected { + return Err(LegacyMigrationError::InvalidRequest( + "committed structured Memory database differs from the staged merge".to_string(), + )); + } + Ok(()) + } + + fn validate_commit(&self, context: &DomainContext<'_>) -> LegacyMigrationResult<()> { + let manifest = read_structured_manifest(context)?; + if !manifest.source_present { + return Ok(()); + } + let current = read_memory_snapshot( + &target_structured_memory_path(context.roots), + MemoryDatabaseRole::CurrentTarget, + )?; + if Some(memory_snapshot_digest(¤t)?) != manifest.merged_digest { + return Err(LegacyMigrationError::InvalidRequest( + "current Memory owner did not read the committed structured data".to_string(), + )); + } + Ok(()) + } + + fn rollback_unverified(&self, context: &DomainContext<'_>) -> LegacyMigrationResult<()> { + let Some(manifest) = read_optional_bounded_json::( + &context.layout.stage_root(), + &structured_manifest_path(context), + )? + else { + return Ok(()); + }; + if !manifest.source_present { + return Ok(()); + } + let Some(receipt) = read_structured_commit_receipt(context)? else { + return Ok(()); + }; + if receipt.target_existed != manifest.target_existed + || Some(receipt.merged_digest.as_str()) != manifest.merged_digest.as_deref() + { + return Err(LegacyMigrationError::InvalidRequest( + "structured Memory commit receipt differs from its manifest".to_string(), + )); + } + let target = target_structured_memory_path(context.roots); + let backup = backup_domain_dir(context, "structured-memory").join("memories.sqlite"); + if manifest.target_existed { + let original_digest = manifest.target_digest.as_deref().ok_or_else(|| { + LegacyMigrationError::InvalidRequest( + "structured Memory manifest is missing its original target digest".to_string(), + ) + })?; + let merged_digest = manifest.merged_digest.as_deref().ok_or_else(|| { + LegacyMigrationError::InvalidRequest( + "structured Memory manifest is missing its merged target digest".to_string(), + ) + })?; + if path_entry_exists(&target)? { + validate_sqlite_family(&context.roots.target_user_root, &target)?; + if let Ok(current) = + read_memory_snapshot(&target, MemoryDatabaseRole::CurrentTarget) + { + let current_digest = memory_snapshot_digest(¤t)?; + if current_digest == original_digest { + return Ok(()); + } + if current_digest != merged_digest { + return Err(LegacyMigrationError::InvalidRequest( + "structured Memory target changed after migration commit; refusing to overwrite it during rollback" + .to_string(), + )); + } + } + } + if path_entry_exists(&backup)? { + validate_sqlite_family(&context.layout.backup_root(), &backup)?; + let original = read_memory_snapshot(&backup, MemoryDatabaseRole::CurrentTarget)?; + if memory_snapshot_digest(&original)? != original_digest { + return Err(LegacyMigrationError::InvalidRequest( + "structured Memory rollback backup differs from the staged original target" + .to_string(), + )); + } + remove_sqlite_sidecars(&target)?; + let bytes = fs::read(&backup).map_err(|error| io_error(&backup, error))?; + atomic_write_bytes(&target, &bytes)?; + remove_sqlite_sidecars(&target)?; + } + } else if path_entry_exists(&target)? { + let should_remove = manifest.merged_digest.as_deref().is_some_and(|expected| { + read_memory_snapshot(&target, MemoryDatabaseRole::CurrentTarget) + .and_then(|snapshot| memory_snapshot_digest(&snapshot)) + .is_ok_and(|actual| actual == expected) + }); + if should_remove { + remove_sqlite_sidecars(&target)?; + remove_file_if_present(&target)?; + } + } + Ok(()) + } +} + +impl LegacyDomainAdapter for FileMemoryAdapter { + fn domain(&self) -> MigrationDomainId { + MigrationDomainId::FileMemory + } + + fn scan(&self, roots: &MigrationRoots) -> LegacyMigrationResult { + let plan = plan_file_memory(roots)?; + Ok(DomainScan { + finding: ScanFinding { + domain: self.domain(), + code: if plan.files.is_empty() { + "legacy_file_memory_absent" + } else { + "legacy_file_memory_supported" + } + .to_string(), + severity: if plan.conflicts.is_empty() { + FindingSeverity::Info + } else { + FindingSeverity::Warning + }, + entity_count: plan.files.len() as u64, + logical_bytes: plan.logical_bytes, + source_schema: Some("bitfun.memory-files.v1".to_string()), + migratable: true, + detail: format!( + "{} owner-declared file Memory inputs are eligible for migration", + plan.files.len() + ), + }, + conflicts: plan.conflicts, + target_schema: Some(FILE_MEMORY_SCHEMA.to_string()), + dependencies: Vec::new(), + }) + } + + fn stage(&self, context: &DomainContext<'_>) -> LegacyMigrationResult { + let plan = plan_file_memory(context.roots)?; + let domain_root = stage_domain_dir(context, "file-memory"); + reset_directory(&domain_root)?; + let files_root = domain_root.join("files"); + let mut entries = Vec::with_capacity(plan.files.len()); + let mut imported = 0u64; + let mut skipped = 0u64; + for file in plan.files { + if file.action == FileMemoryAction::Import { + let bytes = fs::read(&file.source_path) + .map_err(|error| io_error(&file.source_path, error))?; + atomic_write_bytes(&files_root.join(&file.target_relative), &bytes)?; + imported = imported.saturating_add(1); + } else { + skipped = skipped.saturating_add(1); + } + entries.push(FileMemoryManifestEntry { + source_relative: relative_string(&file.source_relative), + target_relative: relative_string(&file.target_relative), + action: file.action, + expected_hash: file.expected_hash, + }); + } + let manifest = FileMemoryManifest { + entries, + imported, + skipped, + conflicts: plan.conflicts.len() as u64, + }; + atomic_write_json(&file_manifest_path(context), &manifest)?; + Ok(MigrationDomainResult { + domain: self.domain(), + state: MigrationDomainState::Staged, + imported, + skipped, + conflicts: manifest.conflicts, + ..MigrationDomainResult::default() + }) + } + + fn validate_stage(&self, context: &DomainContext<'_>) -> LegacyMigrationResult<()> { + let manifest = read_file_manifest(context)?; + let files_root = stage_domain_dir(context, "file-memory").join("files"); + for (_, entry) in imported_file_entries(&manifest) { + let relative = validated_memory_relative_path(&entry.target_relative)?; + let path = files_root.join(relative); + validate_regular_file(&files_root, &path)?; + if normalized_file_hash(&path)? != entry.expected_hash { + return Err(LegacyMigrationError::InvalidRequest(format!( + "staged file Memory item {} differs from its manifest", + entry.target_relative + ))); + } + } + Ok(()) + } + + fn commit(&self, context: &DomainContext<'_>) -> LegacyMigrationResult<()> { + let manifest = read_file_manifest(context)?; + let source_root = stage_domain_dir(context, "file-memory").join("files"); + let target_root = target_file_memory_root(context.roots); + if path_entry_exists(&target_root)? { + validate_directory(&context.roots.target_home_root, &target_root)?; + } + for (entry_index, entry) in imported_file_entries(&manifest) { + let relative = validated_memory_relative_path(&entry.target_relative)?; + let source = source_root.join(&relative); + let target = target_root.join(&relative); + if path_entry_exists(&target)? { + validate_regular_file(&target_root, &target)?; + let receipt = read_file_commit_receipt(context, entry_index)?; + if normalized_file_hash(&target)? == entry.expected_hash + && receipt.as_ref().is_some_and(|receipt| { + receipt.target_relative == entry.target_relative + && receipt.expected_hash == entry.expected_hash + }) + { + continue; + } + return Err(LegacyMigrationError::InvalidRequest(format!( + "file Memory target changed after staging: {}", + entry.target_relative + ))); + } + atomic_write_json( + &file_commit_receipt_path(context, entry_index), + &FileMemoryCommitReceipt { + target_relative: entry.target_relative.clone(), + expected_hash: entry.expected_hash.clone(), + }, + )?; + ensure_safe_target_parent(context.roots, &target_root, &target)?; + let bytes = fs::read(&source).map_err(|error| io_error(&source, error))?; + atomic_write_bytes(&target, &bytes)?; + } + Ok(()) + } + + fn validate_commit(&self, context: &DomainContext<'_>) -> LegacyMigrationResult<()> { + let manifest = read_file_manifest(context)?; + let target_root = target_file_memory_root(context.roots); + if path_entry_exists(&target_root)? { + validate_directory(&context.roots.target_home_root, &target_root)?; + } + for (_, entry) in imported_file_entries(&manifest) { + let relative = validated_memory_relative_path(&entry.target_relative)?; + let target = target_root.join(relative); + validate_regular_file(&target_root, &target)?; + if normalized_file_hash(&target)? != entry.expected_hash { + return Err(LegacyMigrationError::InvalidRequest(format!( + "current Memory owner could not validate file {}", + entry.target_relative + ))); + } + } + Ok(()) + } + + fn rollback_unverified(&self, context: &DomainContext<'_>) -> LegacyMigrationResult<()> { + let Some(manifest) = read_optional_bounded_json::( + &context.layout.stage_root(), + &file_manifest_path(context), + )? + else { + return Ok(()); + }; + let target_root = target_file_memory_root(context.roots); + if path_entry_exists(&target_root)? { + validate_directory(&context.roots.target_home_root, &target_root)?; + } + for (entry_index, entry) in imported_file_entries(&manifest).rev() { + let Some(receipt) = read_file_commit_receipt(context, entry_index)? else { + continue; + }; + if receipt.target_relative != entry.target_relative + || receipt.expected_hash != entry.expected_hash + { + return Err(LegacyMigrationError::InvalidRequest(format!( + "file Memory commit receipt differs for {}", + entry.target_relative + ))); + } + let relative = validated_memory_relative_path(&entry.target_relative)?; + let target = target_root.join(&relative); + if path_entry_exists(&target)? + && validate_regular_file(&target_root, &target).is_ok() + && normalized_file_hash(&target)? == entry.expected_hash + { + fs::remove_file(&target).map_err(|error| io_error(&target, error))?; + prune_empty_memory_parents(&target_root, target.parent())?; + } + } + if path_entry_exists(&target_root)? { + validate_directory(&context.roots.target_home_root, &target_root)?; + } + if path_entry_exists(&target_root)? && is_directory_empty(&target_root)? { + fs::remove_dir(&target_root).map_err(|error| io_error(&target_root, error))?; + } + Ok(()) + } +} + +fn preview_structured_merge( + source: &[MemoryRecord], + target: &[MemoryRecord], +) -> StructuredMergeOutcome { + let target_by_id = target + .iter() + .map(|record| { + ( + record.session_id.as_str(), + normalized_memory_content_hash(record), + ) + }) + .collect::>(); + let mut occupied_hashes = target_by_id.values().cloned().collect::>(); + let mut outcome = StructuredMergeOutcome::default(); + for record in source { + let content_hash = normalized_memory_content_hash(record); + if let Some(target_hash) = target_by_id.get(record.session_id.as_str()) { + if target_hash == &content_hash { + outcome.duplicate = outcome.duplicate.saturating_add(1); + outcome.conflicts.push(structured_conflict( + record, + "structured_memory_duplicate", + "The target contains the same stable id and normalized content.", + ConflictResolution::DuplicateSkipped, + )); + } else { + outcome.target_wins = outcome.target_wins.saturating_add(1); + outcome.conflicts.push(structured_conflict( + record, + "structured_memory_id_conflict", + "The target keeps its record because the stable id is also a Session reference.", + ConflictResolution::TargetWins, + )); + } + continue; + } + if !occupied_hashes.insert(content_hash) { + outcome.duplicate = outcome.duplicate.saturating_add(1); + outcome.conflicts.push(structured_conflict( + record, + "structured_memory_content_duplicate", + "The normalized content already exists under another stable id.", + ConflictResolution::DuplicateSkipped, + )); + continue; + } + outcome.imports.push(record.clone()); + } + outcome +} + +fn structured_conflict( + record: &MemoryRecord, + code: &str, + target_summary: &str, + resolution: ConflictResolution, +) -> MigrationConflict { + MigrationConflict { + domain: MigrationDomainId::StructuredMemory, + code: code.to_string(), + source_summary: format!("Legacy structured Memory record {}", record.session_id), + target_summary: target_summary.to_string(), + resolution, + } +} + +fn plan_file_memory(roots: &MigrationRoots) -> LegacyMigrationResult { + let source_root = source_file_memory_root(roots); + let source = collect_memory_files(&roots.legacy_home_root, &source_root)?; + let target_root = target_file_memory_root(roots); + let target = collect_memory_files(&roots.target_home_root, &target_root)?; + let target_by_path = target + .iter() + .map(|file| (file.relative.clone(), file.hash.clone())) + .collect::>(); + let mut occupied_paths = target_by_path.clone(); + let mut occupied_hashes = target + .iter() + .map(|file| file.hash.clone()) + .collect::>(); + let mut files = Vec::with_capacity(source.len()); + let mut conflicts = Vec::new(); + let logical_bytes = source.iter().map(|file| file.bytes).sum(); + + for file in source { + let mut target_relative = file.relative.clone(); + let mut action = FileMemoryAction::Import; + if occupied_hashes.contains(&file.hash) { + action = FileMemoryAction::Duplicate; + conflicts.push(file_conflict( + &file.relative, + &target_relative, + "file_memory_content_duplicate", + ConflictResolution::DuplicateSkipped, + )); + } else if occupied_paths.contains_key(&target_relative) { + if file.kind == MemoryWorkspaceFileKind::AdHocNote { + target_relative = remapped_note_path(&file.relative, &file.hash, &occupied_paths); + conflicts.push(file_conflict( + &file.relative, + &target_relative, + "file_memory_path_remapped", + ConflictResolution::SourceRemapped, + )); + } else { + action = FileMemoryAction::TargetWins; + conflicts.push(file_conflict( + &file.relative, + &target_relative, + "file_memory_path_conflict", + ConflictResolution::TargetWins, + )); + } + } + if action == FileMemoryAction::Import { + occupied_paths.insert(target_relative.clone(), file.hash.clone()); + occupied_hashes.insert(file.hash.clone()); + } + files.push(PlannedMemoryFile { + source_relative: file.relative, + target_relative, + source_path: file.path, + action, + expected_hash: file.hash, + }); + } + Ok(FileMemoryPlan { + files, + conflicts, + logical_bytes, + }) +} + +fn collect_memory_files( + boundary_root: &Path, + memory_root: &Path, +) -> LegacyMigrationResult> { + if !path_entry_exists(memory_root)? { + return Ok(Vec::new()); + } + validate_directory(boundary_root, memory_root)?; + let mut files = Vec::new(); + for name in ["MEMORY.md", "memory_summary.md"] { + let path = memory_root.join(name); + if path_entry_exists(&path)? { + push_memory_file(memory_root, &path, &mut files)?; + } + } + let extensions = memory_root.join("extensions"); + let ad_hoc = extensions.join("ad_hoc"); + let notes = ad_hoc.join("notes"); + for directory in [&extensions, &ad_hoc, ¬es] { + if path_entry_exists(directory)? { + validate_directory(memory_root, directory)?; + } else { + return finish_memory_files(files); + } + } + for entry in fs::read_dir(¬es).map_err(|error| io_error(¬es, error))? { + let entry = entry.map_err(|error| io_error(¬es, error))?; + let path = entry.path(); + let metadata = fs::symlink_metadata(&path).map_err(|error| io_error(&path, error))?; + if metadata.file_type().is_symlink() || is_reparse_point(&metadata) { + return Err(LegacyMigrationError::LinkedPath(path)); + } + if metadata.is_dir() { + return Err(LegacyMigrationError::InvalidRequest(format!( + "nested file Memory directories are unsupported: {}", + path.display() + ))); + } + if classify_memory_workspace_file( + path.strip_prefix(memory_root) + .map_err(|_| LegacyMigrationError::PathEscape(path.clone()))?, + ) + .is_some() + { + push_memory_file(memory_root, &path, &mut files)?; + } + } + finish_memory_files(files) +} + +fn finish_memory_files( + mut files: Vec, +) -> LegacyMigrationResult> { + files.sort_by(|left, right| left.relative.cmp(&right.relative)); + if files.len() > MAX_MEMORY_FILE_COUNT { + return Err(LegacyMigrationError::ResourceLimit(format!( + "file Memory contains more than {MAX_MEMORY_FILE_COUNT} owner-declared files" + ))); + } + let total = files.iter().map(|file| file.bytes).sum::(); + if total > MAX_MEMORY_TOTAL_BYTES { + return Err(LegacyMigrationError::ResourceLimit(format!( + "file Memory exceeds {MAX_MEMORY_TOTAL_BYTES} bytes" + ))); + } + Ok(files) +} + +fn push_memory_file( + memory_root: &Path, + path: &Path, + files: &mut Vec, +) -> LegacyMigrationResult<()> { + validate_regular_file(memory_root, path)?; + let relative = path + .strip_prefix(memory_root) + .map_err(|_| LegacyMigrationError::PathEscape(path.to_path_buf()))? + .to_path_buf(); + let kind = classify_memory_workspace_file(&relative).ok_or_else(|| { + LegacyMigrationError::InvalidRequest(format!( + "file is outside the current Memory owner contract: {}", + relative.display() + )) + })?; + let bytes = fs::metadata(path) + .map_err(|error| io_error(path, error))? + .len(); + if bytes > MAX_MEMORY_FILE_BYTES { + return Err(LegacyMigrationError::ResourceLimit(format!( + "file Memory item exceeds {MAX_MEMORY_FILE_BYTES} bytes: {}", + relative.display() + ))); + } + files.push(MemoryFileFact { + relative, + path: path.to_path_buf(), + hash: normalized_file_hash(path)?, + bytes, + kind, + }); + Ok(()) +} + +fn file_conflict( + source_relative: &Path, + target_relative: &Path, + code: &str, + resolution: ConflictResolution, +) -> MigrationConflict { + MigrationConflict { + domain: MigrationDomainId::FileMemory, + code: code.to_string(), + source_summary: format!( + "Legacy file Memory item {}", + relative_string(source_relative) + ), + target_summary: format!( + "Current file Memory path {}", + relative_string(target_relative) + ), + resolution, + } +} + +fn remapped_note_path(source: &Path, hash: &str, occupied: &BTreeMap) -> PathBuf { + let parent = source.parent().unwrap_or_else(|| Path::new("")); + let stem = source + .file_stem() + .and_then(|stem| stem.to_str()) + .unwrap_or("legacy-note"); + let short_hash = hash.get(..8).unwrap_or(hash); + for suffix in 0u32.. { + let name = if suffix == 0 { + format!("{stem}-from-bitfun-{short_hash}.md") + } else { + format!("{stem}-from-bitfun-{short_hash}-{suffix}.md") + }; + let candidate = parent.join(name); + if !occupied.contains_key(&candidate) { + return candidate; + } + } + unreachable!("u32 note remap namespace should not be exhausted") +} + +fn normalized_memory_content_hash(record: &MemoryRecord) -> String { + let mut hasher = Sha256::new(); + hasher.update(normalize_text(&record.raw_memory).as_bytes()); + hasher.update([0]); + hasher.update(normalize_text(&record.rollout_summary).as_bytes()); + hex::encode(hasher.finalize()) +} + +fn normalized_file_hash(path: &Path) -> LegacyMigrationResult { + let bytes = fs::read(path).map_err(|error| io_error(path, error))?; + let text = std::str::from_utf8(&bytes).map_err(|_| { + LegacyMigrationError::UnsupportedSource(format!( + "file Memory item is not UTF-8: {}", + path.display() + )) + })?; + let mut hasher = Sha256::new(); + hasher.update(normalize_text(text).as_bytes()); + Ok(hex::encode(hasher.finalize())) +} + +fn normalize_text(text: &str) -> String { + let normalized = text + .replace("\r\n", "\n") + .replace('\r', "\n") + .lines() + .map(str::trim_end) + .collect::>() + .join("\n"); + normalized.trim_end_matches('\n').to_string() +} + +fn memory_snapshot_digest(snapshot: &MemoryStoreSnapshot) -> LegacyMigrationResult { + let bytes = serde_json::to_vec(snapshot) + .map_err(|error| LegacyMigrationError::InvalidRequest(error.to_string()))?; + Ok(hex::encode(Sha256::digest(bytes))) +} + +fn read_consistent_scan_snapshot( + roots: &MigrationRoots, + boundary: &Path, + source: &Path, + label: &str, + role: MemoryDatabaseRole, +) -> LegacyMigrationResult { + validate_sqlite_family(boundary, source)?; + let scan_root = roots.migration_root().join("scan-snapshots"); + fs::create_dir_all(&scan_root).map_err(|error| io_error(&scan_root, error))?; + let snapshot = scan_root.join(format!("{label}-{}.sqlite", Uuid::new_v4())); + let result = snapshot_sqlite_read_only(source, &snapshot) + .and_then(|()| read_memory_snapshot(&snapshot, role)); + let cleanup = remove_sqlite_family(&snapshot); + if scan_root.exists() && is_directory_empty(&scan_root).unwrap_or(false) { + let _ = fs::remove_dir(&scan_root); + } + match (result, cleanup) { + (Ok(snapshot), Ok(())) => Ok(snapshot), + (Err(error), _) => Err(error), + (Ok(_), Err(error)) => Err(error), + } +} + +#[derive(Debug, Clone, Copy)] +enum MemoryDatabaseRole { + LegacySource, + CurrentTarget, + StagedCurrent, +} + +fn read_memory_snapshot( + path: &Path, + role: MemoryDatabaseRole, +) -> LegacyMigrationResult { + validate_sqlite(path)?; + let connection = Connection::open_with_flags( + path, + OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX, + ) + .map_err(|error| db_error(path, error))?; + read_memory_store_snapshot(&connection).map_err(|error| match role { + MemoryDatabaseRole::LegacySource => LegacyMigrationError::UnsupportedSource(format!( + "legacy structured Memory schema is not supported: {error}" + )), + MemoryDatabaseRole::CurrentTarget | MemoryDatabaseRole::StagedCurrent => { + LegacyMigrationError::InvalidRequest(format!( + "current structured Memory schema is invalid: {error}" + )) + } + }) +} + +fn verify_structured_target_state( + target: &Path, + manifest: &StructuredMemoryManifest, +) -> LegacyMigrationResult<()> { + if !manifest.target_existed { + if path_entry_exists(target)? { + return Err(LegacyMigrationError::InvalidRequest( + "structured Memory target appeared after staging".to_string(), + )); + } + return Ok(()); + } + if !path_entry_exists(target)? { + return Err(LegacyMigrationError::InvalidRequest( + "structured Memory target disappeared after staging".to_string(), + )); + } + let current = read_memory_snapshot(target, MemoryDatabaseRole::CurrentTarget)?; + if Some(memory_snapshot_digest(¤t)?) != manifest.target_digest { + return Err(LegacyMigrationError::InvalidRequest( + "structured Memory target changed after staging".to_string(), + )); + } + Ok(()) +} + +fn validate_sqlite_family(boundary: &Path, database: &Path) -> LegacyMigrationResult<()> { + validate_regular_file(boundary, database)?; + for sidecar in sqlite_sidecars(database) { + if path_entry_exists(&sidecar)? { + validate_regular_file(boundary, &sidecar)?; + } + } + Ok(()) +} + +fn sqlite_family_size(database: &Path) -> LegacyMigrationResult { + let mut total = fs::metadata(database) + .map_err(|error| io_error(database, error))? + .len(); + for sidecar in sqlite_sidecars(database) { + if path_entry_exists(&sidecar)? { + total = total.saturating_add( + fs::metadata(&sidecar) + .map_err(|error| io_error(&sidecar, error))? + .len(), + ); + } + } + Ok(total) +} + +fn finalize_sqlite_file(path: &Path) -> LegacyMigrationResult<()> { + let connection = Connection::open(path).map_err(|error| db_error(path, error))?; + connection + .execute_batch("PRAGMA wal_checkpoint(TRUNCATE); PRAGMA journal_mode = DELETE;") + .map_err(|error| db_error(path, error))?; + drop(connection); + remove_sqlite_sidecars(path) +} + +fn remove_sqlite_family(path: &Path) -> LegacyMigrationResult<()> { + remove_file_if_present(path)?; + remove_sqlite_sidecars(path) +} + +fn remove_sqlite_sidecars(path: &Path) -> LegacyMigrationResult<()> { + for sidecar in sqlite_sidecars(path) { + remove_file_if_present(&sidecar)?; + } + Ok(()) +} + +fn sqlite_sidecars(path: &Path) -> [PathBuf; 2] { + [ + PathBuf::from(format!("{}-wal", path.display())), + PathBuf::from(format!("{}-shm", path.display())), + ] +} + +fn remove_file_if_present(path: &Path) -> LegacyMigrationResult<()> { + match fs::remove_file(path) { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(io_error(path, error)), + } +} + +fn path_entry_exists(path: &Path) -> LegacyMigrationResult { + match fs::symlink_metadata(path) { + Ok(_) => Ok(true), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(error) => Err(io_error(path, error)), + } +} + +fn reset_directory(path: &Path) -> LegacyMigrationResult<()> { + if path_entry_exists(path)? { + let metadata = fs::symlink_metadata(path).map_err(|error| io_error(path, error))?; + if metadata.file_type().is_symlink() || is_reparse_point(&metadata) { + return Err(LegacyMigrationError::LinkedPath(path.to_path_buf())); + } + fs::remove_dir_all(path).map_err(|error| io_error(path, error))?; + } + fs::create_dir_all(path).map_err(|error| io_error(path, error)) +} + +fn validate_directory(boundary: &Path, path: &Path) -> LegacyMigrationResult<()> { + let metadata = fs::symlink_metadata(path).map_err(|error| io_error(path, error))?; + if metadata.file_type().is_symlink() || is_reparse_point(&metadata) { + return Err(LegacyMigrationError::LinkedPath(path.to_path_buf())); + } + if !metadata.is_dir() { + return Err(LegacyMigrationError::InvalidRequest(format!( + "expected a directory at {}", + path.display() + ))); + } + let canonical_boundary = + fs::canonicalize(boundary).map_err(|error| io_error(boundary, error))?; + let canonical_path = fs::canonicalize(path).map_err(|error| io_error(path, error))?; + if !canonical_path.starts_with(canonical_boundary) { + return Err(LegacyMigrationError::PathEscape(path.to_path_buf())); + } + Ok(()) +} + +fn ensure_safe_target_parent( + roots: &MigrationRoots, + target_root: &Path, + target: &Path, +) -> LegacyMigrationResult<()> { + if !path_entry_exists(&roots.target_home_root)? { + fs::create_dir_all(&roots.target_home_root) + .map_err(|error| io_error(&roots.target_home_root, error))?; + } + validate_directory(&roots.target_home_root, &roots.target_home_root)?; + if !path_entry_exists(target_root)? { + fs::create_dir(target_root).map_err(|error| io_error(target_root, error))?; + } + validate_directory(&roots.target_home_root, target_root)?; + let parent = target.parent().ok_or_else(|| { + LegacyMigrationError::InvalidRequest(format!( + "file Memory target has no parent: {}", + target.display() + )) + })?; + let relative_parent = parent + .strip_prefix(target_root) + .map_err(|_| LegacyMigrationError::PathEscape(parent.to_path_buf()))?; + let mut current = target_root.to_path_buf(); + for component in relative_parent.components() { + let Component::Normal(component) = component else { + return Err(LegacyMigrationError::PathEscape(parent.to_path_buf())); + }; + current.push(component); + if path_entry_exists(¤t)? { + validate_directory(target_root, ¤t)?; + } else { + fs::create_dir(¤t).map_err(|error| io_error(¤t, error))?; + } + } + Ok(()) +} + +fn validated_memory_relative_path(raw: &str) -> LegacyMigrationResult { + let relative = PathBuf::from(raw); + if classify_memory_workspace_file(&relative).is_none() + || relative + .components() + .any(|component| !matches!(component, Component::Normal(_))) + { + return Err(LegacyMigrationError::PathEscape(relative)); + } + Ok(relative) +} + +fn imported_file_entries( + manifest: &FileMemoryManifest, +) -> impl DoubleEndedIterator { + manifest + .entries + .iter() + .enumerate() + .filter(|(_, entry)| entry.action == FileMemoryAction::Import) +} + +fn prune_empty_memory_parents( + target_root: &Path, + mut parent: Option<&Path>, +) -> LegacyMigrationResult<()> { + while let Some(path) = parent { + if path == target_root || !path.starts_with(target_root) || !is_directory_empty(path)? { + break; + } + fs::remove_dir(path).map_err(|error| io_error(path, error))?; + parent = path.parent(); + } + Ok(()) +} + +fn is_directory_empty(path: &Path) -> LegacyMigrationResult { + Ok(fs::read_dir(path) + .map_err(|error| io_error(path, error))? + .next() + .is_none()) +} + +#[cfg(windows)] +fn is_reparse_point(metadata: &fs::Metadata) -> bool { + use std::os::windows::fs::MetadataExt; + const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0400; + metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 +} + +#[cfg(not(windows))] +fn is_reparse_point(_metadata: &fs::Metadata) -> bool { + false +} + +fn empty_domain_scan( + domain: MigrationDomainId, + code: &str, + source_schema: &str, + target_schema: &str, + detail: &str, +) -> DomainScan { + DomainScan { + finding: ScanFinding { + domain, + code: code.to_string(), + source_schema: Some(source_schema.to_string()), + migratable: true, + detail: detail.to_string(), + ..ScanFinding::default() + }, + conflicts: Vec::new(), + target_schema: Some(target_schema.to_string()), + dependencies: Vec::new(), + } +} + +fn source_structured_memory_path(roots: &MigrationRoots) -> PathBuf { + roots.legacy_user_root.join(STRUCTURED_MEMORY_RELATIVE_PATH) +} + +fn target_structured_memory_path(roots: &MigrationRoots) -> PathBuf { + roots.target_user_root.join(STRUCTURED_MEMORY_RELATIVE_PATH) +} + +fn source_file_memory_root(roots: &MigrationRoots) -> PathBuf { + roots.legacy_home_root.join("memories") +} + +fn target_file_memory_root(roots: &MigrationRoots) -> PathBuf { + roots.target_home_root.join("memories") +} + +fn structured_manifest_path(context: &DomainContext<'_>) -> PathBuf { + stage_domain_dir(context, "structured-memory").join("manifest.json") +} + +fn file_manifest_path(context: &DomainContext<'_>) -> PathBuf { + stage_domain_dir(context, "file-memory").join("manifest.json") +} + +fn structured_commit_receipt_path(context: &DomainContext<'_>) -> PathBuf { + backup_domain_dir(context, "structured-memory").join("commit-receipt.json") +} + +fn file_commit_receipt_path(context: &DomainContext<'_>, entry_index: usize) -> PathBuf { + backup_domain_dir(context, "file-memory") + .join("commit-receipts") + .join(format!("{entry_index:04}.json")) +} + +fn read_structured_manifest( + context: &DomainContext<'_>, +) -> LegacyMigrationResult { + read_bounded_json( + &context.layout.stage_root(), + &structured_manifest_path(context), + ) +} + +fn read_file_manifest(context: &DomainContext<'_>) -> LegacyMigrationResult { + read_bounded_json(&context.layout.stage_root(), &file_manifest_path(context)) +} + +fn read_structured_commit_receipt( + context: &DomainContext<'_>, +) -> LegacyMigrationResult> { + read_optional_bounded_json( + &context.layout.backup_root(), + &structured_commit_receipt_path(context), + ) +} + +fn read_file_commit_receipt( + context: &DomainContext<'_>, + entry_index: usize, +) -> LegacyMigrationResult> { + read_optional_bounded_json( + &context.layout.backup_root(), + &file_commit_receipt_path(context, entry_index), + ) +} + +fn relative_string(path: &Path) -> String { + path.to_string_lossy().replace('\\', "/") +} + +fn db_error(path: &Path, error: rusqlite::Error) -> LegacyMigrationError { + LegacyMigrationError::InvalidRequest(format!( + "SQLite operation failed at {}: {error}", + path.display() + )) +} + +fn owner_error(error: impl std::fmt::Display) -> LegacyMigrationError { + LegacyMigrationError::InvalidRequest(format!("Memory persistence owner rejected data: {error}")) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::legacy_migration::adapters_for_groups; + use openbitfun_legacy_migration::{ + probe_legacy_source, CancellationToken, CrashInjector, CrashPoint, MigrationEngine, + NoCrashInjection, ProbeLimits, + }; + use openbitfun_product_domains::legacy_migration::{ + MigrationGroupId, MigrationRunStatus, MigrationSelection, + }; + use std::io::Read; + use std::sync::atomic::{AtomicBool, Ordering}; + + struct CrashOnce { + point: CrashPoint, + fired: AtomicBool, + } + + impl CrashInjector for CrashOnce { + fn should_crash(&self, point: CrashPoint) -> bool { + point == self.point && !self.fired.swap(true, Ordering::AcqRel) + } + } + + #[test] + fn structured_memory_uses_wal_target_priority_hash_dedup_and_owner_validation() { + let temp = test_tempdir("structured-memory"); + let roots = fixture_roots(temp.path()); + copy_fixture(&roots); + let source_connection = materialize_source_memory(&roots, true); + let target_path = target_structured_memory_path(&roots); + fs::create_dir_all(target_path.parent().unwrap()).unwrap(); + let target_connection = Connection::open(&target_path).unwrap(); + initialize_memory_schema(&target_connection).unwrap(); + upsert_memory_record( + &target_connection, + &memory_record( + "session-1", + "Current target fact.", + "Current target summary.", + ), + true, + ) + .unwrap(); + upsert_memory_record( + &target_connection, + &memory_record( + "target-duplicate", + "Repeated durable fact.\n", + "Repeated summary.", + ), + true, + ) + .unwrap(); + target_connection + .execute( + "INSERT INTO jobs (kind, job_key, status, retry_remaining) VALUES ('memory_stage1', 'target-job', 'done', 3)", + [], + ) + .unwrap(); + drop(target_connection); + + let source_hash = hash_source_roots(&roots); + let selection = memory_selection(); + let source = probe_legacy_source(&roots, ProbeLimits::default()) + .unwrap() + .unwrap(); + let engine = MigrationEngine::new(roots.clone(), adapters_for_groups(&selection)).unwrap(); + let plan = engine + .plan(&source, selection.clone(), &CancellationToken::default()) + .unwrap(); + let structured_conflicts = plan + .conflicts + .iter() + .filter(|conflict| conflict.domain == MigrationDomainId::StructuredMemory) + .collect::>(); + assert!(structured_conflicts + .iter() + .any(|conflict| conflict.code == "structured_memory_id_conflict")); + assert!(structured_conflicts + .iter() + .any(|conflict| conflict.code == "structured_memory_content_duplicate")); + assert!(structured_conflicts.iter().all(|conflict| { + !conflict + .source_summary + .contains("Synthetic migration fixture memory") + && !conflict.target_summary.contains("Current target fact") + })); + + let report = engine + .execute(&plan, &CancellationToken::default(), &NoCrashInjection) + .unwrap(); + assert!(matches!( + report.status, + MigrationRunStatus::Completed | MigrationRunStatus::CompletedWithWarnings + )); + let target_connection = Connection::open(&target_path).unwrap(); + let snapshot = read_memory_store_snapshot(&target_connection).unwrap(); + assert_eq!(snapshot.records.len(), 4); + assert_eq!(snapshot.jobs.len(), 1); + assert_eq!(snapshot.jobs[0].job_key, "target-job"); + assert_eq!( + snapshot + .records + .iter() + .find(|record| record.session_id == "session-1") + .unwrap() + .raw_memory, + "Current target fact." + ); + assert!(snapshot + .records + .iter() + .any(|record| record.session_id == "session-memory-import")); + assert!(snapshot + .records + .iter() + .any(|record| record.session_id == "session-memory-wal")); + assert!(!snapshot + .records + .iter() + .any(|record| record.session_id == "session-memory-duplicate")); + drop(target_connection); + assert!(!PathBuf::from(format!("{}-wal", target_path.display())).exists()); + assert!(!PathBuf::from(format!("{}-shm", target_path.display())).exists()); + let report_json = serde_json::to_string(&report).unwrap(); + for private_text in [ + "Synthetic migration fixture memory", + "Current target fact", + "Repeated durable fact", + "WAL-only durable fact", + ] { + assert!(!report_json.contains(private_text)); + } + assert_eq!(hash_source_roots(&roots), source_hash); + + let repeated_source = probe_legacy_source(&roots, ProbeLimits::default()) + .unwrap() + .unwrap(); + let repeated_plan = engine + .plan(&repeated_source, selection, &CancellationToken::default()) + .unwrap(); + engine + .execute( + &repeated_plan, + &CancellationToken::default(), + &NoCrashInjection, + ) + .unwrap(); + let target_connection = Connection::open(&target_path).unwrap(); + assert_eq!( + read_memory_store_snapshot(&target_connection) + .unwrap() + .records + .len(), + 4 + ); + assert_eq!(hash_source_roots(&roots), source_hash); + drop(source_connection); + } + + #[test] + fn structured_memory_recovers_after_commit_before_journal() { + let temp = test_tempdir("structured-memory-crash"); + let roots = fixture_roots(temp.path()); + copy_fixture(&roots); + let source_connection = materialize_source_memory(&roots, false); + let source_hash = hash_source_roots(&roots); + let selection = memory_selection(); + let source = probe_legacy_source(&roots, ProbeLimits::default()) + .unwrap() + .unwrap(); + let engine = MigrationEngine::new(roots.clone(), adapters_for_groups(&selection)).unwrap(); + let plan = engine + .plan(&source, selection, &CancellationToken::default()) + .unwrap(); + let crash = CrashOnce { + point: CrashPoint::AfterCommit(MigrationDomainId::StructuredMemory), + fired: AtomicBool::new(false), + }; + assert!(matches!( + engine.execute(&plan, &CancellationToken::default(), &crash), + Err(LegacyMigrationError::InjectedCrash( + CrashPoint::AfterCommit(MigrationDomainId::StructuredMemory) + )) + )); + let report = engine + .execute(&plan, &CancellationToken::default(), &NoCrashInjection) + .unwrap(); + assert!(matches!( + report.status, + MigrationRunStatus::Completed | MigrationRunStatus::CompletedWithWarnings + )); + let target = Connection::open(target_structured_memory_path(&roots)).unwrap(); + let snapshot = read_memory_store_snapshot(&target).unwrap(); + assert_eq!(snapshot.records.len(), 1); + assert!(snapshot.jobs.is_empty()); + assert_eq!(hash_source_roots(&roots), source_hash); + drop(source_connection); + } + + #[test] + fn structured_memory_rollback_preserves_post_commit_target_changes() { + let temp = test_tempdir("structured-memory-rollback-race"); + let roots = fixture_roots(temp.path()); + copy_fixture(&roots); + let source_connection = materialize_source_memory(&roots, false); + let target_path = target_structured_memory_path(&roots); + fs::create_dir_all(target_path.parent().unwrap()).unwrap(); + let target = Connection::open(&target_path).unwrap(); + initialize_memory_schema(&target).unwrap(); + upsert_memory_record( + &target, + &memory_record( + "original-target", + "Original target fact.", + "Original target summary.", + ), + true, + ) + .unwrap(); + drop(target); + + let selection = memory_selection(); + let source = probe_legacy_source(&roots, ProbeLimits::default()) + .unwrap() + .unwrap(); + let engine = MigrationEngine::new(roots.clone(), adapters_for_groups(&selection)).unwrap(); + let plan = engine + .plan(&source, selection, &CancellationToken::default()) + .unwrap(); + let crash = CrashOnce { + point: CrashPoint::AfterCommit(MigrationDomainId::StructuredMemory), + fired: AtomicBool::new(false), + }; + assert!(matches!( + engine.execute(&plan, &CancellationToken::default(), &crash), + Err(LegacyMigrationError::InjectedCrash( + CrashPoint::AfterCommit(MigrationDomainId::StructuredMemory) + )) + )); + + let target = Connection::open(&target_path).unwrap(); + upsert_memory_record( + &target, + &memory_record( + "post-commit-target", + "Post-commit target fact.", + "Post-commit target summary.", + ), + true, + ) + .unwrap(); + drop(target); + + let error = engine + .execute(&plan, &CancellationToken::default(), &NoCrashInjection) + .unwrap_err(); + assert!(error + .to_string() + .contains("structured Memory target changed after staging")); + let target = Connection::open(&target_path).unwrap(); + let snapshot = read_memory_store_snapshot(&target).unwrap(); + assert!(snapshot + .records + .iter() + .any(|record| record.session_id == "original-target")); + assert!(snapshot + .records + .iter() + .any(|record| record.session_id == "post-commit-target")); + assert!(snapshot + .records + .iter() + .any(|record| record.session_id == "session-1")); + drop(source_connection); + } + + #[test] + fn structured_memory_does_not_remove_a_target_that_appears_after_staging() { + let temp = test_tempdir("structured-target-race"); + let roots = fixture_roots(temp.path()); + copy_fixture(&roots); + let source_connection = materialize_source_memory(&roots, false); + let source_hash = hash_source_roots(&roots); + let selection = memory_selection(); + let source = probe_legacy_source(&roots, ProbeLimits::default()) + .unwrap() + .unwrap(); + let engine = MigrationEngine::new(roots.clone(), adapters_for_groups(&selection)).unwrap(); + let plan = engine + .plan(&source, selection, &CancellationToken::default()) + .unwrap(); + let crash = CrashOnce { + point: CrashPoint::AfterStageValidated(MigrationDomainId::StructuredMemory), + fired: AtomicBool::new(false), + }; + assert!(matches!( + engine.execute(&plan, &CancellationToken::default(), &crash), + Err(LegacyMigrationError::InjectedCrash( + CrashPoint::AfterStageValidated(MigrationDomainId::StructuredMemory) + )) + )); + + let target_path = target_structured_memory_path(&roots); + fs::create_dir_all(target_path.parent().unwrap()).unwrap(); + let target = Connection::open(&target_path).unwrap(); + initialize_memory_schema(&target).unwrap(); + upsert_memory_record( + &target, + &memory_record("new-target", "New target fact.", "New target summary."), + true, + ) + .unwrap(); + drop(target); + + let error = engine + .execute(&plan, &CancellationToken::default(), &NoCrashInjection) + .unwrap_err(); + assert!(error + .to_string() + .contains("structured Memory target appeared after staging")); + let target = Connection::open(&target_path).unwrap(); + let snapshot = read_memory_store_snapshot(&target).unwrap(); + assert_eq!(snapshot.records.len(), 1); + assert_eq!(snapshot.records[0].session_id, "new-target"); + assert_eq!(hash_source_roots(&roots), source_hash); + drop(source_connection); + } + + #[test] + fn file_memory_preserves_target_remaps_notes_and_excludes_generated_files() { + let temp = test_tempdir("file-memory"); + let roots = fixture_roots(temp.path()); + copy_fixture(&roots); + let source_connection = materialize_source_memory(&roots, false); + let source_memory = source_file_memory_root(&roots); + let source_notes = source_memory.join("extensions/ad_hoc/notes"); + fs::create_dir_all(&source_notes).unwrap(); + fs::write( + source_notes.join("collision.md"), + "Legacy collision note.\r\n", + ) + .unwrap(); + fs::write(source_notes.join("duplicate.md"), "Duplicate note.\n").unwrap(); + fs::write( + source_memory.join("raw_memories.md"), + "Generated raw memory.", + ) + .unwrap(); + fs::write( + source_memory.join("phase2_workspace_diff.md"), + "Temporary diff.", + ) + .unwrap(); + fs::create_dir_all(source_memory.join("rollout_summaries")).unwrap(); + fs::write( + source_memory.join("rollout_summaries/generated.md"), + "Generated rollout summary.", + ) + .unwrap(); + fs::write( + source_memory.join("extensions/ad_hoc/instructions.md"), + "Generated owner instructions.", + ) + .unwrap(); + + let target_memory = target_file_memory_root(&roots); + let target_notes = target_memory.join("extensions/ad_hoc/notes"); + fs::create_dir_all(&target_notes).unwrap(); + fs::write(target_memory.join("MEMORY.md"), "Current target Memory.\n").unwrap(); + fs::write( + target_notes.join("collision.md"), + "Current collision note.\n", + ) + .unwrap(); + fs::write(target_notes.join("existing.md"), "Duplicate note.\r\n").unwrap(); + + let source_hash = hash_source_roots(&roots); + let selection = memory_selection(); + let source = probe_legacy_source(&roots, ProbeLimits::default()) + .unwrap() + .unwrap(); + let engine = MigrationEngine::new(roots.clone(), adapters_for_groups(&selection)).unwrap(); + let plan = engine + .plan(&source, selection, &CancellationToken::default()) + .unwrap(); + let file_conflicts = plan + .conflicts + .iter() + .filter(|conflict| conflict.domain == MigrationDomainId::FileMemory) + .collect::>(); + assert!(file_conflicts + .iter() + .any(|conflict| conflict.code == "file_memory_path_conflict")); + assert!(file_conflicts + .iter() + .any(|conflict| conflict.code == "file_memory_path_remapped")); + assert!(file_conflicts + .iter() + .any(|conflict| conflict.code == "file_memory_content_duplicate")); + + let report = engine + .execute(&plan, &CancellationToken::default(), &NoCrashInjection) + .unwrap(); + assert_eq!( + fs::read_to_string(target_memory.join("MEMORY.md")).unwrap(), + "Current target Memory.\n" + ); + assert_eq!( + fs::read_to_string(target_notes.join("collision.md")).unwrap(), + "Current collision note.\n" + ); + let remapped = fs::read_dir(&target_notes) + .unwrap() + .map(|entry| entry.unwrap().path()) + .find(|path| { + path.file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.starts_with("collision-from-bitfun-")) + }) + .expect("colliding ad-hoc note should be deterministically remapped"); + assert_eq!( + fs::read_to_string(remapped).unwrap(), + "Legacy collision note.\r\n" + ); + assert!(!target_notes.join("duplicate.md").exists()); + assert!(!target_memory.join("raw_memories.md").exists()); + assert!(!target_memory.join("phase2_workspace_diff.md").exists()); + assert!(!target_memory.join("rollout_summaries").exists()); + assert!(!target_memory + .join("extensions/ad_hoc/instructions.md") + .exists()); + let report_json = serde_json::to_string(&report).unwrap(); + for private_text in [ + "Synthetic memory fixture", + "Legacy collision note", + "Current target Memory", + "Duplicate note", + ] { + assert!(!report_json.contains(private_text)); + } + assert_eq!(hash_source_roots(&roots), source_hash); + drop(source_connection); + } + + #[test] + fn file_memory_recovers_after_commit_before_journal_without_duplicates() { + let temp = test_tempdir("file-memory-crash"); + let roots = fixture_roots(temp.path()); + copy_fixture(&roots); + let source_connection = materialize_source_memory(&roots, false); + let source_hash = hash_source_roots(&roots); + let selection = memory_selection(); + let source = probe_legacy_source(&roots, ProbeLimits::default()) + .unwrap() + .unwrap(); + let engine = MigrationEngine::new(roots.clone(), adapters_for_groups(&selection)).unwrap(); + let plan = engine + .plan(&source, selection, &CancellationToken::default()) + .unwrap(); + let crash = CrashOnce { + point: CrashPoint::AfterCommit(MigrationDomainId::FileMemory), + fired: AtomicBool::new(false), + }; + assert!(matches!( + engine.execute(&plan, &CancellationToken::default(), &crash), + Err(LegacyMigrationError::InjectedCrash( + CrashPoint::AfterCommit(MigrationDomainId::FileMemory) + )) + )); + let target_index = target_file_memory_root(&roots).join("MEMORY.md"); + let committed = fs::read(&target_index).unwrap(); + + engine + .execute(&plan, &CancellationToken::default(), &NoCrashInjection) + .unwrap(); + assert_eq!(fs::read(&target_index).unwrap(), committed); + assert_eq!( + collect_memory_files(&roots.target_home_root, &target_file_memory_root(&roots)) + .unwrap() + .len(), + 1 + ); + assert_eq!(hash_source_roots(&roots), source_hash); + drop(source_connection); + } + + #[test] + fn file_memory_does_not_claim_an_equal_target_that_appears_after_staging() { + let temp = test_tempdir("file-target-race"); + let roots = fixture_roots(temp.path()); + copy_fixture(&roots); + let source_connection = materialize_source_memory(&roots, false); + let source_hash = hash_source_roots(&roots); + let selection = memory_selection(); + let source = probe_legacy_source(&roots, ProbeLimits::default()) + .unwrap() + .unwrap(); + let engine = MigrationEngine::new(roots.clone(), adapters_for_groups(&selection)).unwrap(); + let plan = engine + .plan(&source, selection, &CancellationToken::default()) + .unwrap(); + let crash = CrashOnce { + point: CrashPoint::AfterStageValidated(MigrationDomainId::FileMemory), + fired: AtomicBool::new(false), + }; + assert!(matches!( + engine.execute(&plan, &CancellationToken::default(), &crash), + Err(LegacyMigrationError::InjectedCrash( + CrashPoint::AfterStageValidated(MigrationDomainId::FileMemory) + )) + )); + + let target = target_file_memory_root(&roots).join("MEMORY.md"); + fs::create_dir_all(target.parent().unwrap()).unwrap(); + let source_bytes = fs::read(source_file_memory_root(&roots).join("MEMORY.md")).unwrap(); + fs::write(&target, &source_bytes).unwrap(); + let error = engine + .execute(&plan, &CancellationToken::default(), &NoCrashInjection) + .unwrap_err(); + assert!(error + .to_string() + .contains("file Memory target changed after staging")); + assert_eq!(fs::read(&target).unwrap(), source_bytes); + assert_eq!(hash_source_roots(&roots), source_hash); + drop(source_connection); + } + + #[test] + fn invalid_structured_schema_and_cancellation_leave_source_unchanged() { + let temp = test_tempdir("memory-source-failure"); + let roots = fixture_roots(temp.path()); + copy_fixture(&roots); + let invalid_path = source_structured_memory_path(&roots); + fs::create_dir_all(invalid_path.parent().unwrap()).unwrap(); + let invalid = Connection::open(&invalid_path).unwrap(); + invalid + .execute_batch( + "CREATE TABLE memories (id TEXT PRIMARY KEY, content TEXT NOT NULL, created_at INTEGER NOT NULL);", + ) + .unwrap(); + drop(invalid); + let source_hash = hash_source_roots(&roots); + let selection = memory_selection(); + let source = probe_legacy_source(&roots, ProbeLimits::default()) + .unwrap() + .unwrap(); + let engine = MigrationEngine::new(roots.clone(), adapters_for_groups(&selection)).unwrap(); + assert!(engine + .plan(&source, selection.clone(), &CancellationToken::default()) + .unwrap_err() + .to_string() + .contains("legacy structured Memory schema is not supported")); + assert_eq!(hash_source_roots(&roots), source_hash); + + fs::remove_file(&invalid_path).unwrap(); + let source_connection = materialize_source_memory(&roots, false); + let source_hash = hash_source_roots(&roots); + let source = probe_legacy_source(&roots, ProbeLimits::default()) + .unwrap() + .unwrap(); + let cancellation = CancellationToken::default(); + cancellation.cancel(); + assert!(matches!( + engine.plan(&source, selection, &cancellation), + Err(LegacyMigrationError::Cancelled) + )); + assert_eq!(hash_source_roots(&roots), source_hash); + drop(source_connection); + } + + #[test] + fn file_memory_manifest_rejects_path_traversal() { + assert!(matches!( + validated_memory_relative_path("../MEMORY.md"), + Err(LegacyMigrationError::PathEscape(_)) + )); + assert!(matches!( + validated_memory_relative_path("extensions/ad_hoc/notes/nested/note.md"), + Err(LegacyMigrationError::PathEscape(_)) + )); + } + + #[test] + fn content_normalization_preserves_leading_markdown_indentation() { + assert_eq!(normalize_text("fact \r\n\r\n"), "fact"); + assert_ne!(normalize_text(" code\n"), normalize_text("code\n")); + } + + #[cfg(unix)] + #[test] + fn file_memory_rejects_symlinked_owner_file() { + use std::os::unix::fs::symlink; + + let temp = test_tempdir("file-memory-symlink"); + let roots = fixture_roots(temp.path()); + fs::create_dir_all(&roots.legacy_home_root).unwrap(); + let memory_root = source_file_memory_root(&roots); + fs::create_dir_all(&memory_root).unwrap(); + let outside = roots.legacy_home_root.join("outside.md"); + fs::write(&outside, "outside").unwrap(); + symlink(&outside, memory_root.join("MEMORY.md")).unwrap(); + + assert!(matches!( + plan_file_memory(&roots), + Err(LegacyMigrationError::LinkedPath(_)) + )); + } + + #[cfg(windows)] + #[test] + fn file_memory_rejects_reparse_owner_file_when_supported() { + use std::os::windows::fs::symlink_file; + + let temp = test_tempdir("file-memory-reparse"); + let roots = fixture_roots(temp.path()); + fs::create_dir_all(&roots.legacy_home_root).unwrap(); + let memory_root = source_file_memory_root(&roots); + fs::create_dir_all(&memory_root).unwrap(); + let outside = roots.legacy_home_root.join("outside.md"); + fs::write(&outside, "outside").unwrap(); + if symlink_file(&outside, memory_root.join("MEMORY.md")).is_err() { + return; + } + + assert!(matches!( + plan_file_memory(&roots), + Err(LegacyMigrationError::LinkedPath(_)) + )); + } + + fn memory_selection() -> MigrationSelection { + MigrationSelection { + groups: BTreeSet::from([MigrationGroupId::Memory]), + } + } + + fn memory_record(session_id: &str, raw_memory: &str, summary: &str) -> MemoryRecord { + MemoryRecord { + session_id: session_id.to_string(), + workspace_path: "C:\\fixture-workspace".to_string(), + rollout_path: format!("C:\\fixture-workspace\\sessions\\{session_id}"), + source_updated_at_unix_secs: 10, + raw_memory: raw_memory.to_string(), + rollout_summary: summary.to_string(), + rollout_slug: Some(format!("memory-{session_id}")), + generated_at_unix_secs: 11, + usage_count: 0, + last_usage_unix_secs: None, + selected_for_phase2: 0, + selected_for_phase2_source_updated_at: None, + } + } + + fn materialize_source_memory(roots: &MigrationRoots, with_wal_rows: bool) -> Connection { + let sql_path = roots.legacy_user_root.join("data/memories/memories.sql"); + let database_path = source_structured_memory_path(roots); + fs::create_dir_all(database_path.parent().unwrap()).unwrap(); + let connection = Connection::open(&database_path).unwrap(); + connection + .execute_batch(&fs::read_to_string(sql_path).unwrap()) + .unwrap(); + if with_wal_rows { + connection + .pragma_update(None, "journal_mode", "WAL") + .unwrap(); + connection + .pragma_update(None, "wal_autocheckpoint", 0) + .unwrap(); + for record in [ + memory_record( + "session-memory-import", + "Imported durable fact.", + "Imported summary.", + ), + memory_record( + "session-memory-duplicate", + "Repeated durable fact.\r\n", + "Repeated summary. ", + ), + memory_record( + "session-memory-wal", + "WAL-only durable fact.", + "WAL-only summary.", + ), + ] { + upsert_memory_record(&connection, &record, true).unwrap(); + } + assert!(PathBuf::from(format!("{}-wal", database_path.display())).exists()); + } + connection + } + + fn fixture_roots(root: &Path) -> MigrationRoots { + let legacy_user_root = root.join("legacy-user"); + MigrationRoots { + legacy_skills_root: legacy_user_root.join("skills"), + legacy_user_root, + legacy_home_root: root.join("legacy-home"), + 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-skills"), + target_ssh_root: root.join("target-ssh"), + } + } + + fn copy_fixture(roots: &MigrationRoots) { + let fixture = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../services/legacy-migration/tests/fixtures/v0.2.19"); + copy_directory(&fixture.join("user-root"), &roots.legacy_user_root).unwrap(); + copy_directory(&fixture.join("home"), &roots.legacy_home_root).unwrap(); + copy_directory(&fixture.join("ssh"), &roots.legacy_ssh_root).unwrap(); + } + + fn copy_directory(source: &Path, target: &Path) -> std::io::Result<()> { + fs::create_dir_all(target)?; + for entry in fs::read_dir(source)? { + let entry = entry?; + let source_path = entry.path(); + let target_path = target.join(entry.file_name()); + if entry.file_type()?.is_dir() { + copy_directory(&source_path, &target_path)?; + } else { + fs::copy(source_path, target_path)?; + } + } + Ok(()) + } + + fn hash_source_roots(roots: &MigrationRoots) -> String { + let mut entries = Vec::new(); + for root in [ + &roots.legacy_user_root, + &roots.legacy_home_root, + &roots.legacy_ssh_root, + ] { + collect_source_files(root, root, &mut entries); + } + entries.sort_by(|left, right| left.0.cmp(&right.0)); + let mut hasher = Sha256::new(); + for (path, bytes) in entries { + hasher.update(path.to_string_lossy().replace('\\', "/").as_bytes()); + hasher.update([0]); + hasher.update(bytes); + hasher.update([0]); + } + hex::encode(hasher.finalize()) + } + + fn collect_source_files(root: &Path, path: &Path, entries: &mut Vec<(PathBuf, Vec)>) { + if path.is_file() { + if path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.ends_with("-shm")) + { + return; + } + let mut bytes = Vec::new(); + fs::File::open(path) + .unwrap() + .read_to_end(&mut bytes) + .unwrap(); + entries.push((path.strip_prefix(root).unwrap().to_path_buf(), bytes)); + return; + } + let mut children = fs::read_dir(path) + .unwrap() + .map(|entry| entry.unwrap().path()) + .collect::>(); + children.sort(); + for child in children { + collect_source_files(root, &child, entries); + } + } + + fn test_tempdir(label: &str) -> tempfile::TempDir { + let root = std::env::var_os("OPENBITFUN_TEST_TMPDIR") + .map(PathBuf::from) + .unwrap_or_else(std::env::temp_dir); + fs::create_dir_all(&root).unwrap(); + tempfile::Builder::new() + .prefix(&format!("obfm-{}-", &label[..label.len().min(4)])) + .tempdir_in(root) + .unwrap() + } +} diff --git a/src/crates/assembly/core/src/legacy_migration/mod.rs b/src/crates/assembly/core/src/legacy_migration/mod.rs new file mode 100644 index 0000000000..4785cec44a --- /dev/null +++ b/src/crates/assembly/core/src/legacy_migration/mod.rs @@ -0,0 +1,121 @@ +//! Product-owned legacy BitFun domain adapters. +//! +//! The generic service crate owns offline orchestration and filesystem safety; +//! this module binds each legacy reader and converter to the current product +//! domain model without moving those owners into the service layer. + +mod agent_coordination; +mod common; +mod extensions; +mod memory; +mod remote_connect; +mod remote_ssh; +mod settings; +mod workspace_sessions; + +use openbitfun_legacy_migration::{ + DomainContext, DomainScan, LegacyDomainAdapter, LegacyMigrationResult, MigrationRoots, +}; +use openbitfun_product_domains::legacy_migration::{ + FindingSeverity, MigrationDomainId, MigrationDomainResult, MigrationDomainState, + MigrationGroupId, MigrationSelection, ScanFinding, +}; + +pub fn adapters_for_groups(selection: &MigrationSelection) -> Vec> { + let selected = selection.expanded_domains(); + let mut adapters: Vec> = Vec::new(); + if selected.contains(&MigrationDomainId::Settings) { + adapters.push(Box::new(settings::SettingsAdapter)); + } + if selected.contains(&MigrationDomainId::Credentials) { + adapters.push(Box::new(settings::CredentialsAdapter)); + } + if selected.contains(&MigrationDomainId::Skills) { + adapters.push(Box::new(extensions::SkillsAdapter)); + } + if selected.contains(&MigrationDomainId::Miniapps) { + adapters.push(Box::new(extensions::MiniappsAdapter)); + } + if selected.contains(&MigrationDomainId::Agents) { + adapters.push(Box::new(extensions::AgentsAdapter)); + } + if selected.contains(&MigrationDomainId::WorkspaceSessions) { + adapters.push(Box::new(workspace_sessions::WorkspaceSessionsAdapter)); + } + if selected.contains(&MigrationDomainId::AgentCoordination) { + adapters.push(Box::new(agent_coordination::AgentCoordinationAdapter)); + } + if selected.contains(&MigrationDomainId::StructuredMemory) { + adapters.push(Box::new(memory::StructuredMemoryAdapter)); + } + if selected.contains(&MigrationDomainId::FileMemory) { + adapters.push(Box::new(memory::FileMemoryAdapter)); + } + if selected.contains(&MigrationDomainId::RemoteConnectDevices) { + adapters.push(Box::new(remote_connect::RemoteConnectAdapter)); + } + if selected.contains(&MigrationDomainId::RemoteSsh) { + adapters.push(Box::new(remote_ssh::RemoteSshAdapter)); + } + if selected.contains(&MigrationDomainId::CrossReferenceRepair) { + adapters.push(Box::new(CrossReferenceAdapter)); + } + adapters +} + +struct CrossReferenceAdapter; + +impl LegacyDomainAdapter for CrossReferenceAdapter { + fn domain(&self) -> MigrationDomainId { + MigrationDomainId::CrossReferenceRepair + } + + fn scan(&self, _roots: &MigrationRoots) -> LegacyMigrationResult { + Ok(DomainScan { + finding: ScanFinding { + domain: self.domain(), + code: "cross_reference_validation_enabled".to_string(), + severity: FindingSeverity::Info, + migratable: true, + detail: "Selected owner adapters will be checked after their atomic commits." + .to_string(), + ..ScanFinding::default() + }, + conflicts: Vec::new(), + target_schema: Some("openbitfun.cross-references.current".to_string()), + dependencies: Vec::new(), + }) + } + + fn stage(&self, _context: &DomainContext<'_>) -> LegacyMigrationResult { + Ok(MigrationDomainResult { + domain: self.domain(), + state: MigrationDomainState::Staged, + ..MigrationDomainResult::default() + }) + } + + fn validate_stage(&self, context: &DomainContext<'_>) -> LegacyMigrationResult<()> { + validate_selected_cross_references(context) + } + + fn commit(&self, _context: &DomainContext<'_>) -> LegacyMigrationResult<()> { + Ok(()) + } + + fn validate_commit(&self, context: &DomainContext<'_>) -> LegacyMigrationResult<()> { + validate_selected_cross_references(context) + } +} + +fn validate_selected_cross_references(context: &DomainContext<'_>) -> LegacyMigrationResult<()> { + if context + .plan + .selection + .groups + .contains(&MigrationGroupId::WorkspacesSessionsAndTasks) + { + agent_coordination::validate_committed_coordination_cross_references(context)?; + } + Ok(()) +} diff --git a/src/crates/assembly/core/src/legacy_migration/remote_connect.rs b/src/crates/assembly/core/src/legacy_migration/remote_connect.rs new file mode 100644 index 0000000000..11d8a3026a --- /dev/null +++ b/src/crates/assembly/core/src/legacy_migration/remote_connect.rs @@ -0,0 +1,1515 @@ +use super::common::{ + backup_domain_dir, backup_file_once, io_error, read_bounded_json, read_optional_bounded_json, + relative_display, restore_unverified_file, stage_domain_dir, validate_regular_file, + MAX_JSON_BYTES, +}; +use openbitfun_legacy_migration::{ + atomic_write_json, DomainContext, DomainScan, LegacyDomainAdapter, LegacyMigrationError, + LegacyMigrationResult, MigrationRoots, +}; +use openbitfun_product_domains::legacy_migration::{ + ConflictResolution, FindingSeverity, MigrationConflict, MigrationDiagnostic, MigrationDomainId, + MigrationDomainResult, MigrationDomainState, ScanFinding, +}; +use openbitfun_services_integrations::remote_persistence as owner; +use owner::{ + AccountHintRecord, AccountSessionRecord, AccountSyncStateRecord, BotChatStateRecord, + BotConfigRecord, BotPersistenceRecord, LegacyAccountSessionKeyDomains, MachineBinding, + RemoteConnectFormStateRecord, SavedBotConnectionRecord, SettingsCursorRecord, +}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::path::{Path, PathBuf}; + +const DOMAIN_DIR: &str = "remote-connect"; +const SOURCE_SCHEMA: &str = "bitfun.remote-connect.v0.2.19"; +const TARGET_SCHEMA: &str = "openbitfun.remote-connect.current"; +const MAX_REMOTE_FILES: usize = 4_096; +const MAX_SECRET_BYTES: u64 = 16 * 1024 * 1024; +const LEGACY_ACCOUNT_SESSION_KEY_DOMAINS: LegacyAccountSessionKeyDomains<'static> = + LegacyAccountSessionKeyDomains { + v1: b"BitFun::session_store::v1", + v2: b"|BitFun::session_store::v2|", + }; + +pub(crate) struct RemoteConnectAdapter; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +enum BotSourceKind { + Canonical, + Fallback, + None, + UnresolvedBackup, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct RemoteConnectManifest { + source_files: BTreeMap, + target_before: BTreeMap>, + bot_source_kind: BotSourceKind, + target_bot_unresolved: bool, + imported: u64, + skipped: u64, + conflicts: u64, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +struct RemoteConnectReceipt { + manifest_digest: String, + completed: bool, + post_files: BTreeMap>, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +struct RemoteConnectOutcome { + imported: u64, + skipped: u64, + conflicts: u64, + warnings: Vec, + requires_reauthentication: Vec, +} + +#[derive(Default)] +struct RemoteConnectState { + files: BTreeMap, + device: Option, + account_session_present: bool, + account_hint: Option, + sync_states: BTreeMap, + settings_cursors: BTreeMap, + bot: Option, + bot_source_kind: BotSourceKind, + bot_unresolved: bool, + weixin_sync: BTreeMap, + weixin_tokens: BTreeMap>, +} + +impl Default for BotSourceKind { + fn default() -> Self { + Self::None + } +} + +impl LegacyDomainAdapter for RemoteConnectAdapter { + fn domain(&self) -> MigrationDomainId { + MigrationDomainId::RemoteConnectDevices + } + + fn scan(&self, roots: &MigrationRoots) -> LegacyMigrationResult { + let source = read_state(&roots.legacy_home_root, true)?; + let target = read_state(&roots.target_home_root, false)?; + let preview = preview(&source, &target); + Ok(DomainScan { + finding: ScanFinding { + domain: self.domain(), + code: if source.files.is_empty() { + "legacy_remote_connect_absent".to_string() + } else { + "legacy_remote_connect_supported".to_string() + }, + severity: if preview.conflicts.is_empty() { + FindingSeverity::Info + } else { + FindingSeverity::Warning + }, + entity_count: source_entity_count(&source), + logical_bytes: total_bytes(&roots.legacy_home_root, source.files.keys())?, + source_schema: Some(SOURCE_SCHEMA.to_string()), + migratable: !source.bot_unresolved || source.files.len() > 1, + detail: "Legacy Remote Connect identity, account, sync, and bot stores were inspected without exposing credentials.".to_string(), + }, + conflicts: preview.conflicts, + target_schema: Some(TARGET_SCHEMA.to_string()), + dependencies: Vec::new(), + }) + } + + fn stage(&self, context: &DomainContext<'_>) -> LegacyMigrationResult { + reset_stage(context)?; + let source = read_state(&context.roots.legacy_home_root, true)?; + let target = read_state(&context.roots.target_home_root, false)?; + let preview = preview(&source, &target); + let target_before = target_candidates(&source, &target) + .into_iter() + .map(|relative| { + let digest = target.files.get(&relative).cloned(); + (relative, digest) + }) + .collect(); + let manifest = RemoteConnectManifest { + source_files: source.files, + target_before, + bot_source_kind: source.bot_source_kind, + target_bot_unresolved: target.bot_unresolved, + imported: preview.imported, + skipped: preview.skipped, + conflicts: preview.conflicts.len() as u64, + }; + fs::create_dir_all(stage_domain_dir(context, DOMAIN_DIR)) + .map_err(|error| io_error(&stage_domain_dir(context, DOMAIN_DIR), error))?; + atomic_write_json(&manifest_path(context), &manifest)?; + Ok(MigrationDomainResult { + domain: self.domain(), + state: MigrationDomainState::Staged, + imported: manifest.imported, + skipped: manifest.skipped, + conflicts: manifest.conflicts, + ..MigrationDomainResult::default() + }) + } + + fn validate_stage(&self, context: &DomainContext<'_>) -> LegacyMigrationResult<()> { + let manifest = read_manifest(context)?; + let source = read_state(&context.roots.legacy_home_root, true)?; + if source.files != manifest.source_files + || source.bot_source_kind != manifest.bot_source_kind + { + return Err(LegacyMigrationError::InvalidRequest( + "legacy Remote Connect inputs changed after staging".to_string(), + )); + } + if let Some(receipt) = read_optional_bounded_json::( + &context.layout.stage_root(), + &receipt_path(context), + )? { + if receipt.manifest_digest != json_digest(&manifest)? { + return Err(LegacyMigrationError::InvalidRequest( + "Remote Connect commit receipt does not match its staged manifest".to_string(), + )); + } + return Ok(()); + } + let target = read_state(&context.roots.target_home_root, false)?; + if current_candidates( + &context.roots.target_home_root, + manifest.target_before.keys(), + )? != manifest.target_before + || target.bot_unresolved != manifest.target_bot_unresolved + { + return Err(LegacyMigrationError::InvalidRequest( + "current Remote Connect data changed after staging".to_string(), + )); + } + Ok(()) + } + + fn commit(&self, context: &DomainContext<'_>) -> LegacyMigrationResult<()> { + let manifest = read_manifest(context)?; + let manifest_digest = json_digest(&manifest)?; + let existing_receipt = read_optional_bounded_json::( + &context.layout.stage_root(), + &receipt_path(context), + )?; + if let Some(receipt) = &existing_receipt { + if receipt.manifest_digest != manifest_digest { + return Err(LegacyMigrationError::InvalidRequest( + "Remote Connect commit receipt does not match its manifest".to_string(), + )); + } + if receipt.completed + && current_candidates(&context.roots.target_home_root, receipt.post_files.keys())? + == receipt.post_files + { + return Ok(()); + } + } else { + let current = current_candidates( + &context.roots.target_home_root, + manifest.target_before.keys(), + )?; + if current != manifest.target_before { + return Err(LegacyMigrationError::InvalidRequest( + "current Remote Connect data changed before commit".to_string(), + )); + } + backup_targets(context, &manifest.target_before)?; + atomic_write_json( + &receipt_path(context), + &RemoteConnectReceipt { + manifest_digest: manifest_digest.clone(), + completed: false, + post_files: BTreeMap::new(), + }, + )?; + } + + let source = read_state(&context.roots.legacy_home_root, true)?; + if source.files != manifest.source_files { + return Err(LegacyMigrationError::InvalidRequest( + "legacy Remote Connect inputs changed during commit".to_string(), + )); + } + let original_root = backup_domain_dir(context, DOMAIN_DIR); + let original = read_state(&original_root, false)?; + let mut outcome = RemoteConnectOutcome { + imported: manifest.imported, + skipped: manifest.skipped, + conflicts: manifest.conflicts, + ..RemoteConnectOutcome::default() + }; + if manifest.conflicts > 0 { + outcome.warnings.push(warning( + "remote_connect_conflicts_require_review", + "One or more current Remote Connect records took priority or require owner recovery.", + )); + } + if !manifest.source_files.is_empty() { + apply_merge(context, &source, &original, &mut outcome)?; + } + + let post_files = current_candidates( + &context.roots.target_home_root, + manifest.target_before.keys(), + )?; + atomic_write_json(&outcome_path(context), &outcome)?; + atomic_write_json( + &receipt_path(context), + &RemoteConnectReceipt { + manifest_digest, + completed: true, + post_files, + }, + ) + } + + fn validate_commit(&self, context: &DomainContext<'_>) -> LegacyMigrationResult<()> { + let receipt: RemoteConnectReceipt = + read_bounded_json(&context.layout.stage_root(), &receipt_path(context))?; + if !receipt.completed + || current_candidates(&context.roots.target_home_root, receipt.post_files.keys())? + != receipt.post_files + { + return Err(LegacyMigrationError::InvalidRequest( + "current Remote Connect owner did not retain the committed data".to_string(), + )); + } + // Strict owner readers validate every installed active store. The + // encrypted session is also decrypted here, never reported. + let state = read_state(&context.roots.target_home_root, false)?; + if state.account_session_present { + owner::read_current_account_session( + &context.roots.target_home_root, + &MachineBinding::current(), + ) + .map_err(owner_error)?; + } + Ok(()) + } + + fn finalize_result( + &self, + context: &DomainContext<'_>, + staged: &MigrationDomainResult, + ) -> LegacyMigrationResult { + let outcome: RemoteConnectOutcome = + read_bounded_json(&context.layout.stage_root(), &outcome_path(context))?; + let mut result = staged.clone(); + result.imported = outcome.imported; + result.skipped = outcome.skipped; + result.conflicts = outcome.conflicts; + result.warnings = outcome.warnings; + result.requires_reauthentication = outcome.requires_reauthentication; + Ok(result) + } + + fn rollback_unverified(&self, context: &DomainContext<'_>) -> LegacyMigrationResult<()> { + let Some(manifest) = read_optional_bounded_json::( + &context.layout.stage_root(), + &manifest_path(context), + )? + else { + return Ok(()); + }; + let backup_root = backup_domain_dir(context, DOMAIN_DIR); + for (relative, digest) in &manifest.target_before { + let target = context.roots.target_home_root.join(relative); + let backup = backup_root.join(relative); + restore_unverified_file(&target, &backup, digest.is_some())?; + } + Ok(()) + } +} + +struct Preview { + imported: u64, + skipped: u64, + conflicts: Vec, +} + +fn preview(source: &RemoteConnectState, target: &RemoteConnectState) -> Preview { + let mut imported = 0; + let mut skipped = 0; + let mut conflicts = Vec::new(); + if source.device.is_some() { + if target.device.is_some() { + skipped += 1; + conflicts.push(target_wins( + "device_identity_target_wins", + "legacy device identity", + "current device identity", + )); + } else { + imported += 1; + } + } + if source.account_session_present { + if target.account_session_present { + skipped += 1; + conflicts.push(target_wins( + "account_session_target_wins", + "legacy account session", + "current account session", + )); + } else { + imported += 1; + } + } + if source.account_hint.is_some() { + imported += 1; + } + for connection in source + .bot + .as_ref() + .into_iter() + .flat_map(|bot| &bot.connections) + { + if target.bot.as_ref().is_some_and(|bot| { + bot.connections + .iter() + .any(|candidate| candidate.bot_type == connection.bot_type) + }) { + skipped += 1; + conflicts.push(target_wins( + "bot_connection_target_wins", + "legacy bot connection", + "current bot connection", + )); + } else { + imported += 1; + } + } + if source.bot_unresolved { + conflicts.push(MigrationConflict { + domain: MigrationDomainId::RemoteConnectDevices, + code: "legacy_bot_transaction_unresolved".to_string(), + source_summary: "canonical bot persistence is missing while its backup exists" + .to_string(), + target_summary: "fallback bot persistence was not opened".to_string(), + resolution: ConflictResolution::RequiresUserAction, + }); + } + if target.bot_unresolved { + conflicts.push(MigrationConflict { + domain: MigrationDomainId::RemoteConnectDevices, + code: "target_bot_transaction_unresolved".to_string(), + source_summary: "legacy bot persistence was left unchanged".to_string(), + target_summary: "current bot persistence has an unresolved replacement backup" + .to_string(), + resolution: ConflictResolution::RequiresUserAction, + }); + } + Preview { + imported, + skipped, + conflicts, + } +} + +fn target_wins(code: &str, source: &str, target: &str) -> MigrationConflict { + MigrationConflict { + domain: MigrationDomainId::RemoteConnectDevices, + code: code.to_string(), + source_summary: source.to_string(), + target_summary: target.to_string(), + resolution: ConflictResolution::TargetWins, + } +} + +fn apply_merge( + context: &DomainContext<'_>, + source: &RemoteConnectState, + target: &RemoteConnectState, + outcome: &mut RemoteConnectOutcome, +) -> LegacyMigrationResult<()> { + let target_root = &context.roots.target_home_root; + if target.device.is_none() { + if let Some(device) = &source.device { + owner::write_device_identity(&target_root.join("device_identity.json"), device) + .map_err(owner_error)?; + } + } + if let Some(source_hint) = &source.account_hint { + let mut merged = target.account_hint.clone().unwrap_or_default(); + fill_empty(&mut merged.username, &source_hint.username); + fill_empty(&mut merged.relay_url, &source_hint.relay_url); + owner::write_account_hint(&target_root.join("account_hint.json"), &merged) + .map_err(owner_error)?; + } + + let binding = MachineBinding::current(); + let source_session = if source.account_session_present { + match owner::read_legacy_account_session( + &context.roots.legacy_home_root, + &binding, + LEGACY_ACCOUNT_SESSION_KEY_DOMAINS, + ) { + Ok(value) => value, + Err(_) => { + warn_reauthentication( + outcome, + "remote_connect_account", + "legacy_account_session_unavailable", + "The legacy Remote Connect account could not be securely transferred.", + ); + None + } + } + } else { + None + }; + let target_session = if target.account_session_present { + match owner::read_current_account_session(&backup_domain_dir(context, DOMAIN_DIR), &binding) + { + Ok(value) => value, + Err(_) => { + warn_reauthentication( + outcome, + "remote_connect_account", + "target_account_session_unavailable", + "The existing Remote Connect account session needs authentication repair.", + ); + None + } + } + } else { + None + }; + let effective_session = if target.account_session_present { + target_session.as_ref() + } else if let Some(session) = &source_session { + owner::write_current_account_session(target_root, &binding, session) + .map_err(owner_error)?; + Some(session) + } else { + None + }; + if let (Some(source_session), Some(effective_session)) = + (source_session.as_ref(), effective_session) + { + if same_account(source_session, effective_session) { + merge_account_sync(context, source_session, target, outcome)?; + } else { + outcome.skipped = outcome + .skipped + .saturating_add(source.sync_states.len() as u64); + outcome.warnings.push(warning( + "account_sync_different_account_skipped", + "Legacy account sync cursors were not applied to a different current account.", + )); + } + } + + let merged_bot = match (&source.bot, &target.bot) { + (_, _) if target.bot_unresolved => { + outcome.warnings.push(warning( + "target_bot_transaction_unresolved", + "Bot persistence was left unchanged because the current owner has an unresolved replacement backup.", + )); + None + } + (Some(source), Some(target)) => Some(merge_bot(source, target, true)), + (Some(source), None) => Some(source.clone()), + (None, Some(target)) => Some(target.clone()), + (None, None) => None, + }; + if let Some(bot) = &merged_bot { + owner::write_bot_persistence(&target_root.join("remote_connect_persistence.json"), bot) + .map_err(owner_error)?; + merge_weixin_auxiliary(context, source, target, bot)?; + } + if source.bot_unresolved { + outcome.warnings.push(warning( + "legacy_bot_transaction_unresolved", + "Fallback bot persistence was not restored because a canonical backup indicates an unresolved transaction.", + )); + } + Ok(()) +} + +fn merge_account_sync( + context: &DomainContext<'_>, + session: &AccountSessionRecord, + target: &RemoteConnectState, + outcome: &mut RemoteConnectOutcome, +) -> LegacyMigrationResult<()> { + let Some(component) = owner::safe_account_file_component(&session.user_id) else { + outcome.warnings.push(warning( + "account_sync_invalid_user_id", + "Account sync cursors were skipped because their safe owner filename could not be resolved.", + )); + return Ok(()); + }; + let state_name = format!("{component}.json"); + let settings_name = format!("{component}.settings.json"); + let source_root = context.roots.legacy_home_root.join("account_sync"); + let target_root = context.roots.target_home_root.join("account_sync"); + if let Some(source_state) = strict_sync_state( + &source_root.join(&state_name), + &context.roots.legacy_home_root, + )? { + let merged = merge_sync_state( + source_state, + target + .sync_states + .get(&state_name) + .cloned() + .unwrap_or_default(), + ); + owner::write_account_sync_state(&target_root.join(&state_name), &merged) + .map_err(owner_error)?; + outcome.imported = outcome.imported.saturating_add(1); + } + if let Some(source_cursor) = strict_settings_cursor( + &source_root.join(&settings_name), + &context.roots.legacy_home_root, + )? { + let merged = choose_settings_cursor( + source_cursor, + target.settings_cursors.get(&settings_name).cloned(), + ); + owner::write_settings_cursor(&target_root.join(&settings_name), &merged) + .map_err(owner_error)?; + outcome.imported = outcome.imported.saturating_add(1); + } + Ok(()) +} + +fn merge_sync_state( + source: AccountSyncStateRecord, + mut target: AccountSyncStateRecord, +) -> AccountSyncStateRecord { + target.last_session_since = target.last_session_since.max(source.last_session_since); + for (session_id, hash) in source.uploaded_hashes { + target.uploaded_hashes.entry(session_id).or_insert(hash); + } + target +} + +fn choose_settings_cursor( + source: SettingsCursorRecord, + target: Option, +) -> SettingsCursorRecord { + target + .filter(|cursor| cursor.version >= source.version) + .unwrap_or(source) +} + +fn same_account(left: &AccountSessionRecord, right: &AccountSessionRecord) -> bool { + left.user_id == right.user_id + && normalized_url(&left.relay_url) == normalized_url(&right.relay_url) +} + +fn normalized_url(value: &str) -> &str { + value.trim().trim_end_matches('/') +} + +fn merge_bot( + source: &BotPersistenceRecord, + target: &BotPersistenceRecord, + target_present: bool, +) -> BotPersistenceRecord { + let mut merged = target.clone(); + for source_connection in &source.connections { + if let Some(target_connection) = merged + .connections + .iter_mut() + .find(|candidate| candidate.bot_type == source_connection.bot_type) + { + merge_bot_connection(source_connection, target_connection); + } else { + merged.connections.push(source_connection.clone()); + } + } + merge_form_state(&source.form_state, &mut merged.form_state); + if !target_present { + merged.verbose_mode = source.verbose_mode; + } + merged +} + +fn merge_bot_connection(source: &SavedBotConnectionRecord, target: &mut SavedBotConnectionRecord) { + fill_empty(&mut target.chat_id, &source.chat_id); + if target.connected_at == 0 { + target.connected_at = source.connected_at; + } + merge_bot_config(&source.config, &mut target.config); + merge_chat_state(&source.chat_state, &mut target.chat_state); +} + +fn merge_bot_config(source: &BotConfigRecord, target: &mut BotConfigRecord) { + match (source, target) { + ( + BotConfigRecord::Feishu { app_id, app_secret }, + BotConfigRecord::Feishu { + app_id: target_id, + app_secret: target_secret, + }, + ) => { + fill_empty(target_id, app_id); + fill_empty(target_secret, app_secret); + } + ( + BotConfigRecord::Telegram { bot_token }, + BotConfigRecord::Telegram { + bot_token: target_token, + }, + ) => fill_empty(target_token, bot_token), + ( + BotConfigRecord::Weixin { + ilink_token, + base_url, + bot_account_id, + }, + BotConfigRecord::Weixin { + ilink_token: target_token, + base_url: target_url, + bot_account_id: target_account, + }, + ) => { + fill_empty(target_token, ilink_token); + fill_empty(target_url, base_url); + fill_empty(target_account, bot_account_id); + } + _ => {} + } +} + +fn merge_chat_state(source: &BotChatStateRecord, target: &mut BotChatStateRecord) { + fill_empty(&mut target.chat_id, &source.chat_id); + let mut imported_source_context = false; + if target.current_workspace.is_none() { + target.current_workspace = source.current_workspace.clone(); + imported_source_context |= target.current_workspace.is_some(); + } + if target.current_assistant.is_none() { + target.current_assistant = source.current_assistant.clone(); + imported_source_context |= target.current_assistant.is_some(); + } + if target.current_assistant_name.is_none() { + target.current_assistant_name = source.current_assistant_name.clone(); + } + if target.current_session_id.is_none() { + target.current_session_id = source.current_session_id.clone(); + imported_source_context |= target.current_session_id.is_some(); + } + if imported_source_context { + target.account_remote_context |= source.account_remote_context; + } +} + +fn merge_form_state( + source: &RemoteConnectFormStateRecord, + target: &mut RemoteConnectFormStateRecord, +) { + fill_empty(&mut target.custom_server_url, &source.custom_server_url); + fill_empty(&mut target.telegram_bot_token, &source.telegram_bot_token); + fill_empty(&mut target.feishu_app_id, &source.feishu_app_id); + fill_empty(&mut target.feishu_app_secret, &source.feishu_app_secret); + fill_empty(&mut target.weixin_ilink_token, &source.weixin_ilink_token); + fill_empty(&mut target.weixin_base_url, &source.weixin_base_url); + fill_empty( + &mut target.weixin_bot_account_id, + &source.weixin_bot_account_id, + ); +} + +fn fill_empty(target: &mut String, source: &str) { + if target.trim().is_empty() && !source.trim().is_empty() { + *target = source.to_string(); + } +} + +fn merge_weixin_auxiliary( + context: &DomainContext<'_>, + source: &RemoteConnectState, + target: &RemoteConnectState, + merged: &BotPersistenceRecord, +) -> LegacyMigrationResult<()> { + let active_ids = active_weixin_ids(merged)?; + for account_id in active_ids { + if let Some(source_buf) = source.weixin_sync.get(&account_id) { + let target_path = weixin_sync_path(&context.roots.target_home_root, &account_id); + if !target.files.contains_key(&home_relative( + &context.roots.target_home_root, + &target_path, + )) { + owner::write_weixin_sync_buffer(&target_path, source_buf).map_err(owner_error)?; + } + } + if let Some(source_tokens) = source.weixin_tokens.get(&account_id) { + let mut tokens = target + .weixin_tokens + .get(&account_id) + .cloned() + .unwrap_or_default(); + for (peer, token) in source_tokens { + tokens.entry(peer.clone()).or_insert_with(|| token.clone()); + } + owner::write_context_tokens( + &weixin_tokens_path(&context.roots.target_home_root, &account_id), + &tokens, + ) + .map_err(owner_error)?; + } + } + Ok(()) +} + +fn read_state(root: &Path, legacy: bool) -> LegacyMigrationResult { + let mut state = RemoteConnectState::default(); + let device_path = root.join("device_identity.json"); + if existing_regular(root, &device_path, MAX_JSON_BYTES)? { + state.device = owner::read_device_identity(&device_path).map_err(owner_error)?; + record_file(root, &device_path, &mut state.files)?; + } + let hint_path = root.join("account_hint.json"); + if existing_regular(root, &hint_path, MAX_JSON_BYTES)? { + state.account_hint = owner::read_account_hint(&hint_path).map_err(owner_error)?; + record_file(root, &hint_path, &mut state.files)?; + } + for name in ["account_session.enc", "account_session.key"] { + let path = root.join(name); + if existing_regular(root, &path, MAX_SECRET_BYTES)? { + record_file(root, &path, &mut state.files)?; + } + } + state.account_session_present = state.files.contains_key("account_session.enc"); + + let sync_root = root.join("account_sync"); + if existing_directory(root, &sync_root)? { + let paths = owner::account_sync_paths(&sync_root).map_err(owner_error)?; + if paths.len() > MAX_REMOTE_FILES { + return Err(LegacyMigrationError::ResourceLimit( + "account sync file count exceeds the migration limit".to_string(), + )); + } + for path in paths { + existing_regular(root, &path, MAX_JSON_BYTES)?; + let name = path + .file_name() + .and_then(|name| name.to_str()) + .ok_or_else(|| { + LegacyMigrationError::InvalidRequest( + "account sync filename is not UTF-8".to_string(), + ) + })? + .to_string(); + if name.ends_with(".settings.json") { + let value = owner::read_settings_cursor(&path) + .map_err(owner_error)? + .ok_or_else(|| { + LegacyMigrationError::InvalidRequest( + "account settings cursor disappeared".to_string(), + ) + })?; + state.settings_cursors.insert(name, value); + } else { + let value = owner::read_account_sync_state(&path) + .map_err(owner_error)? + .ok_or_else(|| { + LegacyMigrationError::InvalidRequest( + "account sync state disappeared".to_string(), + ) + })?; + state.sync_states.insert(name, value); + } + record_file(root, &path, &mut state.files)?; + } + } + + let canonical = root.join("remote_connect_persistence.json"); + let backup = root.join("remote_connect_persistence.json.bak"); + let fallback = root.join("bot_connections.json"); + let canonical_exists = existing_regular(root, &canonical, MAX_JSON_BYTES)?; + let backup_exists = existing_regular(root, &backup, MAX_JSON_BYTES)?; + let fallback_exists = existing_regular(root, &fallback, MAX_JSON_BYTES)?; + for path in [&canonical, &backup, &fallback] { + if path.exists() { + record_file(root, path, &mut state.files)?; + } + } + if canonical_exists { + state.bot = owner::read_bot_persistence(&canonical).map_err(owner_error)?; + state.bot_source_kind = BotSourceKind::Canonical; + } else if backup_exists { + state.bot_unresolved = true; + state.bot_source_kind = BotSourceKind::UnresolvedBackup; + } else if legacy && fallback_exists { + state.bot = owner::read_bot_persistence(&fallback).map_err(owner_error)?; + state.bot_source_kind = BotSourceKind::Fallback; + } + + if let Some(bot) = &state.bot { + for account_id in active_weixin_ids(bot)? { + let sync_path = weixin_sync_path(root, &account_id); + if existing_regular(root, &sync_path, MAX_SECRET_BYTES)? { + if let Some(value) = + owner::read_weixin_sync_buffer(&sync_path).map_err(owner_error)? + { + state.weixin_sync.insert(account_id.clone(), value); + } + record_file(root, &sync_path, &mut state.files)?; + } + let token_path = weixin_tokens_path(root, &account_id); + if existing_regular(root, &token_path, MAX_JSON_BYTES)? { + if let Some(value) = owner::read_context_tokens(&token_path).map_err(owner_error)? { + state.weixin_tokens.insert(account_id.clone(), value); + } + record_file(root, &token_path, &mut state.files)?; + } + } + } + Ok(state) +} + +fn active_weixin_ids(bot: &BotPersistenceRecord) -> LegacyMigrationResult> { + let mut ids = BTreeSet::new(); + for connection in &bot.connections { + if let BotConfigRecord::Weixin { bot_account_id, .. } = &connection.config { + if bot_account_id.is_empty() { + continue; + } + if !owner::is_safe_weixin_account_id(bot_account_id) { + return Err(LegacyMigrationError::InvalidRequest( + "Weixin bot account id is not a safe persistence component".to_string(), + )); + } + ids.insert(bot_account_id.clone()); + } + } + Ok(ids) +} + +fn strict_sync_state( + path: &Path, + root: &Path, +) -> LegacyMigrationResult> { + if !existing_regular(root, path, MAX_JSON_BYTES)? { + return Ok(None); + } + owner::read_account_sync_state(path).map_err(owner_error) +} + +fn strict_settings_cursor( + path: &Path, + root: &Path, +) -> LegacyMigrationResult> { + if !existing_regular(root, path, MAX_JSON_BYTES)? { + return Ok(None); + } + owner::read_settings_cursor(path).map_err(owner_error) +} + +fn target_candidates(source: &RemoteConnectState, target: &RemoteConnectState) -> BTreeSet { + let mut paths = target.files.keys().cloned().collect::>(); + paths.extend([ + "device_identity.json".to_string(), + "account_hint.json".to_string(), + "account_session.enc".to_string(), + "account_session.key".to_string(), + "remote_connect_persistence.json".to_string(), + ]); + for name in source + .sync_states + .keys() + .chain(source.settings_cursors.keys()) + { + paths.insert(format!("account_sync/{name}")); + } + for account_id in source.weixin_sync.keys().chain(source.weixin_tokens.keys()) { + paths.insert(format!("weixin/{account_id}_get_updates_buf.txt")); + paths.insert(format!("weixin/{account_id}_context_tokens.json")); + } + paths +} + +fn current_candidates<'a>( + root: &Path, + candidates: impl IntoIterator, +) -> LegacyMigrationResult>> { + let mut values = BTreeMap::new(); + for relative in candidates { + let path = root.join(relative); + let digest = match fs::symlink_metadata(&path) { + Ok(_) => { + validate_regular_file(root, &path)?; + Some(file_digest(&path)?) + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, + Err(error) => return Err(io_error(&path, error)), + }; + values.insert(relative.clone(), digest); + } + Ok(values) +} + +fn backup_targets( + context: &DomainContext<'_>, + target_before: &BTreeMap>, +) -> LegacyMigrationResult<()> { + let backup_root = backup_domain_dir(context, DOMAIN_DIR); + for (relative, digest) in target_before { + if digest.is_some() { + let target = context.roots.target_home_root.join(relative); + let backup = backup_root.join(relative); + if is_secret_relative(relative) { + if !backup.exists() { + let bytes = fs::read(&target).map_err(|error| io_error(&target, error))?; + owner::write_private_bytes(&backup, &bytes).map_err(owner_error)?; + } + } else { + backup_file_once(&target, &backup)?; + } + } + } + Ok(()) +} + +fn is_secret_relative(relative: &str) -> bool { + matches!( + relative, + "account_session.enc" + | "account_session.key" + | "account_hint.json" + | "remote_connect_persistence.json" + | "remote_connect_persistence.json.bak" + | "bot_connections.json" + ) || relative.starts_with("weixin/") +} + +fn existing_regular(root: &Path, path: &Path, max_bytes: u64) -> LegacyMigrationResult { + match fs::symlink_metadata(path) { + Ok(_) => { + validate_regular_file(root, path)?; + let bytes = fs::metadata(path) + .map_err(|error| io_error(path, error))? + .len(); + if bytes > max_bytes { + return Err(LegacyMigrationError::ResourceLimit(format!( + "Remote Connect file exceeds {max_bytes} bytes: {}", + relative_display(root, path) + ))); + } + Ok(true) + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(error) => Err(io_error(path, error)), + } +} + +fn existing_directory(root: &Path, path: &Path) -> LegacyMigrationResult { + match fs::symlink_metadata(path) { + Ok(metadata) => { + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Err(LegacyMigrationError::InvalidRequest(format!( + "expected a regular Remote Connect directory at {}", + relative_display(root, path) + ))); + } + let canonical_root = fs::canonicalize(root).map_err(|error| io_error(root, error))?; + let canonical_path = fs::canonicalize(path).map_err(|error| io_error(path, error))?; + if !canonical_path.starts_with(canonical_root) { + return Err(LegacyMigrationError::PathEscape(path.to_path_buf())); + } + Ok(true) + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(error) => Err(io_error(path, error)), + } +} + +fn record_file( + root: &Path, + path: &Path, + files: &mut BTreeMap, +) -> LegacyMigrationResult<()> { + files.insert(home_relative(root, path), file_digest(path)?); + Ok(()) +} + +fn home_relative(root: &Path, path: &Path) -> String { + relative_display(root, path) +} + +fn file_digest(path: &Path) -> LegacyMigrationResult { + let bytes = fs::read(path).map_err(|error| io_error(path, error))?; + Ok(format!("sha256:{}", hex::encode(Sha256::digest(bytes)))) +} + +fn json_digest(value: &impl Serialize) -> LegacyMigrationResult { + let bytes = serde_json::to_vec(value).map_err(|error| { + LegacyMigrationError::InvalidRequest(format!("serialize Remote Connect manifest: {error}")) + })?; + Ok(format!("sha256:{}", hex::encode(Sha256::digest(bytes)))) +} + +fn total_bytes<'a>( + root: &Path, + paths: impl IntoIterator, +) -> LegacyMigrationResult { + let mut total = 0u64; + for relative in paths { + let path = root.join(relative); + total = total.saturating_add( + fs::metadata(&path) + .map_err(|error| io_error(&path, error))? + .len(), + ); + } + Ok(total) +} + +fn source_entity_count(state: &RemoteConnectState) -> u64 { + u64::from(state.device.is_some()) + + u64::from(state.account_session_present) + + u64::from(state.account_hint.is_some()) + + state.sync_states.len() as u64 + + state.settings_cursors.len() as u64 + + state + .bot + .as_ref() + .map_or(0, |bot| bot.connections.len() as u64) + + state.weixin_sync.len() as u64 + + state.weixin_tokens.len() as u64 +} + +fn weixin_sync_path(root: &Path, account_id: &str) -> PathBuf { + root.join("weixin") + .join(format!("{account_id}_get_updates_buf.txt")) +} + +fn weixin_tokens_path(root: &Path, account_id: &str) -> PathBuf { + root.join("weixin") + .join(format!("{account_id}_context_tokens.json")) +} + +fn warn_reauthentication( + outcome: &mut RemoteConnectOutcome, + identifier: &str, + code: &str, + message: &str, +) { + outcome + .requires_reauthentication + .push(identifier.to_string()); + outcome.warnings.push(warning(code, message)); +} + +fn warning(code: &str, message: &str) -> MigrationDiagnostic { + MigrationDiagnostic { + code: code.to_string(), + severity: FindingSeverity::Warning, + domain: Some(MigrationDomainId::RemoteConnectDevices), + message: message.to_string(), + action: Some( + "Review the item in Data Migrator and authenticate again if needed".to_string(), + ), + ..MigrationDiagnostic::default() + } +} + +fn owner_error(error: anyhow::Error) -> LegacyMigrationError { + LegacyMigrationError::InvalidRequest(format!( + "Remote Connect persistence owner rejected the data: {error:#}" + )) +} + +fn manifest_path(context: &DomainContext<'_>) -> PathBuf { + stage_domain_dir(context, DOMAIN_DIR).join("manifest.json") +} + +fn receipt_path(context: &DomainContext<'_>) -> PathBuf { + stage_domain_dir(context, DOMAIN_DIR).join("commit-receipt.json") +} + +fn outcome_path(context: &DomainContext<'_>) -> PathBuf { + stage_domain_dir(context, DOMAIN_DIR).join("outcome.json") +} + +fn read_manifest(context: &DomainContext<'_>) -> LegacyMigrationResult { + read_bounded_json(&context.layout.stage_root(), &manifest_path(context)) +} + +fn reset_stage(context: &DomainContext<'_>) -> LegacyMigrationResult<()> { + let path = stage_domain_dir(context, DOMAIN_DIR); + match fs::remove_dir_all(&path) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(io_error(&path, error)), + } + fs::create_dir_all(&path).map_err(|error| io_error(&path, error)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::legacy_migration::adapters_for_groups; + use openbitfun_legacy_migration::{ + probe_legacy_source, CancellationToken, CrashInjector, CrashPoint, LegacyMigrationError, + MigrationEngine, NoCrashInjection, ProbeLimits, + }; + use openbitfun_product_domains::legacy_migration::{ + MigrationGroupId, MigrationRunStatus, MigrationSelection, + }; + use std::sync::atomic::{AtomicBool, Ordering}; + + #[test] + fn remote_connect_merge_preserves_target_identity_and_remote_bot_context() { + let temp = test_tempdir("merge"); + let roots = fixture_roots(temp.path()); + seed_probe_source(&roots); + fs::create_dir_all(&roots.legacy_home_root).unwrap(); + fs::create_dir_all(&roots.target_home_root).unwrap(); + let source_device = owner::DeviceIdentityRecord { + device_id: "11111111111111111111111111111111".to_string(), + device_name: "legacy-device".to_string(), + mac_address: "02:00:00:00:00:11".to_string(), + }; + let target_device = owner::DeviceIdentityRecord { + device_id: "22222222222222222222222222222222".to_string(), + device_name: "current-device".to_string(), + mac_address: "02:00:00:00:00:22".to_string(), + }; + owner::write_device_identity( + &roots.legacy_home_root.join("device_identity.json"), + &source_device, + ) + .unwrap(); + owner::write_device_identity( + &roots.target_home_root.join("device_identity.json"), + &target_device, + ) + .unwrap(); + let source_bot = bot_fixture("legacy-secret", Some("/srv/legacy"), true); + let target_bot = bot_fixture("current-secret", None, false); + owner::write_bot_persistence( + &roots + .legacy_home_root + .join("remote_connect_persistence.json"), + &source_bot, + ) + .unwrap(); + owner::write_bot_persistence( + &roots + .target_home_root + .join("remote_connect_persistence.json"), + &target_bot, + ) + .unwrap(); + // A syntactically valid encrypted envelope with the wrong key proves + // credential failure is reported without blocking identity/bot data. + fs::write( + roots.legacy_home_root.join("account_session.enc"), + "AAAAAAAAAAAAAAAAAAAA", + ) + .unwrap(); + fs::write( + roots.legacy_home_root.join("account_session.key"), + [0u8; 32], + ) + .unwrap(); + let before = source_hashes(&roots.legacy_home_root); + + let report = run_remote_group(&roots); + assert_eq!(report.status, MigrationRunStatus::CompletedWithWarnings); + assert_eq!( + owner::read_device_identity(&roots.target_home_root.join("device_identity.json")) + .unwrap(), + Some(target_device) + ); + let bot = owner::read_bot_persistence( + &roots + .target_home_root + .join("remote_connect_persistence.json"), + ) + .unwrap() + .unwrap(); + let connection = &bot.connections[0]; + assert!(matches!( + &connection.config, + BotConfigRecord::Telegram { bot_token } if bot_token == "current-secret" + )); + assert_eq!( + connection + .chat_state + .current_workspace + .as_ref() + .map(|workspace| workspace.path.as_str()), + Some("/srv/legacy") + ); + assert!(connection.chat_state.account_remote_context); + assert!(report + .requires_reauthentication + .contains(&"remote_connect_account".to_string())); + let report_json = serde_json::to_string(&report).unwrap(); + assert!(!report_json.contains("legacy-secret")); + assert!(!report_json.contains("current-secret")); + assert_eq!(source_hashes(&roots.legacy_home_root), before); + assert_stage_redacted(&roots, &report.run_id, &["legacy-secret", "current-secret"]); + } + + #[test] + fn canonical_backup_blocks_legacy_bot_fallback() { + let temp = test_tempdir("fallback"); + let roots = fixture_roots(temp.path()); + seed_probe_source(&roots); + fs::create_dir_all(&roots.legacy_home_root).unwrap(); + owner::write_bot_persistence( + &roots.legacy_home_root.join("bot_connections.json"), + &bot_fixture("fallback-secret", None, false), + ) + .unwrap(); + fs::write( + roots + .legacy_home_root + .join("remote_connect_persistence.json.bak"), + b"unresolved owner transaction", + ) + .unwrap(); + + let report = run_remote_group(&roots); + assert_eq!(report.status, MigrationRunStatus::CompletedWithWarnings); + assert!(!roots + .target_home_root + .join("remote_connect_persistence.json") + .exists()); + let remote = report + .domain_results + .iter() + .find(|result| result.domain == MigrationDomainId::RemoteConnectDevices) + .unwrap(); + assert!(remote + .warnings + .iter() + .any(|warning| warning.code == "legacy_bot_transaction_unresolved")); + assert!(!serde_json::to_string(&report) + .unwrap() + .contains("fallback-secret")); + } + + #[test] + fn remote_connect_commit_resumes_after_the_owner_write_without_duplicates() { + let temp = test_tempdir("crash-recovery"); + let roots = fixture_roots(temp.path()); + seed_probe_source(&roots); + fs::create_dir_all(&roots.legacy_home_root).unwrap(); + owner::write_bot_persistence( + &roots + .legacy_home_root + .join("remote_connect_persistence.json"), + &bot_fixture("recovery-secret", Some("/srv/recovery"), true), + ) + .unwrap(); + let source = probe_legacy_source(&roots, ProbeLimits::default()) + .unwrap() + .unwrap(); + let selection = MigrationSelection { + groups: BTreeSet::from([MigrationGroupId::RemoteConnectionsAndDevices]), + }; + let engine = MigrationEngine::new(roots.clone(), adapters_for_groups(&selection)).unwrap(); + let plan = engine + .plan(&source, selection, &CancellationToken::default()) + .unwrap(); + let crash = CrashOnce { + point: CrashPoint::AfterCommit(MigrationDomainId::RemoteConnectDevices), + fired: AtomicBool::new(false), + }; + assert!(matches!( + engine.execute(&plan, &CancellationToken::default(), &crash), + Err(LegacyMigrationError::InjectedCrash( + CrashPoint::AfterCommit(MigrationDomainId::RemoteConnectDevices) + )) + )); + let report = engine + .execute(&plan, &CancellationToken::default(), &NoCrashInjection) + .unwrap(); + assert!(matches!( + report.status, + MigrationRunStatus::Completed | MigrationRunStatus::CompletedWithWarnings + )); + let bot = owner::read_bot_persistence( + &roots + .target_home_root + .join("remote_connect_persistence.json"), + ) + .unwrap() + .unwrap(); + assert_eq!(bot.connections.len(), 1); + } + + #[test] + fn account_sync_merge_keeps_target_hashes_and_whole_version_pairs() { + let source = AccountSyncStateRecord { + last_session_since: 9, + uploaded_hashes: std::collections::HashMap::from([ + ("shared".to_string(), "source-hash".to_string()), + ("source-only".to_string(), "source-only-hash".to_string()), + ]), + }; + let target = AccountSyncStateRecord { + last_session_since: 7, + uploaded_hashes: std::collections::HashMap::from([( + "shared".to_string(), + "target-hash".to_string(), + )]), + }; + let merged = merge_sync_state(source, target); + assert_eq!(merged.last_session_since, 9); + assert_eq!(merged.uploaded_hashes["shared"], "target-hash"); + assert_eq!(merged.uploaded_hashes["source-only"], "source-only-hash"); + + let selected = choose_settings_cursor( + SettingsCursorRecord { + version: 4, + hash: "source-pair".to_string(), + }, + Some(SettingsCursorRecord { + version: 6, + hash: "target-pair".to_string(), + }), + ); + assert_eq!(selected.version, 6); + assert_eq!(selected.hash, "target-pair"); + } + + struct CrashOnce { + point: CrashPoint, + fired: AtomicBool, + } + + impl CrashInjector for CrashOnce { + fn should_crash(&self, point: CrashPoint) -> bool { + point == self.point && !self.fired.swap(true, Ordering::AcqRel) + } + } + + fn bot_fixture( + token: &str, + workspace: Option<&str>, + account_remote_context: bool, + ) -> BotPersistenceRecord { + BotPersistenceRecord { + connections: vec![SavedBotConnectionRecord { + bot_type: "telegram".to_string(), + chat_id: "chat-1".to_string(), + config: BotConfigRecord::Telegram { + bot_token: token.to_string(), + }, + chat_state: BotChatStateRecord { + chat_id: "chat-1".to_string(), + paired: true, + current_workspace: workspace.map(|path| owner::BotWorkspaceRefRecord { + path: path.to_string(), + remote_connection_id: Some("ssh-user@example.invalid:22".to_string()), + remote_ssh_host: Some("example.invalid".to_string()), + }), + current_assistant: None, + current_assistant_name: None, + current_session_id: None, + display_mode: owner::BotDisplayModeRecord::Assistant, + account_remote_context, + }, + connected_at: 1, + }], + form_state: RemoteConnectFormStateRecord::default(), + verbose_mode: false, + } + } + + fn run_remote_group( + roots: &MigrationRoots, + ) -> openbitfun_product_domains::legacy_migration::MigrationRunReport { + let source = probe_legacy_source(roots, ProbeLimits::default()) + .unwrap() + .expect("legacy source"); + let selection = MigrationSelection { + groups: BTreeSet::from([MigrationGroupId::RemoteConnectionsAndDevices]), + }; + let engine = MigrationEngine::new(roots.clone(), adapters_for_groups(&selection)).unwrap(); + let plan = engine + .plan(&source, selection, &CancellationToken::default()) + .unwrap(); + engine + .execute(&plan, &CancellationToken::default(), &NoCrashInjection) + .unwrap() + } + + fn seed_probe_source(roots: &MigrationRoots) { + let path = roots.legacy_user_root.join("config/app.json"); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, r#"{"version":"0.2.19"}"#).unwrap(); + } + + fn fixture_roots(root: &Path) -> MigrationRoots { + 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/skills"), + target_ssh_root: root.join("target/ssh"), + } + } + + fn source_hashes(root: &Path) -> BTreeMap { + let mut values = BTreeMap::new(); + for relative in [ + "device_identity.json", + "remote_connect_persistence.json", + "account_session.enc", + "account_session.key", + ] { + let path = root.join(relative); + if path.exists() { + values.insert(relative.to_string(), file_digest(&path).unwrap()); + } + } + values + } + + fn assert_stage_redacted(roots: &MigrationRoots, run_id: &str, secrets: &[&str]) { + let stage = roots + .migration_root() + .join("runs") + .join(run_id) + .join("stage/remote-connect"); + for entry in fs::read_dir(stage).unwrap() { + let path = entry.unwrap().path(); + if path.is_file() { + let contents = fs::read_to_string(path).unwrap(); + for secret in secrets { + assert!(!contents.contains(secret)); + } + } + } + } + + fn test_tempdir(label: &str) -> tempfile::TempDir { + let root = std::env::var_os("OPENBITFUN_TEST_TMPDIR") + .map(PathBuf::from) + .unwrap_or_else(std::env::temp_dir); + fs::create_dir_all(&root).unwrap(); + tempfile::Builder::new() + .prefix(&format!("remote-connect-migration-{label}-")) + .tempdir_in(root) + .unwrap() + } +} diff --git a/src/crates/assembly/core/src/legacy_migration/remote_ssh.rs b/src/crates/assembly/core/src/legacy_migration/remote_ssh.rs new file mode 100644 index 0000000000..6426cace95 --- /dev/null +++ b/src/crates/assembly/core/src/legacy_migration/remote_ssh.rs @@ -0,0 +1,1185 @@ +use super::common::{ + backup_domain_dir, backup_file_once, io_error, read_bounded_json, read_optional_bounded_json, + relative_display, restore_unverified_file, stage_domain_dir, validate_regular_file, + MAX_JSON_BYTES, +}; +use openbitfun_legacy_migration::{ + atomic_write_json, DomainContext, DomainScan, LegacyDomainAdapter, LegacyMigrationError, + LegacyMigrationResult, MigrationRoots, +}; +use openbitfun_product_domains::legacy_migration::{ + ConflictResolution, FindingSeverity, MigrationConflict, MigrationDiagnostic, MigrationDomainId, + MigrationDomainResult, MigrationDomainState, ScanFinding, +}; +use openbitfun_services_integrations::remote_persistence as owner; +use owner::{ + KnownHostRecord, RemoteWorkspaceRecord, SavedAuthTypeRecord, SavedConnectionRecord, + SshVaultRecord, +}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::path::{Path, PathBuf}; + +const DOMAIN_DIR: &str = "remote-ssh"; +const SOURCE_SCHEMA: &str = "bitfun.remote-ssh.v0.2.19"; +const TARGET_SCHEMA: &str = "openbitfun.remote-ssh.current"; +const FILES: [&str; 5] = [ + "ssh_connections.json", + "remote_workspace.json", + "known_hosts", + ".ssh_password_vault.key", + "ssh_password_vault.json", +]; +const MAX_VAULT_BYTES: u64 = 16 * 1024 * 1024; + +pub(crate) struct RemoteSshAdapter; + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +enum VaultHealth { + #[default] + Absent, + Valid, + Invalid, +} + +struct SshState { + files: BTreeMap, + connections: Vec, + workspaces: Vec, + known_hosts: Vec, + vault_health: VaultHealth, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +enum ProfileOrigin { + Source, + Target, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct VaultSource { + origin: ProfileOrigin, + original_id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct RemoteSshManifest { + source_files: BTreeMap, + target_before: BTreeMap>, + staged_files: BTreeMap, + vault_sources: BTreeMap, + source_vault_health: VaultHealth, + target_vault_health: VaultHealth, + imported: u64, + skipped: u64, + conflicts: u64, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +struct RemoteSshReceipt { + manifest_digest: String, + completed: bool, + post_files: BTreeMap>, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +struct RemoteSshOutcome { + imported: u64, + skipped: u64, + conflicts: u64, + warnings: Vec, + requires_reauthentication: Vec, + requires_relocation: Vec, +} + +struct MergePlan { + connections: Vec, + workspaces: Vec, + known_hosts: Vec, + vault_sources: BTreeMap, + conflicts: Vec, + imported: u64, + skipped: u64, +} + +impl LegacyDomainAdapter for RemoteSshAdapter { + fn domain(&self) -> MigrationDomainId { + MigrationDomainId::RemoteSsh + } + + fn scan(&self, roots: &MigrationRoots) -> LegacyMigrationResult { + let source = read_state(&roots.legacy_ssh_root, true)?; + let target = read_state(&roots.target_ssh_root, false)?; + let merged = merge_states(&source, &target); + Ok(DomainScan { + finding: ScanFinding { + domain: self.domain(), + code: if source.files.is_empty() { + "legacy_remote_ssh_absent".to_string() + } else { + "legacy_remote_ssh_supported".to_string() + }, + severity: if merged.conflicts.is_empty() + && source.vault_health != VaultHealth::Invalid + { + FindingSeverity::Info + } else { + FindingSeverity::Warning + }, + entity_count: (source.connections.len() + + source.workspaces.len() + + source.known_hosts.len()) as u64, + logical_bytes: total_bytes(&roots.legacy_ssh_root, source.files.keys())?, + source_schema: Some(SOURCE_SCHEMA.to_string()), + migratable: true, + detail: "Legacy SSH profiles, POSIX workspace registrations, host trust, and credential references were inspected as one atomic subdomain.".to_string(), + }, + conflicts: merged.conflicts, + target_schema: Some(TARGET_SCHEMA.to_string()), + dependencies: Vec::new(), + }) + } + + fn stage(&self, context: &DomainContext<'_>) -> LegacyMigrationResult { + reset_stage(context)?; + let source = read_state(&context.roots.legacy_ssh_root, true)?; + let target = read_state(&context.roots.target_ssh_root, false)?; + let merged = merge_states(&source, &target); + let domain_root = stage_domain_dir(context, DOMAIN_DIR); + owner::write_saved_connections( + &domain_root.join("ssh_connections.json"), + &merged.connections, + ) + .map_err(owner_error)?; + owner::write_remote_workspaces( + &domain_root.join("remote_workspace.json"), + &merged.workspaces, + ) + .map_err(owner_error)?; + owner::write_known_hosts(&domain_root.join("known_hosts"), &merged.known_hosts) + .map_err(owner_error)?; + let staged_files = [ + "ssh_connections.json", + "remote_workspace.json", + "known_hosts", + ] + .into_iter() + .map(|relative| { + let path = domain_root.join(relative); + Ok((relative.to_string(), file_digest(&path)?)) + }) + .collect::>>()?; + let target_before = FILES + .into_iter() + .map(|relative| (relative.to_string(), target.files.get(relative).cloned())) + .collect(); + let manifest = RemoteSshManifest { + source_files: source.files, + target_before, + staged_files, + vault_sources: merged.vault_sources, + source_vault_health: source.vault_health, + target_vault_health: target.vault_health, + imported: merged.imported, + skipped: merged.skipped, + conflicts: merged.conflicts.len() as u64, + }; + atomic_write_json(&manifest_path(context), &manifest)?; + Ok(MigrationDomainResult { + domain: self.domain(), + state: MigrationDomainState::Staged, + imported: manifest.imported, + skipped: manifest.skipped, + conflicts: manifest.conflicts, + ..MigrationDomainResult::default() + }) + } + + fn validate_stage(&self, context: &DomainContext<'_>) -> LegacyMigrationResult<()> { + let manifest = read_manifest(context)?; + let source = read_state(&context.roots.legacy_ssh_root, true)?; + if source.files != manifest.source_files + || source.vault_health != manifest.source_vault_health + { + return Err(LegacyMigrationError::InvalidRequest( + "legacy SSH inputs changed after staging".to_string(), + )); + } + if let Some(receipt) = read_optional_bounded_json::( + &context.layout.stage_root(), + &receipt_path(context), + )? { + if receipt.manifest_digest != json_digest(&manifest)? { + return Err(LegacyMigrationError::InvalidRequest( + "SSH commit receipt does not match its staged manifest".to_string(), + )); + } + return validate_staged_files(context, &manifest); + } + if current_files(&context.roots.target_ssh_root)? != manifest.target_before { + return Err(LegacyMigrationError::InvalidRequest( + "current SSH data changed after staging".to_string(), + )); + } + validate_staged_files(context, &manifest) + } + + fn commit(&self, context: &DomainContext<'_>) -> LegacyMigrationResult<()> { + let manifest = read_manifest(context)?; + validate_staged_files(context, &manifest)?; + let manifest_digest = json_digest(&manifest)?; + let receipt = read_optional_bounded_json::( + &context.layout.stage_root(), + &receipt_path(context), + )?; + if let Some(receipt) = &receipt { + if receipt.manifest_digest != manifest_digest { + return Err(LegacyMigrationError::InvalidRequest( + "SSH commit receipt does not match its manifest".to_string(), + )); + } + if receipt.completed + && current_files(&context.roots.target_ssh_root)? == receipt.post_files + { + return Ok(()); + } + } else { + if current_files(&context.roots.target_ssh_root)? != manifest.target_before { + return Err(LegacyMigrationError::InvalidRequest( + "current SSH data changed before commit".to_string(), + )); + } + backup_targets(context, &manifest.target_before)?; + atomic_write_json( + &receipt_path(context), + &RemoteSshReceipt { + manifest_digest: manifest_digest.clone(), + completed: false, + post_files: BTreeMap::new(), + }, + )?; + } + let source = read_state(&context.roots.legacy_ssh_root, true)?; + if source.files != manifest.source_files { + return Err(LegacyMigrationError::InvalidRequest( + "legacy SSH inputs changed during commit".to_string(), + )); + } + let original = read_state(&backup_domain_dir(context, DOMAIN_DIR), false)?; + let staged = read_staged(context)?; + let mut outcome = RemoteSshOutcome { + imported: manifest.imported, + skipped: manifest.skipped, + conflicts: manifest.conflicts, + ..RemoteSshOutcome::default() + }; + if manifest.conflicts > 0 { + outcome.warnings.push(warning( + "ssh_conflicts_require_review", + "One or more SSH target records won or require explicit host-key review.", + )); + } + if manifest.source_files.is_empty() { + let post_files = current_files(&context.roots.target_ssh_root)?; + atomic_write_json(&outcome_path(context), &outcome)?; + return atomic_write_json( + &receipt_path(context), + &RemoteSshReceipt { + manifest_digest, + completed: true, + post_files, + }, + ); + } + fs::create_dir_all(&context.roots.target_ssh_root) + .map_err(|error| io_error(&context.roots.target_ssh_root, error))?; + owner::write_saved_connections( + &context.roots.target_ssh_root.join("ssh_connections.json"), + &staged.connections, + ) + .map_err(owner_error)?; + owner::write_remote_workspaces( + &context.roots.target_ssh_root.join("remote_workspace.json"), + &staged.workspaces, + ) + .map_err(owner_error)?; + owner::write_known_hosts( + &context.roots.target_ssh_root.join("known_hosts"), + &staged.known_hosts, + ) + .map_err(owner_error)?; + migrate_vault( + context, + &source, + &original, + &staged.connections, + &manifest, + &mut outcome, + )?; + record_unavailable_workspace_references(&staged, &mut outcome); + let post_files = current_files(&context.roots.target_ssh_root)?; + atomic_write_json(&outcome_path(context), &outcome)?; + atomic_write_json( + &receipt_path(context), + &RemoteSshReceipt { + manifest_digest, + completed: true, + post_files, + }, + ) + } + + fn validate_commit(&self, context: &DomainContext<'_>) -> LegacyMigrationResult<()> { + let receipt: RemoteSshReceipt = + read_bounded_json(&context.layout.stage_root(), &receipt_path(context))?; + if !receipt.completed + || current_files(&context.roots.target_ssh_root)? != receipt.post_files + { + return Err(LegacyMigrationError::InvalidRequest( + "current SSH owner did not retain the committed data".to_string(), + )); + } + let current = read_state(&context.roots.target_ssh_root, false)?; + if current.vault_health == VaultHealth::Invalid { + // A pre-existing invalid target vault is retained rather than + // overwritten. Its affected profiles are reported for repair. + let original = read_manifest(context)?; + if original.target_vault_health != VaultHealth::Invalid { + return Err(LegacyMigrationError::InvalidRequest( + "committed SSH password vault is not readable".to_string(), + )); + } + } + Ok(()) + } + + fn finalize_result( + &self, + context: &DomainContext<'_>, + staged: &MigrationDomainResult, + ) -> LegacyMigrationResult { + let outcome: RemoteSshOutcome = + read_bounded_json(&context.layout.stage_root(), &outcome_path(context))?; + let mut result = staged.clone(); + result.imported = outcome.imported; + result.skipped = outcome.skipped; + result.conflicts = outcome.conflicts; + result.warnings = outcome.warnings; + result.requires_reauthentication = outcome.requires_reauthentication; + result.requires_relocation = outcome.requires_relocation; + Ok(result) + } + + fn rollback_unverified(&self, context: &DomainContext<'_>) -> LegacyMigrationResult<()> { + let Some(manifest) = read_optional_bounded_json::( + &context.layout.stage_root(), + &manifest_path(context), + )? + else { + return Ok(()); + }; + let backup_root = backup_domain_dir(context, DOMAIN_DIR); + for (relative, digest) in &manifest.target_before { + restore_unverified_file( + &context.roots.target_ssh_root.join(relative), + &backup_root.join(relative), + digest.is_some(), + )?; + } + Ok(()) + } +} + +fn read_state(root: &Path, legacy: bool) -> LegacyMigrationResult { + let mut files = BTreeMap::new(); + for relative in FILES { + let path = root.join(relative); + if existing_regular( + root, + &path, + if relative.contains("vault") { + MAX_VAULT_BYTES + } else { + MAX_JSON_BYTES + }, + )? { + files.insert(relative.to_string(), file_digest(&path)?); + } + } + let connections = if files.contains_key("ssh_connections.json") { + owner::read_saved_connections(&root.join("ssh_connections.json")) + .map_err(owner_error)? + .unwrap_or_default() + } else { + Vec::new() + }; + let workspaces = if files.contains_key("remote_workspace.json") { + if legacy { + owner::read_legacy_remote_workspaces(&root.join("remote_workspace.json")) + } else { + owner::read_current_remote_workspaces(&root.join("remote_workspace.json")) + } + .map_err(owner_error)? + .unwrap_or_default() + } else { + Vec::new() + }; + let known_hosts = if files.contains_key("known_hosts") { + owner::read_known_hosts(&root.join("known_hosts")) + .map_err(owner_error)? + .unwrap_or_default() + } else { + Vec::new() + }; + let vault_health = if files.contains_key(".ssh_password_vault.key") + || files.contains_key("ssh_password_vault.json") + { + match owner::read_ssh_vault(root) { + Ok(Some(_)) => VaultHealth::Valid, + Ok(None) => VaultHealth::Absent, + Err(_) => VaultHealth::Invalid, + } + } else { + VaultHealth::Absent + }; + Ok(SshState { + files, + connections, + workspaces, + known_hosts, + vault_health, + }) +} + +fn merge_states(source: &SshState, target: &SshState) -> MergePlan { + let mut conflicts = Vec::new(); + let mut imported = 0u64; + let mut skipped = 0u64; + let mut connections = Vec::new(); + let mut vault_sources = BTreeMap::new(); + let mut target_ids = BTreeSet::new(); + let mut source_id_map = BTreeMap::new(); + let mut target_id_map = BTreeMap::new(); + + for connection in &target.connections { + let mut connection = connection.clone(); + let original_id = connection.id.clone(); + connection.id = canonical_connection_id(&connection.id); + target_id_map.insert(original_id.clone(), connection.id.clone()); + if target_ids.insert(connection.id.clone()) { + vault_sources.insert( + connection.id.clone(), + VaultSource { + origin: ProfileOrigin::Target, + original_id, + }, + ); + connections.push(connection); + } + } + for connection in &source.connections { + let mut connection = connection.clone(); + let original_id = connection.id.clone(); + connection.id = canonical_connection_id(&connection.id); + source_id_map.insert(original_id.clone(), connection.id.clone()); + if target_ids.insert(connection.id.clone()) { + imported = imported.saturating_add(1); + vault_sources.insert( + connection.id.clone(), + VaultSource { + origin: ProfileOrigin::Source, + original_id, + }, + ); + connections.push(connection); + } else { + skipped = skipped.saturating_add(1); + conflicts.push(MigrationConflict { + domain: MigrationDomainId::RemoteSsh, + code: "ssh_connection_target_wins".to_string(), + source_summary: "legacy SSH connection".to_string(), + target_summary: "current SSH connection with the same stable id".to_string(), + resolution: ConflictResolution::TargetWins, + }); + } + } + + let mut workspace_keys = BTreeSet::new(); + let mut workspaces = Vec::new(); + for (origin, values, mapping) in [ + (ProfileOrigin::Target, &target.workspaces, &target_id_map), + (ProfileOrigin::Source, &source.workspaces, &source_id_map), + ] { + for workspace in values { + let mut workspace = workspace.clone(); + workspace.connection_id = mapping + .get(&workspace.connection_id) + .cloned() + .unwrap_or_else(|| canonical_connection_id(&workspace.connection_id)); + let key = ( + workspace.connection_id.clone(), + workspace.remote_path.clone(), + ); + if workspace_keys.insert(key) { + if origin == ProfileOrigin::Source { + imported = imported.saturating_add(1); + } + workspaces.push(workspace); + } else if origin == ProfileOrigin::Source { + skipped = skipped.saturating_add(1); + } + } + } + + let mut known_hosts_by_key = BTreeMap::<(String, u16), KnownHostRecord>::new(); + for host in &target.known_hosts { + known_hosts_by_key.insert((host.host.clone(), host.port), host.clone()); + } + for host in &source.known_hosts { + let key = (host.host.clone(), host.port); + match known_hosts_by_key.get(&key) { + None => { + known_hosts_by_key.insert(key, host.clone()); + imported = imported.saturating_add(1); + } + Some(existing) + if existing.fingerprint == host.fingerprint + && existing.public_key == host.public_key => + { + skipped = skipped.saturating_add(1); + } + Some(existing) => { + skipped = skipped.saturating_add(1); + conflicts.push(MigrationConflict { + domain: MigrationDomainId::RemoteSsh, + code: "ssh_known_host_conflict".to_string(), + source_summary: format!("legacy fingerprint {}", host.fingerprint), + target_summary: format!("current fingerprint {}", existing.fingerprint), + resolution: ConflictResolution::RequiresUserAction, + }); + } + } + } + MergePlan { + connections, + workspaces, + known_hosts: known_hosts_by_key.into_values().collect(), + vault_sources, + conflicts, + imported, + skipped, + } +} + +fn migrate_vault( + context: &DomainContext<'_>, + source: &SshState, + original: &SshState, + connections: &[SavedConnectionRecord], + manifest: &RemoteSshManifest, + outcome: &mut RemoteSshOutcome, +) -> LegacyMigrationResult<()> { + let source_vault = if source.vault_health == VaultHealth::Valid { + owner::read_ssh_vault(&context.roots.legacy_ssh_root).map_err(owner_error)? + } else { + None + }; + let original_root = backup_domain_dir(context, DOMAIN_DIR); + let target_vault = if original.vault_health == VaultHealth::Valid { + owner::read_ssh_vault(&original_root).map_err(owner_error)? + } else { + None + }; + if original.vault_health == VaultHealth::Invalid { + for connection in connections + .iter() + .filter(|connection| needs_password(connection)) + { + require_ssh_reauthentication(outcome, &connection.id, "target_ssh_vault_unavailable"); + } + outcome.warnings.push(warning( + "target_ssh_vault_unavailable", + "The existing SSH password vault was retained unchanged because the current owner could not read it.", + )); + return Ok(()); + } + + let mut output = target_vault.unwrap_or_else(owner::new_ssh_vault); + let mut changed = false; + for connection in connections + .iter() + .filter(|connection| needs_password(connection)) + { + let Some(source_ref) = manifest.vault_sources.get(&connection.id) else { + require_ssh_reauthentication(outcome, &connection.id, "ssh_password_reference_missing"); + continue; + }; + let already_present = output + .decrypt(&connection.id) + .map_err(owner_error)? + .is_some(); + if already_present { + continue; + } + let credential = match source_ref.origin { + ProfileOrigin::Target => { + target_vault_entry(&output, &source_ref.original_id, &connection.id) + .map_err(owner_error)? + } + ProfileOrigin::Source => source_vault + .as_ref() + .map(|vault| vault.decrypt(&source_ref.original_id)) + .transpose() + .map_err(owner_error)? + .flatten(), + }; + if let Some(credential) = credential { + output + .store(connection.id.clone(), &credential) + .map_err(owner_error)?; + if source_ref.origin == ProfileOrigin::Target && source_ref.original_id != connection.id + { + output.remove(&source_ref.original_id); + } + changed = true; + } else { + require_ssh_reauthentication(outcome, &connection.id, "ssh_password_unavailable"); + } + } + if source.vault_health == VaultHealth::Invalid { + outcome.warnings.push(warning( + "legacy_ssh_vault_unavailable", + "SSH profiles and workspaces were retained, but one or more legacy passwords could not be transferred.", + )); + } + if changed || original.vault_health == VaultHealth::Valid { + owner::write_ssh_vault(&context.roots.target_ssh_root, &output).map_err(owner_error)?; + } + Ok(()) +} + +fn target_vault_entry( + vault: &SshVaultRecord, + original_id: &str, + canonical_id: &str, +) -> anyhow::Result> { + if original_id == canonical_id { + return vault.decrypt(canonical_id); + } + vault.decrypt(original_id) +} + +fn needs_password(connection: &SavedConnectionRecord) -> bool { + matches!(connection.auth_type, SavedAuthTypeRecord::Password) + && connection + .container + .as_ref() + .is_none_or(|container| !container.local) +} + +fn require_ssh_reauthentication(outcome: &mut RemoteSshOutcome, id: &str, code: &str) { + outcome.requires_reauthentication.push(id.to_string()); + outcome.warnings.push(MigrationDiagnostic { + code: code.to_string(), + severity: FindingSeverity::Warning, + domain: Some(MigrationDomainId::RemoteSsh), + message: "An SSH profile was retained but requires credential re-entry.".to_string(), + action: Some("Enter the credential again before reconnecting".to_string()), + ..MigrationDiagnostic::default() + }); +} + +fn record_unavailable_workspace_references(state: &SshState, outcome: &mut RemoteSshOutcome) { + let connection_ids = state + .connections + .iter() + .map(|connection| connection.id.as_str()) + .collect::>(); + for workspace in &state.workspaces { + if !connection_ids.contains(workspace.connection_id.as_str()) { + outcome + .requires_relocation + .push(workspace.connection_id.clone()); + outcome.warnings.push(warning( + "ssh_workspace_profile_missing", + "A remote workspace was retained without silently falling back to a local path, but its SSH profile needs repair.", + )); + } + } +} + +fn canonical_connection_id(id: &str) -> String { + if let Some(rest) = id.strip_prefix("ssh-") { + if let (Some(at), Some(colon)) = (rest.find('@'), rest.rfind(':')) { + if colon > at && rest[colon + 1..].parse::().is_ok() { + return format!("ssh-{}", &rest[..colon]); + } + } + } + id.to_string() +} + +fn read_staged(context: &DomainContext<'_>) -> LegacyMigrationResult { + read_state(&stage_domain_dir(context, DOMAIN_DIR), false) +} + +fn validate_staged_files( + context: &DomainContext<'_>, + manifest: &RemoteSshManifest, +) -> LegacyMigrationResult<()> { + let staged = read_staged(context)?; + let actual = [ + "ssh_connections.json", + "remote_workspace.json", + "known_hosts", + ] + .into_iter() + .map(|relative| { + let path = stage_domain_dir(context, DOMAIN_DIR).join(relative); + Ok((relative.to_string(), file_digest(&path)?)) + }) + .collect::>>()?; + if actual != manifest.staged_files + || staged.files.len() != 3 + || staged.vault_health != VaultHealth::Absent + { + return Err(LegacyMigrationError::InvalidRequest( + "staged SSH owner data differs from its manifest".to_string(), + )); + } + Ok(()) +} + +fn current_files(root: &Path) -> LegacyMigrationResult>> { + FILES + .into_iter() + .map(|relative| { + let path = root.join(relative); + let digest = match fs::symlink_metadata(&path) { + Ok(_) => { + validate_regular_file(root, &path)?; + Some(file_digest(&path)?) + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, + Err(error) => return Err(io_error(&path, error)), + }; + Ok((relative.to_string(), digest)) + }) + .collect() +} + +fn backup_targets( + context: &DomainContext<'_>, + target_before: &BTreeMap>, +) -> LegacyMigrationResult<()> { + let backup_root = backup_domain_dir(context, DOMAIN_DIR); + for (relative, digest) in target_before { + if digest.is_some() { + let target = context.roots.target_ssh_root.join(relative); + let backup = backup_root.join(relative); + if matches!( + relative.as_str(), + ".ssh_password_vault.key" | "ssh_password_vault.json" + ) { + if !backup.exists() { + let bytes = fs::read(&target).map_err(|error| io_error(&target, error))?; + owner::write_private_bytes(&backup, &bytes).map_err(owner_error)?; + } + } else { + backup_file_once(&target, &backup)?; + } + } + } + Ok(()) +} + +fn existing_regular(root: &Path, path: &Path, max_bytes: u64) -> LegacyMigrationResult { + match fs::symlink_metadata(path) { + Ok(_) => { + validate_regular_file(root, path)?; + let bytes = fs::metadata(path) + .map_err(|error| io_error(path, error))? + .len(); + if bytes > max_bytes { + return Err(LegacyMigrationError::ResourceLimit(format!( + "SSH persistence file exceeds {max_bytes} bytes: {}", + relative_display(root, path) + ))); + } + Ok(true) + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(error) => Err(io_error(path, error)), + } +} + +fn file_digest(path: &Path) -> LegacyMigrationResult { + let bytes = fs::read(path).map_err(|error| io_error(path, error))?; + Ok(format!("sha256:{}", hex::encode(Sha256::digest(bytes)))) +} + +fn json_digest(value: &impl Serialize) -> LegacyMigrationResult { + let bytes = serde_json::to_vec(value).map_err(|error| { + LegacyMigrationError::InvalidRequest(format!("serialize SSH manifest: {error}")) + })?; + Ok(format!("sha256:{}", hex::encode(Sha256::digest(bytes)))) +} + +fn total_bytes<'a>( + root: &Path, + paths: impl IntoIterator, +) -> LegacyMigrationResult { + let mut total = 0u64; + for relative in paths { + let path = root.join(relative); + total = total.saturating_add( + fs::metadata(&path) + .map_err(|error| io_error(&path, error))? + .len(), + ); + } + Ok(total) +} + +fn warning(code: &str, message: &str) -> MigrationDiagnostic { + MigrationDiagnostic { + code: code.to_string(), + severity: FindingSeverity::Warning, + domain: Some(MigrationDomainId::RemoteSsh), + message: message.to_string(), + action: Some("Review the SSH profile before reconnecting".to_string()), + ..MigrationDiagnostic::default() + } +} + +fn owner_error(error: anyhow::Error) -> LegacyMigrationError { + LegacyMigrationError::InvalidRequest(format!( + "Remote SSH persistence owner rejected the data: {error:#}" + )) +} + +fn reset_stage(context: &DomainContext<'_>) -> LegacyMigrationResult<()> { + let path = stage_domain_dir(context, DOMAIN_DIR); + match fs::remove_dir_all(&path) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(io_error(&path, error)), + } + fs::create_dir_all(&path).map_err(|error| io_error(&path, error)) +} + +fn manifest_path(context: &DomainContext<'_>) -> PathBuf { + stage_domain_dir(context, DOMAIN_DIR).join("manifest.json") +} + +fn receipt_path(context: &DomainContext<'_>) -> PathBuf { + stage_domain_dir(context, DOMAIN_DIR).join("commit-receipt.json") +} + +fn outcome_path(context: &DomainContext<'_>) -> PathBuf { + stage_domain_dir(context, DOMAIN_DIR).join("outcome.json") +} + +fn read_manifest(context: &DomainContext<'_>) -> LegacyMigrationResult { + read_bounded_json(&context.layout.stage_root(), &manifest_path(context)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::legacy_migration::adapters_for_groups; + use openbitfun_legacy_migration::{ + probe_legacy_source, CancellationToken, MigrationEngine, NoCrashInjection, ProbeLimits, + }; + use openbitfun_product_domains::legacy_migration::{ + MigrationGroupId, MigrationRunStatus, MigrationSelection, + }; + + #[test] + fn ssh_merge_rewrites_reference_closure_and_keeps_target_host_key() { + let temp = test_tempdir("merge"); + let roots = fixture_roots(temp.path()); + seed_probe_source(&roots); + fs::create_dir_all(&roots.legacy_ssh_root).unwrap(); + fs::create_dir_all(&roots.target_ssh_root).unwrap(); + let source_connection = connection("ssh-user@example.invalid:22"); + owner::write_saved_connections( + &roots.legacy_ssh_root.join("ssh_connections.json"), + std::slice::from_ref(&source_connection), + ) + .unwrap(); + fs::write( + roots.legacy_ssh_root.join("remote_workspace.json"), + serde_json::to_vec_pretty(&workspace("ssh-user@example.invalid:22")).unwrap(), + ) + .unwrap(); + owner::write_known_hosts( + &roots.legacy_ssh_root.join("known_hosts"), + &[known_host("SHA256:legacy", "legacy-public-key")], + ) + .unwrap(); + let mut source_vault = owner::new_ssh_vault(); + source_vault + .store("ssh-user@example.invalid:22".to_string(), "legacy-password") + .unwrap(); + owner::write_ssh_vault(&roots.legacy_ssh_root, &source_vault).unwrap(); + owner::write_known_hosts( + &roots.target_ssh_root.join("known_hosts"), + &[known_host("SHA256:current", "current-public-key")], + ) + .unwrap(); + let before = source_hashes(&roots.legacy_ssh_root); + + let report = run_remote_group(&roots); + assert_eq!(report.status, MigrationRunStatus::CompletedWithWarnings); + let connections = + owner::read_saved_connections(&roots.target_ssh_root.join("ssh_connections.json")) + .unwrap() + .unwrap(); + assert_eq!(connections.len(), 1); + assert_eq!(connections[0].id, "ssh-user@example.invalid"); + let workspaces = owner::read_current_remote_workspaces( + &roots.target_ssh_root.join("remote_workspace.json"), + ) + .unwrap() + .unwrap(); + assert_eq!(workspaces[0].connection_id, "ssh-user@example.invalid"); + assert_eq!(workspaces[0].remote_path, "/srv/project"); + let known_hosts = owner::read_known_hosts(&roots.target_ssh_root.join("known_hosts")) + .unwrap() + .unwrap(); + assert_eq!(known_hosts[0].fingerprint, "SHA256:current"); + let vault = owner::read_ssh_vault(&roots.target_ssh_root) + .unwrap() + .expect("migrated vault"); + assert_eq!( + vault + .decrypt("ssh-user@example.invalid") + .unwrap() + .as_deref(), + Some("legacy-password") + ); + assert!(vault + .decrypt("ssh-user@example.invalid:22") + .unwrap() + .is_none()); + assert_eq!(source_hashes(&roots.legacy_ssh_root), before); + let report_json = serde_json::to_string(&report).unwrap(); + assert!(!report_json.contains("legacy-password")); + assert_stage_redacted(&roots, &report.run_id, "legacy-password"); + let ssh = report + .domain_results + .iter() + .find(|result| result.domain == MigrationDomainId::RemoteSsh) + .unwrap(); + assert!(ssh.conflicts >= 1); + } + + #[test] + fn corrupt_vault_retains_profiles_and_requests_reauthentication() { + let temp = test_tempdir("corrupt-vault"); + let roots = fixture_roots(temp.path()); + seed_probe_source(&roots); + fs::create_dir_all(&roots.legacy_ssh_root).unwrap(); + owner::write_saved_connections( + &roots.legacy_ssh_root.join("ssh_connections.json"), + &[connection("ssh-user@example.invalid:22")], + ) + .unwrap(); + fs::write( + roots.legacy_ssh_root.join(".ssh_password_vault.key"), + [0u8; 7], + ) + .unwrap(); + fs::write( + roots.legacy_ssh_root.join("ssh_password_vault.json"), + r#"{"entries":{"ssh-user@example.invalid:22":"invalid"}}"#, + ) + .unwrap(); + + let report = run_remote_group(&roots); + assert_eq!(report.status, MigrationRunStatus::CompletedWithWarnings); + let connections = + owner::read_saved_connections(&roots.target_ssh_root.join("ssh_connections.json")) + .unwrap() + .unwrap(); + assert_eq!(connections[0].id, "ssh-user@example.invalid"); + assert!(report + .requires_reauthentication + .contains(&"ssh-user@example.invalid".to_string())); + assert!(!roots + .target_ssh_root + .join("ssh_password_vault.json") + .exists()); + } + + #[test] + fn canonical_remote_fixture_is_read_by_current_owners_after_migration() { + let temp = test_tempdir("canonical-fixture"); + let fixture = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../services/legacy-migration/tests/fixtures/v0.2.19"); + let roots = MigrationRoots { + legacy_user_root: fixture.join("user-root"), + legacy_home_root: fixture.join("home"), + legacy_skills_root: fixture.join("user-root/skills"), + legacy_ssh_root: fixture.join("ssh"), + target_user_root: temp.path().join("target/user"), + target_home_root: temp.path().join("target/home"), + target_skills_root: temp.path().join("target/skills"), + target_ssh_root: temp.path().join("target/ssh"), + }; + let report = run_remote_group(&roots); + assert!(matches!( + report.status, + MigrationRunStatus::Completed | MigrationRunStatus::CompletedWithWarnings + )); + let identity = + owner::read_device_identity(&roots.target_home_root.join("device_identity.json")) + .unwrap() + .unwrap(); + assert_eq!(identity.device_id, "0123456789abcdef0123456789abcdef"); + let bot = owner::read_bot_persistence( + &roots + .target_home_root + .join("remote_connect_persistence.json"), + ) + .unwrap() + .unwrap(); + assert!(bot.connections[0].chat_state.account_remote_context); + let connections = + owner::read_saved_connections(&roots.target_ssh_root.join("ssh_connections.json")) + .unwrap() + .unwrap(); + assert_eq!(connections[0].id, "ssh-fixture@example.invalid"); + let workspaces = owner::read_current_remote_workspaces( + &roots.target_ssh_root.join("remote_workspace.json"), + ) + .unwrap() + .unwrap(); + assert_eq!(workspaces[0].connection_id, connections[0].id); + assert_eq!(workspaces[0].remote_path, "/srv/fixture-workspace"); + assert_eq!( + owner::read_known_hosts(&roots.target_ssh_root.join("known_hosts")) + .unwrap() + .unwrap() + .len(), + 1 + ); + } + + fn connection(id: &str) -> SavedConnectionRecord { + SavedConnectionRecord { + id: id.to_string(), + name: "fixture connection".to_string(), + host: "example.invalid".to_string(), + port: 22, + username: "user".to_string(), + auth_type: SavedAuthTypeRecord::Password, + default_workspace: Some("/srv/project".to_string()), + last_connected: Some(1), + proxy_jump: Some("jump.example.invalid".to_string()), + container: None, + options: owner::SshConnectionOptionsRecord::default(), + } + } + + fn workspace(connection_id: &str) -> RemoteWorkspaceRecord { + RemoteWorkspaceRecord { + connection_id: connection_id.to_string(), + remote_path: "/srv/project".to_string(), + connection_name: "fixture connection".to_string(), + ssh_host: "example.invalid".to_string(), + } + } + + fn known_host(fingerprint: &str, public_key: &str) -> KnownHostRecord { + KnownHostRecord { + host: "example.invalid".to_string(), + port: 22, + key_type: "ssh-ed25519".to_string(), + fingerprint: fingerprint.to_string(), + public_key: public_key.to_string(), + } + } + + fn run_remote_group( + roots: &MigrationRoots, + ) -> openbitfun_product_domains::legacy_migration::MigrationRunReport { + let source = probe_legacy_source(roots, ProbeLimits::default()) + .unwrap() + .expect("legacy source"); + let selection = MigrationSelection { + groups: BTreeSet::from([MigrationGroupId::RemoteConnectionsAndDevices]), + }; + let engine = MigrationEngine::new(roots.clone(), adapters_for_groups(&selection)).unwrap(); + let plan = engine + .plan(&source, selection, &CancellationToken::default()) + .unwrap(); + engine + .execute(&plan, &CancellationToken::default(), &NoCrashInjection) + .unwrap() + } + + fn seed_probe_source(roots: &MigrationRoots) { + let path = roots.legacy_user_root.join("config/app.json"); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, r#"{"version":"0.2.19"}"#).unwrap(); + } + + fn fixture_roots(root: &Path) -> MigrationRoots { + 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/skills"), + target_ssh_root: root.join("target/ssh"), + } + } + + fn source_hashes(root: &Path) -> BTreeMap { + FILES + .into_iter() + .filter_map(|relative| { + let path = root.join(relative); + path.exists() + .then(|| (relative.to_string(), file_digest(&path).unwrap())) + }) + .collect() + } + + fn assert_stage_redacted(roots: &MigrationRoots, run_id: &str, secret: &str) { + let stage = roots + .migration_root() + .join("runs") + .join(run_id) + .join("stage/remote-ssh"); + for entry in fs::read_dir(stage).unwrap() { + let path = entry.unwrap().path(); + if path.is_file() { + assert!(!fs::read_to_string(path).unwrap().contains(secret)); + } + } + } + + fn test_tempdir(label: &str) -> tempfile::TempDir { + let root = std::env::var_os("OPENBITFUN_TEST_TMPDIR") + .map(PathBuf::from) + .unwrap_or_else(std::env::temp_dir); + fs::create_dir_all(&root).unwrap(); + tempfile::Builder::new() + .prefix(&format!("remote-ssh-migration-{label}-")) + .tempdir_in(root) + .unwrap() + } +} diff --git a/src/crates/assembly/core/src/legacy_migration/settings.rs b/src/crates/assembly/core/src/legacy_migration/settings.rs new file mode 100644 index 0000000000..071c17fe91 --- /dev/null +++ b/src/crates/assembly/core/src/legacy_migration/settings.rs @@ -0,0 +1,1232 @@ +use super::common::{ + backup_domain_dir, backup_file_once, read_bounded_json, read_optional_bounded_json, + restore_unverified_file, stage_domain_dir, +}; +use crate::service::config::manager::validate_current_config_value; +use crate::service::config::types::{AIModelConfig, GlobalConfig}; +use openbitfun_legacy_migration::{ + atomic_write_json, DomainContext, DomainScan, LegacyDomainAdapter, LegacyMigrationError, + LegacyMigrationResult, MigrationRoots, +}; +use openbitfun_product_domains::legacy_migration::{ + ConflictResolution, FindingSeverity, MigrationConflict, MigrationDiagnostic, MigrationDomainId, + MigrationDomainResult, MigrationDomainState, ScanFinding, +}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::BTreeSet; +use std::fs; +use std::path::PathBuf; + +const SOURCE_SCHEMA: &str = "bitfun.config.v1"; +const TARGET_SCHEMA: &str = "openbitfun.config.current"; + +pub(crate) struct SettingsAdapter; +pub(crate) struct CredentialsAdapter; + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct StagedSettings { + target_existed: bool, + imported: u64, + skipped: u64, + conflicts: u64, + config: GlobalConfig, +} + +#[derive(Debug, Default, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +struct CredentialManifest { + target_existed: bool, + model_ids: Vec, + voice_call: bool, + mcp_servers: bool, + unsupported_secret_fields: Vec, +} + +#[derive(Default)] +struct MergeOutcome { + imported: u64, + skipped: u64, + conflicts: Vec, +} + +impl LegacyDomainAdapter for SettingsAdapter { + fn domain(&self) -> MigrationDomainId { + MigrationDomainId::Settings + } + + fn scan(&self, roots: &MigrationRoots) -> LegacyMigrationResult { + let source = read_source_config(roots)?; + let target = read_target_config(roots)?; + let (_, outcome) = merge_settings(&source, target)?; + let bytes = serde_json::to_vec(&source) + .map_err(|error| LegacyMigrationError::InvalidRequest(error.to_string()))?; + Ok(DomainScan { + finding: ScanFinding { + domain: self.domain(), + code: "legacy_settings_supported".to_string(), + severity: FindingSeverity::Info, + entity_count: outcome.imported + outcome.skipped, + logical_bytes: bytes.len() as u64, + source_schema: Some(SOURCE_SCHEMA.to_string()), + migratable: true, + detail: "Supported legacy settings will be converted through the current configuration contract." + .to_string(), + }, + conflicts: outcome.conflicts, + target_schema: Some(TARGET_SCHEMA.to_string()), + dependencies: Vec::new(), + }) + } + + fn stage(&self, context: &DomainContext<'_>) -> LegacyMigrationResult { + let source = read_source_config(context.roots)?; + let target_path = target_config_path(context.roots); + let target_existed = target_path.exists(); + let target = read_target_config(context.roots)?; + let (config, outcome) = merge_settings(&source, target)?; + validate_current_config(&config, "staged legacy configuration")?; + let staged = StagedSettings { + target_existed, + imported: outcome.imported, + skipped: outcome.skipped, + conflicts: outcome.conflicts.len() as u64, + config, + }; + atomic_write_json(&settings_stage_path(context), &staged)?; + Ok(MigrationDomainResult { + domain: self.domain(), + state: MigrationDomainState::Staged, + imported: staged.imported, + skipped: staged.skipped, + conflicts: staged.conflicts, + ..MigrationDomainResult::default() + }) + } + + fn validate_stage(&self, context: &DomainContext<'_>) -> LegacyMigrationResult<()> { + let staged: StagedSettings = + read_bounded_json(&context.layout.stage_root(), &settings_stage_path(context))?; + validate_current_config(&staged.config, "staged legacy configuration") + } + + fn commit(&self, context: &DomainContext<'_>) -> LegacyMigrationResult<()> { + let staged: StagedSettings = + read_bounded_json(&context.layout.stage_root(), &settings_stage_path(context))?; + let target = target_config_path(context.roots); + backup_file_once(&target, &settings_backup_path(context))?; + atomic_write_json(&target, &staged.config) + } + + fn validate_commit(&self, context: &DomainContext<'_>) -> LegacyMigrationResult<()> { + let expected: StagedSettings = + read_bounded_json(&context.layout.stage_root(), &settings_stage_path(context))?; + let actual = read_target_config(context.roots)?; + validate_current_config(&actual, "committed legacy configuration")?; + let expected_value = serde_json::to_value(expected.config) + .map_err(|error| LegacyMigrationError::InvalidRequest(error.to_string()))?; + let actual_value = serde_json::to_value(actual) + .map_err(|error| LegacyMigrationError::InvalidRequest(error.to_string()))?; + if actual_value != expected_value { + return Err(LegacyMigrationError::InvalidRequest( + "committed configuration does not match the staged owner model".to_string(), + )); + } + Ok(()) + } + + fn rollback_unverified(&self, context: &DomainContext<'_>) -> LegacyMigrationResult<()> { + let staged = read_optional_bounded_json::( + &context.layout.stage_root(), + &settings_stage_path(context), + )?; + if let Some(staged) = staged { + restore_unverified_file( + &target_config_path(context.roots), + &settings_backup_path(context), + staged.target_existed, + )?; + } + Ok(()) + } +} + +impl LegacyDomainAdapter for CredentialsAdapter { + fn domain(&self) -> MigrationDomainId { + MigrationDomainId::Credentials + } + + fn scan(&self, roots: &MigrationRoots) -> LegacyMigrationResult { + let source = read_source_config(roots)?; + let manifest = credential_manifest(&source, target_config_path(roots).exists()); + let count = manifest.model_ids.len() as u64 + + u64::from(manifest.voice_call) + + u64::from(manifest.mcp_servers); + Ok(DomainScan { + finding: ScanFinding { + domain: self.domain(), + code: if count == 0 { + "legacy_credentials_absent" + } else { + "legacy_credentials_supported" + } + .to_string(), + severity: if manifest.unsupported_secret_fields.is_empty() { + FindingSeverity::Info + } else { + FindingSeverity::Warning + }, + entity_count: count, + logical_bytes: 0, + source_schema: Some(SOURCE_SCHEMA.to_string()), + migratable: true, + detail: "Credentials are read only during commit and are never written to migration staging or reports." + .to_string(), + }, + conflicts: Vec::new(), + target_schema: Some(TARGET_SCHEMA.to_string()), + dependencies: vec![MigrationDomainId::Settings], + }) + } + + fn stage(&self, context: &DomainContext<'_>) -> LegacyMigrationResult { + let source = read_source_config(context.roots)?; + let manifest = credential_manifest(&source, target_config_path(context.roots).exists()); + atomic_write_json(&credentials_stage_path(context), &manifest)?; + let warnings = manifest + .unsupported_secret_fields + .iter() + .map(|field| MigrationDiagnostic { + code: "credential_requires_reauthentication".to_string(), + severity: FindingSeverity::Warning, + domain: Some(self.domain()), + relative_path: None, + message: format!("Credential metadata at {field} is not safely portable."), + action: Some("Enter the credential again in OpenBitFun.".to_string()), + }) + .collect::>(); + Ok(MigrationDomainResult { + domain: self.domain(), + state: MigrationDomainState::Staged, + imported: manifest.model_ids.len() as u64 + + u64::from(manifest.voice_call) + + u64::from(manifest.mcp_servers), + skipped: manifest.unsupported_secret_fields.len() as u64, + warnings, + requires_reauthentication: manifest.unsupported_secret_fields.clone(), + ..MigrationDomainResult::default() + }) + } + + fn validate_stage(&self, context: &DomainContext<'_>) -> LegacyMigrationResult<()> { + let bytes = fs::read(credentials_stage_path(context)).map_err(|error| { + LegacyMigrationError::InvalidRequest(format!("credential stage is unreadable: {error}")) + })?; + let manifest: CredentialManifest = serde_json::from_slice(&bytes).map_err(|error| { + LegacyMigrationError::InvalidRequest(format!("credential stage is invalid: {error}")) + })?; + let source = read_source_config(context.roots)?; + let mut secret_values = Vec::new(); + collect_secret_values(&source, &mut secret_values); + for secret in secret_values.into_iter().filter(|secret| secret.len() >= 4) { + if bytes + .windows(secret.len()) + .any(|window| window == secret.as_bytes()) + { + return Err(LegacyMigrationError::InvalidRequest( + "credential staging contains a prohibited secret value".to_string(), + )); + } + } + if manifest.model_ids.iter().any(|id| id.trim().is_empty()) { + return Err(LegacyMigrationError::InvalidRequest( + "credential staging contains an empty logical model id".to_string(), + )); + } + Ok(()) + } + + fn commit(&self, context: &DomainContext<'_>) -> LegacyMigrationResult<()> { + let source = read_source_config(context.roots)?; + let target_path = target_config_path(context.roots); + let mut target = read_target_config(context.roots)?; + let manifest: CredentialManifest = read_bounded_json( + &context.layout.stage_root(), + &credentials_stage_path(context), + )?; + backup_file_once(&target_path, &credentials_backup_path(context))?; + + if let Some(models) = source.pointer("/ai/models").and_then(Value::as_array) { + for source_model in models { + let Some(id) = source_model.get("id").and_then(Value::as_str) else { + continue; + }; + if !manifest.model_ids.iter().any(|candidate| candidate == id) { + continue; + } + let source_model = serde_json::from_value::(source_model.clone()) + .map_err(|error| { + LegacyMigrationError::UnsupportedSource(format!( + "legacy model credential owner record is invalid: {error}" + )) + })?; + if let Some(target_model) = target.ai.models.iter_mut().find(|model| model.id == id) + { + if target_model.api_key.is_empty() { + target_model.api_key = source_model.api_key; + } + if target_model + .custom_headers + .as_ref() + .is_none_or(|headers| headers.is_empty()) + { + target_model.custom_headers = source_model.custom_headers; + } + if target_model + .custom_request_body + .as_deref() + .is_none_or(str::is_empty) + { + target_model.custom_request_body = source_model.custom_request_body; + } + } + } + } + if manifest.voice_call && target.app.voice_call.api_key.is_empty() { + if let Some(secret) = source + .pointer("/app/voice_call/api_key") + .and_then(Value::as_str) + { + target.app.voice_call.api_key = secret.to_string(); + } + } + if manifest.mcp_servers && target.mcp_servers.is_none() { + target.mcp_servers = source + .get("mcp_servers") + .filter(|value| !value.is_null()) + .cloned(); + } + validate_current_config(&target, "credential migration target")?; + atomic_write_json(&target_path, &target) + } + + fn validate_commit(&self, context: &DomainContext<'_>) -> LegacyMigrationResult<()> { + let source = read_source_config(context.roots)?; + let target = read_target_config(context.roots)?; + let manifest: CredentialManifest = read_bounded_json( + &context.layout.stage_root(), + &credentials_stage_path(context), + )?; + validate_current_config(&target, "committed credential configuration")?; + for id in &manifest.model_ids { + let source_model = source + .pointer("/ai/models") + .and_then(Value::as_array) + .and_then(|models| { + models + .iter() + .find(|model| model.get("id").and_then(Value::as_str) == Some(id.as_str())) + }) + .cloned() + .map(serde_json::from_value::) + .transpose() + .map_err(|error| { + LegacyMigrationError::UnsupportedSource(format!( + "legacy model credential owner record is invalid: {error}" + )) + })?; + let target_model = target.ai.models.iter().find(|model| model.id == *id); + let Some(source_model) = source_model else { + continue; + }; + let Some(target_model) = target_model else { + return Err(LegacyMigrationError::InvalidRequest(format!( + "credential owner model {id} was not committed" + ))); + }; + if !source_model.api_key.is_empty() && target_model.api_key.is_empty() { + return Err(LegacyMigrationError::InvalidRequest(format!( + "credential for model {id} was not committed" + ))); + } + if source_model + .custom_headers + .as_ref() + .is_some_and(|headers| !headers.is_empty()) + && target_model + .custom_headers + .as_ref() + .is_none_or(|headers| headers.is_empty()) + { + return Err(LegacyMigrationError::InvalidRequest(format!( + "custom headers for model {id} were not committed" + ))); + } + if source_model + .custom_request_body + .as_deref() + .is_some_and(|body| !body.is_empty()) + && target_model + .custom_request_body + .as_deref() + .is_none_or(str::is_empty) + { + return Err(LegacyMigrationError::InvalidRequest(format!( + "custom request body for model {id} was not committed" + ))); + } + } + if manifest.mcp_servers && target.mcp_servers.is_none() { + return Err(LegacyMigrationError::InvalidRequest( + "MCP server configuration was not committed".to_string(), + )); + } + Ok(()) + } + + fn rollback_unverified(&self, context: &DomainContext<'_>) -> LegacyMigrationResult<()> { + let manifest = read_optional_bounded_json::( + &context.layout.stage_root(), + &credentials_stage_path(context), + )?; + if let Some(manifest) = manifest { + restore_unverified_file( + &target_config_path(context.roots), + &credentials_backup_path(context), + manifest.target_existed, + )?; + } + Ok(()) + } +} + +fn read_source_config(roots: &MigrationRoots) -> LegacyMigrationResult { + read_bounded_json(&roots.legacy_user_root, &source_config_path(roots)) +} + +fn read_target_config(roots: &MigrationRoots) -> LegacyMigrationResult { + let path = target_config_path(roots); + let value = read_optional_bounded_json::(&roots.target_user_root, &path)?; + match value { + Some(value) => { + validate_current_config_value(&value, "legacy migration target configuration") + .map_err(|error| LegacyMigrationError::InvalidRequest(error.to_string()))?; + serde_json::from_value(value).map_err(|error| { + LegacyMigrationError::InvalidRequest(format!( + "target configuration does not match the current owner model: {error}" + )) + }) + } + None => Ok(GlobalConfig::default()), + } +} + +fn merge_settings( + source: &Value, + target: GlobalConfig, +) -> LegacyMigrationResult<(GlobalConfig, MergeOutcome)> { + let (mut source_config, mut source_value) = convert_source_config(source)?; + let defaults = GlobalConfig::default(); + let mut outcome = MergeOutcome::default(); + let source_models = std::mem::take(&mut source_config.ai.models); + let mut target_value = serde_json::to_value(&target) + .map_err(|error| LegacyMigrationError::InvalidRequest(error.to_string()))?; + let default_value = serde_json::to_value(&defaults) + .map_err(|error| LegacyMigrationError::InvalidRequest(error.to_string()))?; + if let Some(ai) = source_value.get_mut("ai").and_then(Value::as_object_mut) { + ai.remove("models"); + } + merge_compatible_value( + "", + &source_value, + &mut target_value, + Some(&default_value), + &mut outcome, + ); + let mut merged: GlobalConfig = serde_json::from_value(target_value).map_err(|error| { + LegacyMigrationError::InvalidRequest(format!( + "merged configuration does not match the current owner model: {error}" + )) + })?; + merge_models(source_models, &mut merged.ai.models, &mut outcome)?; + merged.product_id = defaults.product_id; + merged.schema_version = defaults.schema_version; + merged.version = defaults.version; + merged.last_modified = chrono::Utc::now(); + Ok((merged, outcome)) +} + +fn convert_source_config(source: &Value) -> LegacyMigrationResult<(GlobalConfig, Value)> { + validate_source_version(source)?; + let defaults = GlobalConfig::default(); + let mut normalized_source = source.clone(); + normalize_legacy_config_value(&mut normalized_source); + strip_staged_credentials(&mut normalized_source); + let normalized_root = normalized_source.as_object_mut().ok_or_else(|| { + LegacyMigrationError::InvalidRequest( + "legacy configuration root is not an object".to_string(), + ) + })?; + for field in ["product_id", "schema_version", "version", "last_modified"] { + normalized_root.remove(field); + } + + let mut converted = serde_json::to_value(&defaults) + .map_err(|error| LegacyMigrationError::InvalidRequest(error.to_string()))?; + overlay_compatible_value(&mut converted, &normalized_source); + let root = converted.as_object_mut().ok_or_else(|| { + LegacyMigrationError::InvalidRequest( + "legacy configuration root is not an object".to_string(), + ) + })?; + root.insert( + "product_id".to_string(), + Value::String(defaults.product_id.clone()), + ); + root.insert( + "schema_version".to_string(), + Value::from(defaults.schema_version), + ); + root.insert( + "version".to_string(), + Value::String(defaults.version.clone()), + ); + root.insert( + "last_modified".to_string(), + Value::from(chrono::Utc::now().timestamp_millis()), + ); + let config: GlobalConfig = serde_json::from_value(converted).map_err(|error| { + LegacyMigrationError::UnsupportedSource(format!( + "legacy configuration cannot be represented by the current owner model: {error}" + )) + })?; + let mut compatible_source = serde_json::to_value(&config) + .map_err(|error| LegacyMigrationError::InvalidRequest(error.to_string()))?; + retain_source_fields(&mut compatible_source, &normalized_source); + Ok((config, compatible_source)) +} + +fn overlay_compatible_value(target: &mut Value, source: &Value) { + match (target, source) { + (Value::Object(target_fields), Value::Object(source_fields)) => { + for (name, source_value) in source_fields { + if let Some(target_value) = target_fields.get_mut(name) { + overlay_compatible_value(target_value, source_value); + } else { + target_fields.insert(name.clone(), source_value.clone()); + } + } + } + (target, source) => *target = source.clone(), + } +} + +fn retain_source_fields(value: &mut Value, source: &Value) { + let (Value::Object(fields), Value::Object(source_fields)) = (value, source) else { + return; + }; + fields.retain(|name, value| { + let Some(source_value) = source_fields.get(name) else { + return false; + }; + retain_source_fields(value, source_value); + true + }); +} + +fn normalize_legacy_config_value(value: &mut Value) { + if let Some(appearance) = value.get_mut("appearance").and_then(Value::as_object_mut) { + let selection = appearance + .get("selection") + .or_else(|| appearance.get("theme_id")) + .and_then(Value::as_str) + .map(canonical_product_id); + if let Some(selection) = selection { + appearance.insert("selection".to_string(), Value::String(selection)); + } + appearance.remove("theme_id"); + } + + let Some(ai) = value.get_mut("ai").and_then(Value::as_object_mut) else { + return; + }; + if let Some(profiles) = ai.get_mut("agent_profiles").and_then(Value::as_object_mut) { + for (profile_id, profile) in profiles { + let Some(profile) = profile.as_object_mut() else { + continue; + }; + if profile + .get("profile_id") + .and_then(Value::as_str) + .is_none_or(str::is_empty) + { + profile.insert("profile_id".to_string(), Value::String(profile_id.clone())); + } + if let Some(enabled) = profile.remove("enabled_skills") { + profile + .entry("enabled_user_skills".to_string()) + .or_insert(enabled); + } + canonicalize_string_list(profile.get_mut("enabled_user_skills")); + canonicalize_string_list(profile.get_mut("disabled_user_skills")); + } + } + if let Some(skill_settings) = ai.get_mut("skill_settings").and_then(Value::as_object_mut) { + canonicalize_string_list(skill_settings.get_mut("globally_disabled_user_skills")); + } +} + +fn canonicalize_string_list(value: Option<&mut Value>) { + let Some(values) = value.and_then(Value::as_array_mut) else { + return; + }; + for value in values { + if let Some(text) = value.as_str() { + *value = Value::String(canonical_product_id(text)); + } + } +} + +fn strip_staged_credentials(value: &mut Value) { + if let Some(root) = value.as_object_mut() { + root.remove("mcp_servers"); + } + if let Some(models) = value + .pointer_mut("/ai/models") + .and_then(Value::as_array_mut) + { + for model in models { + let Some(model) = model.as_object_mut() else { + continue; + }; + model.remove("api_key"); + model.remove("custom_headers"); + model.remove("custom_request_body"); + } + } + if let Some(voice_call) = value + .pointer_mut("/app/voice_call") + .and_then(Value::as_object_mut) + { + voice_call.remove("api_key"); + } +} + +fn merge_compatible_value( + path: &str, + source: &Value, + target: &mut Value, + default: Option<&Value>, + outcome: &mut MergeOutcome, +) { + if source == target { + return; + } + if let (Value::Object(source_fields), Value::Object(target_fields)) = (source, &mut *target) { + let default_fields = default.and_then(Value::as_object); + for (name, source_value) in source_fields { + let child_path = if path.is_empty() { + name.clone() + } else { + format!("{path}.{name}") + }; + if let Some(target_value) = target_fields.get_mut(name) { + merge_compatible_value( + &child_path, + source_value, + target_value, + default_fields.and_then(|fields| fields.get(name)), + outcome, + ); + } else { + target_fields.insert(name.clone(), source_value.clone()); + outcome.imported = outcome.imported.saturating_add(1); + } + } + return; + } + + if default.is_some_and(|default| target == default) { + *target = source.clone(); + outcome.imported = outcome.imported.saturating_add(1); + } else { + record_target_wins(path, outcome); + } +} + +fn merge_models( + source_models: Vec, + target_models: &mut Vec, + outcome: &mut MergeOutcome, +) -> LegacyMigrationResult<()> { + for model in source_models { + if model.id.trim().is_empty() { + outcome.skipped = outcome.skipped.saturating_add(1); + continue; + } + if let Some(existing) = target_models + .iter() + .find(|existing| existing.id == model.id) + { + let existing = settings_only_model_value(existing)?; + let source = settings_only_model_value(&model)?; + if existing != source { + record_target_wins(&format!("ai.models.{}", model.id), outcome); + } + } else { + target_models.push(model); + outcome.imported = outcome.imported.saturating_add(1); + } + } + Ok(()) +} + +fn settings_only_model_value(model: &AIModelConfig) -> LegacyMigrationResult { + let mut value = serde_json::to_value(model) + .map_err(|error| LegacyMigrationError::InvalidRequest(error.to_string()))?; + strip_model_credentials(&mut value); + Ok(value) +} + +fn strip_model_credentials(value: &mut Value) { + let Some(model) = value.as_object_mut() else { + return; + }; + model.remove("api_key"); + model.remove("custom_headers"); + model.remove("custom_request_body"); +} + +fn credential_manifest(source: &Value, target_existed: bool) -> CredentialManifest { + let model_ids = source + .pointer("/ai/models") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter(|model| model_has_portable_credentials(model)) + .filter_map(|model| model.get("id").and_then(Value::as_str)) + .filter(|id| !id.trim().is_empty()) + .map(str::to_string) + .collect::>() + .into_iter() + .collect(); + let voice_call = source + .pointer("/app/voice_call/api_key") + .and_then(Value::as_str) + .is_some_and(|secret| !secret.is_empty()); + let mcp_servers = source + .get("mcp_servers") + .is_some_and(|value| !value.is_null()); + let mut unsupported_secret_fields = Vec::new(); + collect_unsupported_secret_fields(source, "", &mut unsupported_secret_fields); + unsupported_secret_fields.retain(|path| !is_portable_credential_path(path)); + unsupported_secret_fields.sort(); + unsupported_secret_fields.dedup(); + CredentialManifest { + target_existed, + model_ids, + voice_call, + mcp_servers, + unsupported_secret_fields, + } +} + +fn model_has_portable_credentials(model: &Value) -> bool { + model + .get("api_key") + .and_then(Value::as_str) + .is_some_and(|secret| !secret.is_empty()) + || model + .get("custom_headers") + .and_then(Value::as_object) + .is_some_and(|headers| !headers.is_empty()) + || model + .get("custom_request_body") + .and_then(Value::as_str) + .is_some_and(|body| !body.is_empty()) +} + +fn is_portable_credential_path(path: &str) -> bool { + path == "app.voice_call.api_key" + || path == "mcp_servers" + || path.starts_with("mcp_servers.") + || (path.starts_with("ai.models.") + && (path.ends_with(".api_key") + || path.contains(".custom_headers.") + || path.ends_with(".custom_request_body"))) +} + +fn collect_unsupported_secret_fields(value: &Value, path: &str, output: &mut Vec) { + match value { + Value::Object(fields) => { + for (name, value) in fields { + let next = if path.is_empty() { + name.to_string() + } else { + format!("{path}.{name}") + }; + let lower = name.to_ascii_lowercase(); + if matches!(lower.as_str(), "password" | "token" | "secret" | "api_key") + && value.as_str().is_some_and(|secret| !secret.is_empty()) + { + output.push(next.clone()); + } + collect_unsupported_secret_fields(value, &next, output); + } + } + Value::Array(items) => { + for (index, value) in items.iter().enumerate() { + collect_unsupported_secret_fields(value, &format!("{path}.{index}"), output); + } + } + _ => {} + } +} + +fn collect_secret_values(value: &Value, output: &mut Vec) { + match value { + Value::Object(fields) => { + for (name, value) in fields { + let lower = name.to_ascii_lowercase(); + if matches!(lower.as_str(), "password" | "token" | "secret" | "api_key") { + if let Some(secret) = value.as_str().filter(|secret| !secret.is_empty()) { + output.push(secret.to_string()); + } + } + collect_secret_values(value, output); + } + } + Value::Array(items) => { + for value in items { + collect_secret_values(value, output); + } + } + _ => {} + } +} + +fn validate_source_version(source: &Value) -> LegacyMigrationResult<()> { + let schema = source.get("schema_version").and_then(Value::as_u64); + let version = source.get("version").and_then(Value::as_str); + if schema != Some(1) || !version.is_some_and(|version| version.starts_with("0.")) { + return Err(LegacyMigrationError::UnsupportedSource(format!( + "expected BitFun config schema 1 from a 0.x release, found schema={schema:?}, version={version:?}" + ))); + } + Ok(()) +} + +fn validate_current_config(config: &GlobalConfig, context: &str) -> LegacyMigrationResult<()> { + let value = serde_json::to_value(config) + .map_err(|error| LegacyMigrationError::InvalidRequest(error.to_string()))?; + validate_current_config_value(&value, context) + .map_err(|error| LegacyMigrationError::InvalidRequest(error.to_string()))?; + serde_json::from_value::(value) + .map(|_| ()) + .map_err(|error| LegacyMigrationError::InvalidRequest(error.to_string())) +} + +fn record_target_wins(path: &str, outcome: &mut MergeOutcome) { + outcome.skipped += 1; + outcome.conflicts.push(MigrationConflict { + domain: MigrationDomainId::Settings, + code: "target_setting_wins".to_string(), + source_summary: format!("legacy setting {path}"), + target_summary: format!("existing OpenBitFun setting {path}"), + resolution: ConflictResolution::TargetWins, + }); +} + +fn canonical_product_id(value: &str) -> String { + value + .replace("user::bitfun::", "user::openbitfun::") + .replace("bitfun-", "openbitfun-") +} + +fn source_config_path(roots: &MigrationRoots) -> PathBuf { + roots.legacy_user_root.join("config").join("app.json") +} + +fn target_config_path(roots: &MigrationRoots) -> PathBuf { + roots.target_user_root.join("config").join("app.json") +} + +fn settings_stage_path(context: &DomainContext<'_>) -> PathBuf { + stage_domain_dir(context, "settings").join("app.json") +} + +fn credentials_stage_path(context: &DomainContext<'_>) -> PathBuf { + stage_domain_dir(context, "credentials").join("manifest.json") +} + +fn settings_backup_path(context: &DomainContext<'_>) -> PathBuf { + backup_domain_dir(context, "settings").join("app.json") +} + +fn credentials_backup_path(context: &DomainContext<'_>) -> PathBuf { + backup_domain_dir(context, "credentials").join("app.json") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::legacy_migration::adapters_for_groups; + use openbitfun_legacy_migration::{ + probe_legacy_source, CancellationToken, MigrationEngine, NoCrashInjection, ProbeLimits, + }; + use openbitfun_product_domains::legacy_migration::{MigrationGroupId, MigrationSelection}; + use sha2::{Digest, Sha256}; + use std::collections::BTreeSet; + use std::path::Path; + + #[test] + fn settings_and_credentials_convert_without_staging_secrets() { + let temp = test_tempdir("settings-credentials"); + let roots = test_roots(temp.path()); + seed_source(&roots, true); + let source_before = sha256(&source_config_path(&roots)); + let source = probe_legacy_source(&roots, ProbeLimits::default()) + .unwrap() + .unwrap(); + let selection = MigrationSelection { + groups: BTreeSet::from([MigrationGroupId::SettingsAndCredentials]), + }; + let engine = MigrationEngine::new(roots.clone(), adapters_for_groups(&selection)).unwrap(); + let plan = engine + .plan(&source, selection, &CancellationToken::default()) + .unwrap(); + let report = engine + .execute(&plan, &CancellationToken::default(), &NoCrashInjection) + .unwrap(); + + let target = read_target_config(&roots).unwrap(); + assert_eq!(target.app.language, "en-US"); + assert!(!target.app.auto_update); + assert!(target.app.telemetry); + assert!(!target.app.confirm_on_exit); + assert!(!target.app.restore_windows); + assert!(target.app.prevent_sleep); + assert_eq!(target.app.zoom_level, 1.25); + assert_eq!(target.app.notifications.duration, 8123); + assert_eq!(target.editor.font_size, 17); + assert_eq!(target.editor.word_wrap, "on"); + assert!(!target.editor.format_on_save); + assert_eq!(target.terminal.default_shell, "pwsh"); + assert_eq!(target.terminal.terminal_panel_position, "bottom"); + assert_eq!(target.terminal.font_size, 16); + assert_eq!(target.terminal.scrollback, 4321); + assert_eq!(target.workspace.max_file_size, 123_456); + assert_eq!(target.workspace.line_ending, "lf"); + assert!(!target.workspace.insert_final_newline); + assert!(target.tool_permissions.interaction.auto_approve_ask); + assert_eq!(target.appearance.selection, "openbitfun-dark"); + assert_eq!( + target.ai.default_models.primary.as_deref(), + Some("legacy-model") + ); + assert_eq!( + target.ai.default_models.fast.as_deref(), + Some("legacy-model") + ); + assert_eq!(target.ai.agent_model_defaults.mode, "legacy-model"); + assert_eq!( + target + .ai + .agent_model_defaults + .subagents + .default_selection + .fixed_model_id(), + Some("legacy-model") + ); + let profile = &target.ai.agent_profiles["coding_shared"]; + assert_eq!(profile.profile_id, "coding_shared"); + assert_eq!(profile.added_tools, ["LegacyTool"]); + assert_eq!(profile.removed_tools, ["ReadFile"]); + assert_eq!( + profile.disabled_user_skills, + ["user::openbitfun::disabled-skill"] + ); + assert_eq!( + profile.enabled_user_skills, + ["user::openbitfun::user-skill"] + ); + assert_eq!( + target.ai.skill_settings.globally_disabled_user_skills, + ["user::openbitfun::global-disabled-skill"] + ); + assert_eq!( + target.ai.review_teams["default"].reviewer_timeout_seconds, + 77 + ); + assert_eq!(target.ai.subagent_max_concurrency, 3); + assert_eq!(target.ai.stream_idle_timeout_secs, Some(321)); + assert_eq!(target.ai.stream_ttft_timeout_secs, Some(123)); + assert_eq!(target.ai.tool_execution_timeout_secs, Some(456)); + assert!(!target.ai.enable_deferred_tool_loading); + assert_eq!(target.ai.browser_control_preferred_browser, "edge"); + let model = target + .ai + .models + .iter() + .find(|model| model.id == "legacy-model") + .unwrap(); + assert_eq!(model.api_key, "fixture-api-key"); + assert_eq!( + model.custom_headers.as_ref().unwrap()["Authorization"], + "Bearer fixture-header-secret" + ); + assert_eq!( + model.custom_request_body.as_deref(), + Some(r#"{"token":"fixture-body-secret"}"#) + ); + assert_eq!(target.app.voice_call.api_key, "fixture-voice-secret"); + assert_eq!( + target.mcp_servers, + Some(serde_json::json!({ + "fixture": { + "command": "fixture-mcp", + "env": {"TOKEN": "fixture-mcp-secret"} + } + })) + ); + assert_eq!(target.product_id, GlobalConfig::default().product_id); + assert_eq!(sha256(&source_config_path(&roots)), source_before); + assert!(report.requires_reauthentication.is_empty()); + + let stage = fs::read_dir( + roots + .migration_root() + .join("runs") + .join(&plan.run_id) + .join("stage"), + ) + .unwrap() + .flat_map(|entry| walk_files(&entry.unwrap().path())) + .flat_map(|path| fs::read(path).unwrap()) + .collect::>(); + let stage = String::from_utf8_lossy(&stage); + for secret in [ + "fixture-api-key", + "fixture-header-secret", + "fixture-body-secret", + "fixture-voice-secret", + "fixture-mcp-secret", + ] { + assert!(!stage.contains(secret), "staging leaked {secret}"); + } + } + + #[test] + fn explicit_target_values_win_and_are_reported() { + let temp = test_tempdir("settings-target-wins"); + let roots = test_roots(temp.path()); + seed_source(&roots, true); + let mut target = GlobalConfig::default(); + target.app.language = "fr-FR".to_string(); + target.app.close_button_behavior = "quit".to_string(); + target.ai.default_models.primary = Some("target-model".to_string()); + target.ai.models.extend([ + AIModelConfig { + id: "target-model".to_string(), + name: "Target model".to_string(), + ..AIModelConfig::default() + }, + AIModelConfig { + id: "legacy-model".to_string(), + api_key: "target-secret".to_string(), + custom_headers: Some(Default::default()), + custom_request_body: Some(String::new()), + ..AIModelConfig::default() + }, + ]); + atomic_write_json(&target_config_path(&roots), &target).unwrap(); + let source = probe_legacy_source(&roots, ProbeLimits::default()) + .unwrap() + .unwrap(); + let selection = MigrationSelection { + groups: BTreeSet::from([MigrationGroupId::SettingsAndCredentials]), + }; + let engine = MigrationEngine::new(roots.clone(), adapters_for_groups(&selection)).unwrap(); + let plan = engine + .plan(&source, selection, &CancellationToken::default()) + .unwrap(); + assert!(plan + .conflicts + .iter() + .any(|conflict| conflict.code == "target_setting_wins")); + engine + .execute(&plan, &CancellationToken::default(), &NoCrashInjection) + .unwrap(); + let target = read_target_config(&roots).unwrap(); + assert_eq!(target.app.language, "fr-FR"); + assert_eq!(target.app.close_button_behavior, "quit"); + assert_eq!( + target.ai.default_models.primary.as_deref(), + Some("target-model") + ); + assert_eq!( + target.ai.default_models.fast.as_deref(), + Some("legacy-model") + ); + let legacy_model = target + .ai + .models + .iter() + .find(|model| model.id == "legacy-model") + .unwrap(); + assert_eq!(legacy_model.api_key, "target-secret"); + assert_eq!( + legacy_model.custom_headers.as_ref().unwrap()["Authorization"], + "Bearer fixture-header-secret" + ); + assert_eq!( + legacy_model.custom_request_body.as_deref(), + Some(r#"{"token":"fixture-body-secret"}"#) + ); + } + + fn seed_source(roots: &MigrationRoots, with_secret: bool) { + let source = serde_json::json!({ + "product_id": "bitfun", + "app": { + "language": "en-US", + "auto_update": false, + "telemetry": true, + "confirm_on_exit": false, + "restore_windows": false, + "prevent_sleep": true, + "zoom_level": 1.25, + "notifications": {"duration": 8123}, + "voice_call": { + "api_key": if with_secret { "fixture-voice-secret" } else { "" } + } + }, + "editor": { + "font_size": 17, + "word_wrap": "on", + "format_on_save": false + }, + "terminal": { + "default_shell": "pwsh", + "terminal_panel_position": "bottom", + "font_size": 16, + "scrollback": 4321 + }, + "workspace": { + "max_file_size": 123456, + "line_ending": "lf", + "insert_final_newline": false + }, + "tool_permissions": { + "interaction": {"auto_approve_ask": true} + }, + "appearance": {"theme_id": "bitfun-dark"}, + "ai": { + "default_models": { + "primary": "legacy-model", + "fast": "legacy-model" + }, + "agent_model_defaults": { + "mode": "legacy-model", + "subagents": { + "default": {"kind": "fixed", "model_id": "legacy-model"}, + "builtin": {}, + "fork": {"kind": "inherit"} + } + }, + "agent_profiles": { + "coding_shared": { + "added_tools": ["LegacyTool"], + "removed_tools": ["ReadFile"], + "disabled_user_skills": ["user::bitfun::disabled-skill"], + "enabled_skills": ["user::bitfun::user-skill"] + } + }, + "skill_settings": { + "globally_disabled_user_skills": ["user::bitfun::global-disabled-skill"] + }, + "review_teams": { + "default": {"reviewer_timeout_seconds": 77} + }, + "subagent_max_concurrency": 3, + "stream_idle_timeout_secs": 321, + "stream_ttft_timeout_secs": 123, + "tool_execution_timeout_secs": 456, + "enable_deferred_tool_loading": false, + "browser_control_preferred_browser": "edge", + "models": [{ + "id": "legacy-model", + "name": "Legacy model", + "provider": "openai", + "model_name": "legacy-model", + "base_url": "https://example.invalid/v1", + "enabled": true, + "category": "general_chat", + "capabilities": ["text_chat"], + "api_key": if with_secret { "fixture-api-key" } else { "" }, + "custom_headers": if with_secret { + serde_json::json!({"Authorization": "Bearer fixture-header-secret"}) + } else { + serde_json::json!({}) + }, + "custom_headers_mode": "merge", + "custom_request_body": if with_secret { + r#"{"token":"fixture-body-secret"}"# + } else { + "" + }, + "custom_request_body_mode": "merge" + }] + }, + "mcp_servers": { + "fixture": { + "command": "fixture-mcp", + "env": {"TOKEN": if with_secret { "fixture-mcp-secret" } else { "" }} + } + }, + "schema_version": 1, + "version": "0.2.19", + "last_modified": 0 + }); + atomic_write_json(&source_config_path(roots), &source).unwrap(); + } + + fn test_roots(root: &Path) -> MigrationRoots { + 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-skills"), + target_ssh_root: root.join("target-ssh"), + } + } + + fn test_tempdir(label: &str) -> tempfile::TempDir { + let root = std::env::var_os("OPENBITFUN_TEST_TMPDIR") + .map(PathBuf::from) + .unwrap_or_else(std::env::temp_dir); + fs::create_dir_all(&root).unwrap(); + tempfile::Builder::new() + .prefix(&format!("openbitfun-migration-{label}-")) + .tempdir_in(root) + .unwrap() + } + + fn sha256(path: &Path) -> Vec { + Sha256::digest(fs::read(path).unwrap()).to_vec() + } + + fn walk_files(root: &Path) -> Vec { + if root.is_file() { + return vec![root.to_path_buf()]; + } + fs::read_dir(root) + .unwrap() + .flat_map(|entry| walk_files(&entry.unwrap().path())) + .collect() + } +} diff --git a/src/crates/assembly/core/src/legacy_migration/workspace_sessions.rs b/src/crates/assembly/core/src/legacy_migration/workspace_sessions.rs new file mode 100644 index 0000000000..bdd3dc4dc6 --- /dev/null +++ b/src/crates/assembly/core/src/legacy_migration/workspace_sessions.rs @@ -0,0 +1,2136 @@ +use super::common::{ + backup_domain_dir, backup_file_once, io_error, read_bounded_json, read_optional_bounded_json, + relative_display, restore_unverified_file, stage_domain_dir, validate_regular_file, +}; +use crate::infrastructure::app_paths::PathManager; +use crate::service::session_projection_format::validate_runtime_event_log; +use crate::service::workspace::persistence::{ + current_workspace_storage_id, validate_workspace_persistence_data, WorkspacePersistenceData, + WORKSPACE_PERSISTENCE_FORMAT_VERSION, +}; +use crate::service::workspace::{PrimaryAssistantKey, WorkspaceInfo, WorkspaceKind}; +use openbitfun_core_types::product_identity::product_id; +use openbitfun_core_types::validate_session_id; +use openbitfun_legacy_migration::{ + atomic_write_bytes, atomic_write_json, DomainContext, DomainScan, LegacyDomainAdapter, + LegacyMigrationError, LegacyMigrationResult, MigrationRoots, +}; +use openbitfun_product_domains::legacy_migration::{ + ConflictResolution, FindingSeverity, MigrationConflict, MigrationDiagnostic, MigrationDomainId, + MigrationDomainResult, MigrationDomainState, ScanFinding, +}; +use openbitfun_services_core::session::{ + OfflineSessionBundle, OfflineSessionImportStore, SessionMetadata, SessionRelationship, + StoredDialogTurnFile, StoredSessionMetadataFile, SESSION_STORAGE_SCHEMA_VERSION, +}; +use openbitfun_services_core::workspace_identity::{ + canonicalize_local_workspace_root, normalize_remote_workspace_path, LOCAL_WORKSPACE_SSH_HOST, +}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; +use std::fs; +use std::path::{Component, Path, PathBuf}; + +const MAX_SESSION_FILES: usize = 4096; +const MAX_SESSION_FILE_BYTES: u64 = 512 * 1024 * 1024; +const MAX_SESSION_BYTES: u64 = 4 * 1024 * 1024 * 1024; +const MAX_RUNTIME_EVENT_BYTES: u64 = 256 * 1024 * 1024; +const MAX_RUNTIME_DIRECTORIES: usize = 32_768; +const MAX_RUNTIME_DEPTH: usize = 16; + +const SESSION_ROOT_FILES: &[&str] = &[ + "state.json", + "turn-catalog.json", + "token-anchors.json", + "session-revert.json", + "evidence-ledger.json", +]; +const SESSION_REBUILDABLE_ROOT_FILES: &[&str] = &["prompt_cache.json"]; +const SESSION_OWNED_DIRECTORIES: &[&str] = &["snapshots", "artifacts", "tool-results"]; + +pub(crate) struct WorkspaceSessionsAdapter; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum SessionImportAction { + Import, + Duplicate, + TargetWins, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct SessionManifestEntry { + pub(crate) runtime_relative: String, + pub(crate) session_id: String, + pub(crate) action: SessionImportAction, + pub(crate) expected_hash: String, + pub(crate) turn_ids: BTreeSet, + pub(crate) relationship: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct RuntimeEventManifestEntry { + pub(crate) session_id: String, + pub(crate) action: SessionImportAction, + pub(crate) expected_hash: String, + pub(crate) turn_ids: BTreeSet, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct AssistantWorkspaceManifestEntry { + pub(crate) relative_path: String, + pub(crate) action: SessionImportAction, + pub(crate) expected_hash: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct WorkspaceSessionsManifest { + pub(crate) workspace_id_map: BTreeMap, + #[serde(default)] + pub(crate) assistant_workspaces: Vec, + pub(crate) sessions: Vec, + pub(crate) runtime_events: Vec, + pub(crate) target_workspace_existed: bool, + pub(crate) target_workspace_hash: Option, + pub(crate) skipped_paths: Vec, +} + +#[derive(Debug, Deserialize)] +struct LegacyWorkspacePersistenceData { + workspaces: HashMap, + #[serde(default)] + opened_workspace_ids: Vec, + current_workspace_id: Option, + #[serde(default)] + recent_workspaces: Vec, + #[serde(default)] + recent_assistant_workspaces: Vec, + #[serde(default)] + primary_assistant_key: Option, + saved_at: chrono::DateTime, +} + +struct WorkspaceSessionsPlan { + workspace_data: WorkspacePersistenceData, + workspace_id_map: BTreeMap, + assistant_workspaces: Vec, + sessions: Vec, + runtime_events: Vec, + conflicts: Vec, + requires_relocation: Vec, + skipped_paths: Vec, + target_workspace_existed: bool, + target_workspace_hash: Option, + logical_bytes: u64, +} + +struct PlannedAssistantWorkspace { + relative_path: PathBuf, + source_path: PathBuf, + action: SessionImportAction, + expected_hash: String, + logical_bytes: u64, +} + +struct PlannedSession { + runtime_relative: PathBuf, + bundle: OfflineSessionBundle, + auxiliary_files: Vec<(PathBuf, PathBuf)>, + state_bytes_override: Option>, + action: SessionImportAction, + expected_hash: String, +} + +struct PlannedRuntimeEvent { + session_id: String, + source_path: PathBuf, + action: SessionImportAction, + expected_hash: String, + turn_ids: BTreeSet, +} + +impl LegacyDomainAdapter for WorkspaceSessionsAdapter { + fn domain(&self) -> MigrationDomainId { + MigrationDomainId::WorkspaceSessions + } + + fn scan(&self, roots: &MigrationRoots) -> LegacyMigrationResult { + let plan = plan_workspace_sessions(roots)?; + Ok(DomainScan { + finding: ScanFinding { + domain: self.domain(), + code: "legacy_workspace_sessions_supported".to_string(), + severity: if plan.conflicts.is_empty() { + FindingSeverity::Info + } else { + FindingSeverity::Warning + }, + entity_count: (plan.workspace_id_map.len() + + plan.assistant_workspaces.len() + + plan.sessions.len() + + plan.runtime_events.len()) as u64, + logical_bytes: plan.logical_bytes, + source_schema: Some("bitfun.workspace-session.v1".to_string()), + migratable: true, + detail: format!( + "{} workspaces, {} personal assistant directories, {} Sessions, and {} runtime event logs are owner-readable", + plan.workspace_id_map.len(), + plan.assistant_workspaces.len(), + plan.sessions.len(), + plan.runtime_events.len() + ), + }, + conflicts: plan.conflicts, + target_schema: Some("openbitfun.workspace-session.current".to_string()), + dependencies: Vec::new(), + }) + } + + fn stage(&self, context: &DomainContext<'_>) -> LegacyMigrationResult { + let plan = plan_workspace_sessions(context.roots)?; + let domain_root = stage_domain_dir(context, "workspace-sessions"); + atomic_write_json( + &domain_root.join("workspace_data.json"), + &plan.workspace_data, + )?; + + for workspace in &plan.assistant_workspaces { + if workspace.action != SessionImportAction::Import { + continue; + } + let staged = domain_root + .join("personal-assistant") + .join(&workspace.relative_path); + copy_tree(&workspace.source_path, &staged)?; + require_tree_hash(&staged, &workspace.expected_hash)?; + } + + let runtime = offline_runtime()?; + for session in &plan.sessions { + if session.action != SessionImportAction::Import { + continue; + } + let sessions_root = domain_root.join("home").join(&session.runtime_relative); + let store = OfflineSessionImportStore::new(&sessions_root); + runtime + .block_on(store.write_bundle(&session.bundle)) + .map_err(|error| owner_error("write staged Session", error))?; + let staged_session = sessions_root.join(&session.bundle.metadata.session_id); + for (relative, source) in &session.auxiliary_files { + if relative == Path::new("state.json") { + if let Some(bytes) = &session.state_bytes_override { + atomic_write_bytes(&staged_session.join(relative), bytes)?; + continue; + } + } + let source_bytes = fs::read(source).map_err(|error| io_error(source, error))?; + atomic_write_bytes(&staged_session.join(relative), &source_bytes)?; + } + require_tree_hash(&staged_session, &session.expected_hash)?; + } + + for event in &plan.runtime_events { + if event.action != SessionImportAction::Import { + continue; + } + let bytes = fs::read(&event.source_path) + .map_err(|error| io_error(&event.source_path, error))?; + atomic_write_bytes( + &domain_root + .join("runtime-events") + .join(format!("{}.jsonl", event.session_id)), + &bytes, + )?; + } + + let manifest = WorkspaceSessionsManifest { + workspace_id_map: plan.workspace_id_map, + assistant_workspaces: plan + .assistant_workspaces + .iter() + .map(assistant_workspace_manifest_entry) + .collect(), + sessions: plan.sessions.iter().map(session_manifest_entry).collect(), + runtime_events: plan + .runtime_events + .iter() + .map(|event| RuntimeEventManifestEntry { + session_id: event.session_id.clone(), + action: event.action, + expected_hash: event.expected_hash.clone(), + turn_ids: event.turn_ids.clone(), + }) + .collect(), + target_workspace_existed: plan.target_workspace_existed, + target_workspace_hash: plan.target_workspace_hash, + skipped_paths: plan.skipped_paths, + }; + atomic_write_json(&workspace_sessions_manifest_path(context), &manifest)?; + + let imported = manifest + .sessions + .iter() + .filter(|entry| entry.action == SessionImportAction::Import) + .count() + + manifest + .runtime_events + .iter() + .filter(|entry| entry.action == SessionImportAction::Import) + .count() + + manifest.workspace_id_map.len() + + manifest + .assistant_workspaces + .iter() + .filter(|entry| entry.action == SessionImportAction::Import) + .count(); + let skipped = manifest + .sessions + .iter() + .filter(|entry| entry.action != SessionImportAction::Import) + .count() + + manifest + .runtime_events + .iter() + .filter(|entry| entry.action != SessionImportAction::Import) + .count() + + manifest + .assistant_workspaces + .iter() + .filter(|entry| entry.action != SessionImportAction::Import) + .count(); + let mut warnings = manifest + .skipped_paths + .iter() + .map(|path| MigrationDiagnostic { + code: "session_path_not_migrated".to_string(), + severity: FindingSeverity::Info, + domain: Some(self.domain()), + relative_path: Some(path.clone()), + message: "A non-owned or rebuildable Session path was left in the legacy source" + .to_string(), + action: None, + }) + .collect::>(); + let orphaned_relationships = orphaned_session_relationship_count(&manifest); + if orphaned_relationships > 0 { + warnings.push(MigrationDiagnostic { + code: "session_parent_not_present".to_string(), + severity: FindingSeverity::Info, + domain: Some(self.domain()), + relative_path: None, + message: format!( + "{orphaned_relationships} imported Sessions reference a parent Session that is not present in the legacy source; the relationship metadata was preserved" + ), + action: Some( + "No action is required; the affected Sessions remain available as standalone history" + .to_string(), + ), + }); + } + Ok(MigrationDomainResult { + domain: self.domain(), + state: MigrationDomainState::Staged, + imported: imported as u64, + skipped: skipped as u64, + conflicts: plan.conflicts.len() as u64, + warnings, + requires_relocation: plan.requires_relocation, + ..MigrationDomainResult::default() + }) + } + + fn validate_stage(&self, context: &DomainContext<'_>) -> LegacyMigrationResult<()> { + let manifest = read_workspace_sessions_manifest(context)?; + let domain_root = stage_domain_dir(context, "workspace-sessions"); + let workspace_data: WorkspacePersistenceData = read_bounded_json( + &context.layout.stage_root(), + &domain_root.join("workspace_data.json"), + )?; + validate_workspace_persistence_data( + &workspace_data, + &context.roots.target_user_root.join("data/miniapps"), + ) + .map_err(|error| owner_error("validate staged Workspace registry", error))?; + + for entry in imported_assistant_workspaces(&manifest) { + let path = domain_root + .join("personal-assistant") + .join(path_from_manifest(&entry.relative_path)?); + require_tree_hash(&path, &entry.expected_hash)?; + } + + let runtime = offline_runtime()?; + for entry in imported_sessions(&manifest) { + let sessions_root = domain_root + .join("home") + .join(path_from_manifest(&entry.runtime_relative)?); + let store = OfflineSessionImportStore::new(&sessions_root); + let bundle = runtime + .block_on(store.load_bundle(&entry.session_id)) + .map_err(|error| owner_error("read staged Session", error))? + .ok_or_else(|| { + LegacyMigrationError::InvalidRequest(format!( + "staged Session is missing: {}", + entry.session_id + )) + })?; + bundle + .validate() + .map_err(|error| owner_error("validate staged Session", error))?; + require_tree_hash(&sessions_root.join(&entry.session_id), &entry.expected_hash)?; + } + for entry in imported_runtime_events(&manifest) { + let path = domain_root + .join("runtime-events") + .join(format!("{}.jsonl", entry.session_id)); + validate_runtime_event_log(&path, &entry.session_id) + .map_err(|error| owner_error("validate staged runtime event log", error))?; + require_file_hash(&path, &entry.expected_hash)?; + } + Ok(()) + } + + fn commit(&self, context: &DomainContext<'_>) -> LegacyMigrationResult<()> { + let manifest = read_workspace_sessions_manifest(context)?; + let domain_root = stage_domain_dir(context, "workspace-sessions"); + let target_workspace = target_workspace_data_path(context.roots); + let staged_workspace = domain_root.join("workspace_data.json"); + let staged_workspace_hash = hash_file(&staged_workspace)?; + let workspace_already_applied = if target_workspace.exists() { + validate_regular_file(&context.roots.target_user_root, &target_workspace)?; + hash_file(&target_workspace)? == staged_workspace_hash + } else { + false + }; + if !workspace_already_applied { + verify_planned_file_state( + &target_workspace, + manifest.target_workspace_existed, + manifest.target_workspace_hash.as_deref(), + )?; + backup_file_once( + &target_workspace, + &backup_domain_dir(context, "workspace-sessions").join("workspace_data.json"), + )?; + let workspace_bytes = + fs::read(&staged_workspace).map_err(|error| io_error(&staged_workspace, error))?; + atomic_write_bytes(&target_workspace, &workspace_bytes)?; + } + + for entry in imported_assistant_workspaces(&manifest) { + let relative = path_from_manifest(&entry.relative_path)?; + install_directory_idempotent( + &domain_root.join("personal-assistant").join(&relative), + &context + .roots + .target_home_root + .join("personal_assistant") + .join(relative), + &entry.expected_hash, + &context.plan.run_id, + )?; + } + + for entry in imported_sessions(&manifest) { + let relative = path_from_manifest(&entry.runtime_relative)?; + let staged = domain_root + .join("home") + .join(&relative) + .join(&entry.session_id); + let target = context + .roots + .target_home_root + .join(&relative) + .join(&entry.session_id); + install_directory_idempotent( + &staged, + &target, + &entry.expected_hash, + &context.plan.run_id, + )?; + } + for entry in imported_runtime_events(&manifest) { + let staged = domain_root + .join("runtime-events") + .join(format!("{}.jsonl", entry.session_id)); + let target = context + .roots + .target_home_root + .join("runtime-events") + .join(format!("{}.jsonl", entry.session_id)); + install_file_idempotent(&staged, &target, &entry.expected_hash)?; + } + Ok(()) + } + + fn validate_commit(&self, context: &DomainContext<'_>) -> LegacyMigrationResult<()> { + let manifest = read_workspace_sessions_manifest(context)?; + let expected: WorkspacePersistenceData = read_bounded_json( + &context.layout.stage_root(), + &stage_domain_dir(context, "workspace-sessions").join("workspace_data.json"), + )?; + let actual: WorkspacePersistenceData = read_bounded_json( + &context.roots.target_user_root, + &target_workspace_data_path(context.roots), + )?; + validate_workspace_persistence_data( + &actual, + &context.roots.target_user_root.join("data/miniapps"), + ) + .map_err(|error| owner_error("validate committed Workspace registry", error))?; + if serde_json::to_value(&expected).map_err(json_error)? + != serde_json::to_value(&actual).map_err(json_error)? + { + return Err(LegacyMigrationError::InvalidRequest( + "committed Workspace registry differs from the staged owner output".to_string(), + )); + } + + for entry in imported_assistant_workspaces(&manifest) { + let path = context + .roots + .target_home_root + .join("personal_assistant") + .join(path_from_manifest(&entry.relative_path)?); + require_tree_hash(&path, &entry.expected_hash)?; + } + + let runtime = offline_runtime()?; + for entry in imported_sessions(&manifest) { + let relative = path_from_manifest(&entry.runtime_relative)?; + let sessions_root = context.roots.target_home_root.join(&relative); + let store = OfflineSessionImportStore::new(&sessions_root); + let bundle = runtime + .block_on(store.load_bundle(&entry.session_id)) + .map_err(|error| owner_error("read committed Session", error))? + .ok_or_else(|| { + LegacyMigrationError::InvalidRequest(format!( + "committed Session is missing: {}", + entry.session_id + )) + })?; + bundle + .validate() + .map_err(|error| owner_error("validate committed Session", error))?; + require_tree_hash(&sessions_root.join(&entry.session_id), &entry.expected_hash)?; + } + for entry in imported_runtime_events(&manifest) { + let path = context + .roots + .target_home_root + .join("runtime-events") + .join(format!("{}.jsonl", entry.session_id)); + validate_runtime_event_log(&path, &entry.session_id) + .map_err(|error| owner_error("validate committed runtime event log", error))?; + require_file_hash(&path, &entry.expected_hash)?; + } + Ok(()) + } + + fn rollback_unverified(&self, context: &DomainContext<'_>) -> LegacyMigrationResult<()> { + let Some(manifest) = read_optional_bounded_json::( + &context.layout.stage_root(), + &workspace_sessions_manifest_path(context), + )? + else { + return Ok(()); + }; + restore_unverified_file( + &target_workspace_data_path(context.roots), + &backup_domain_dir(context, "workspace-sessions").join("workspace_data.json"), + manifest.target_workspace_existed, + )?; + let domain_root = stage_domain_dir(context, "workspace-sessions"); + for entry in imported_assistant_workspaces(&manifest) { + let relative = path_from_manifest(&entry.relative_path)?; + remove_directory_if_matches( + &domain_root.join("personal-assistant").join(&relative), + &context + .roots + .target_home_root + .join("personal_assistant") + .join(relative), + )?; + } + for entry in imported_sessions(&manifest) { + let relative = path_from_manifest(&entry.runtime_relative)?; + remove_directory_if_matches( + &domain_root + .join("home") + .join(&relative) + .join(&entry.session_id), + &context + .roots + .target_home_root + .join(relative) + .join(&entry.session_id), + )?; + } + for entry in imported_runtime_events(&manifest) { + remove_file_if_matches( + &domain_root + .join("runtime-events") + .join(format!("{}.jsonl", entry.session_id)), + &context + .roots + .target_home_root + .join("runtime-events") + .join(format!("{}.jsonl", entry.session_id)), + )?; + } + Ok(()) + } +} + +fn plan_workspace_sessions(roots: &MigrationRoots) -> LegacyMigrationResult { + let source_workspace_path = source_workspace_data_path(roots); + let legacy: LegacyWorkspacePersistenceData = + read_bounded_json(&roots.legacy_user_root, &source_workspace_path)?; + let target_workspace_path = target_workspace_data_path(roots); + let target = read_optional_bounded_json::( + &roots.target_user_root, + &target_workspace_path, + )?; + if let Some(target) = &target { + validate_workspace_persistence_data(target, &roots.target_user_root.join("data/miniapps")) + .map_err(|error| owner_error("read current Workspace registry", error))?; + } + + let mut conflicts = Vec::new(); + let assistant_workspaces = plan_assistant_workspaces(roots, &mut conflicts)?; + let mut assistant_path_relocations = BTreeMap::new(); + for workspace in &assistant_workspaces { + assistant_path_relocations.insert( + native_path_key(&workspace.source_path), + roots + .target_home_root + .join("personal_assistant") + .join(&workspace.relative_path), + ); + } + + let mut workspace_id_map = BTreeMap::new(); + let mut converted = Vec::new(); + let mut source_workspace_ids = legacy.workspaces.keys().cloned().collect::>(); + source_workspace_ids.sort(); + let mut requires_relocation = Vec::new(); + for source_id in source_workspace_ids { + let mut workspace = legacy.workspaces[&source_id].clone(); + let relocated_assistant = (workspace.workspace_kind == WorkspaceKind::Assistant) + .then(|| assistant_path_relocations.get(&native_path_key(&workspace.root_path))) + .flatten() + .cloned(); + if let Some(target_path) = &relocated_assistant { + workspace.root_path = target_path.clone(); + } + normalize_legacy_workspace_for_current(&mut workspace)?; + let target_id = current_workspace_storage_id(&workspace) + .map_err(|error| owner_error("convert legacy Workspace id", error))?; + workspace.id = target_id.clone(); + if workspace.workspace_kind != WorkspaceKind::Remote + && relocated_assistant.is_none() + && !workspace.root_path.exists() + { + requires_relocation.push(target_id.clone()); + } + workspace_id_map.insert(source_id, target_id.clone()); + converted.push((target_id, workspace)); + } + + let mut output_workspaces = target + .as_ref() + .map(|value| value.workspaces.clone()) + .unwrap_or_default(); + for (target_id, workspace) in converted { + if let Some(existing) = output_workspaces.get(&target_id) { + if serde_json::to_value(existing).map_err(json_error)? + != serde_json::to_value(&workspace).map_err(json_error)? + { + conflicts.push(MigrationConflict { + domain: MigrationDomainId::WorkspaceSessions, + code: "workspace_target_wins".to_string(), + source_summary: format!("legacy Workspace {target_id}"), + target_summary: format!("current Workspace {target_id}"), + resolution: ConflictResolution::TargetWins, + }); + } + } else { + output_workspaces.insert(target_id, workspace); + } + } + + let mut opened_workspace_ids = merge_reference_list( + target.as_ref().map(|value| &value.opened_workspace_ids), + &legacy.opened_workspace_ids, + &workspace_id_map, + &output_workspaces, + ); + let recent_workspaces = merge_reference_list( + target.as_ref().map(|value| &value.recent_workspaces), + &legacy.recent_workspaces, + &workspace_id_map, + &output_workspaces, + ); + let recent_assistant_workspaces = merge_reference_list( + target + .as_ref() + .map(|value| &value.recent_assistant_workspaces), + &legacy.recent_assistant_workspaces, + &workspace_id_map, + &output_workspaces, + ); + let source_current = legacy + .current_workspace_id + .as_ref() + .and_then(|id| workspace_id_map.get(id)) + .filter(|id| output_workspaces.contains_key(*id)) + .cloned(); + let current_workspace_id = target + .as_ref() + .and_then(|value| value.current_workspace_id.clone()) + .or(source_current); + if let Some(current_id) = current_workspace_id.as_ref() { + if !opened_workspace_ids.iter().any(|id| id == current_id) { + opened_workspace_ids.push(current_id.clone()); + } + } + let workspace_data = WorkspacePersistenceData { + format_version: WORKSPACE_PERSISTENCE_FORMAT_VERSION, + product_id: product_id().to_string(), + workspaces: output_workspaces, + opened_workspace_ids, + current_workspace_id, + recent_workspaces, + recent_assistant_workspaces, + primary_assistant_key: target + .as_ref() + .and_then(|value| value.primary_assistant_key.clone()) + .or(legacy.primary_assistant_key), + saved_at: target + .as_ref() + .map(|value| value.saved_at) + .unwrap_or(legacy.saved_at), + }; + validate_workspace_persistence_data( + &workspace_data, + &roots.target_user_root.join("data/miniapps"), + ) + .map_err(|error| owner_error("convert legacy Workspace registry", error))?; + + let target_sessions = index_target_sessions(roots)?; + let (sessions, mut skipped_paths) = plan_sessions( + roots, + &target_sessions, + &assistant_path_relocations, + &mut conflicts, + )?; + let runtime_events = plan_runtime_events(roots, &sessions, &mut conflicts, &mut skipped_paths)?; + let session_bytes = sessions.iter().try_fold(0u64, |total, session| { + expected_session_bytes(session).map(|bytes| total.saturating_add(bytes)) + })?; + let event_bytes = runtime_events + .iter() + .map(|event| { + fs::metadata(&event.source_path) + .map(|metadata| metadata.len()) + .unwrap_or(0) + }) + .sum::(); + let workspace_bytes = fs::metadata(&source_workspace_path) + .map_err(|error| io_error(&source_workspace_path, error))? + .len(); + let target_workspace_existed = target_workspace_path.exists(); + let target_workspace_hash = target_workspace_existed + .then(|| hash_file(&target_workspace_path)) + .transpose()?; + + let assistant_bytes = assistant_workspaces + .iter() + .map(|workspace| workspace.logical_bytes) + .sum::(); + Ok(WorkspaceSessionsPlan { + workspace_data, + workspace_id_map, + assistant_workspaces, + sessions, + runtime_events, + conflicts, + requires_relocation, + skipped_paths, + target_workspace_existed, + target_workspace_hash, + logical_bytes: workspace_bytes + .saturating_add(assistant_bytes) + .saturating_add(session_bytes) + .saturating_add(event_bytes), + }) +} + +fn normalize_legacy_workspace_for_current( + workspace: &mut WorkspaceInfo, +) -> LegacyMigrationResult<()> { + if workspace.workspace_kind == WorkspaceKind::Remote { + let normalized = normalize_remote_workspace_path(&workspace.root_path.to_string_lossy()); + if !normalized.starts_with('/') { + return Err(LegacyMigrationError::UnsupportedSource(format!( + "remote Workspace {} does not use an absolute POSIX root", + workspace.id + ))); + } + workspace.root_path = PathBuf::from(normalized); + } else { + workspace.metadata.insert( + "sshHost".to_string(), + serde_json::Value::String(LOCAL_WORKSPACE_SSH_HOST.to_string()), + ); + if workspace.root_path.exists() { + let (canonical, _) = canonicalize_local_workspace_root(&workspace.root_path) + .map_err(|error| owner_error("canonicalize legacy Workspace root", error))?; + workspace.root_path = canonical; + } + } + Ok(()) +} + +fn plan_assistant_workspaces( + roots: &MigrationRoots, + conflicts: &mut Vec, +) -> LegacyMigrationResult> { + let source_root = roots.legacy_home_root.join("personal_assistant"); + if !source_root.exists() { + return Ok(Vec::new()); + } + let target_root = roots.target_home_root.join("personal_assistant"); + let mut planned = Vec::new(); + for source_path in child_directories(&source_root)? { + let relative_path = PathBuf::from(file_name(&source_path)?); + let (expected_hash, logical_bytes) = hash_tree_with_size(&source_path)?; + let target_path = target_root.join(&relative_path); + let action = if !target_path.exists() { + SessionImportAction::Import + } else if hash_tree(&target_path)? == expected_hash { + SessionImportAction::Duplicate + } else { + conflicts.push(MigrationConflict { + domain: MigrationDomainId::WorkspaceSessions, + code: "assistant_workspace_target_wins".to_string(), + source_summary: format!( + "legacy personal assistant workspace {}", + relative_path.display() + ), + target_summary: format!( + "current personal assistant workspace {}", + relative_path.display() + ), + resolution: ConflictResolution::TargetWins, + }); + SessionImportAction::TargetWins + }; + planned.push(PlannedAssistantWorkspace { + relative_path, + source_path, + action, + expected_hash, + logical_bytes, + }); + } + Ok(planned) +} + +fn relocate_assistant_session_metadata( + metadata: &mut SessionMetadata, + relocations: &BTreeMap, +) -> Option { + let project_workspace = + relocate_session_path(&mut metadata.project_workspace_path, relocations); + let workspace = relocate_session_path(&mut metadata.workspace_path, relocations); + let execution = metadata.execution_target.as_mut().and_then(|target| { + let relocated = relocations.get(&native_path_key(Path::new(&target.root_path)))?; + target.root_path = relocated.to_string_lossy().into_owned(); + Some(relocated.clone()) + }); + project_workspace.or(workspace).or(execution) +} + +fn relocate_session_path( + value: &mut Option, + relocations: &BTreeMap, +) -> Option { + let relocated = relocations.get(&native_path_key(Path::new(value.as_deref()?)))?; + *value = Some(relocated.to_string_lossy().into_owned()); + Some(relocated.clone()) +} + +fn relocate_assistant_session_state( + legacy_home_root: &Path, + state_path: &Path, + relocations: &BTreeMap, +) -> LegacyMigrationResult>> { + if !state_path.is_file() { + return Ok(None); + } + let mut state: serde_json::Value = read_bounded_json(legacy_home_root, state_path)?; + let Some(config) = state + .get_mut("config") + .and_then(serde_json::Value::as_object_mut) + else { + return Ok(None); + }; + + let mut changed = false; + for key in ["workspace_path", "project_workspace_path"] { + if let Some(value) = config.get_mut(key) { + changed |= relocate_json_path(value, relocations); + } + } + if let Some(execution_target) = config + .get_mut("execution_target") + .and_then(serde_json::Value::as_object_mut) + { + for key in ["rootPath", "root_path"] { + if let Some(value) = execution_target.get_mut(key) { + changed |= relocate_json_path(value, relocations); + } + } + } + + changed + .then(|| serde_json::to_vec(&state).map_err(json_error)) + .transpose() +} + +fn relocate_json_path( + value: &mut serde_json::Value, + relocations: &BTreeMap, +) -> bool { + let Some(source_path) = value.as_str() else { + return false; + }; + let Some(target_path) = relocations.get(&native_path_key(Path::new(source_path))) else { + return false; + }; + *value = serde_json::Value::String(target_path.to_string_lossy().into_owned()); + true +} + +fn assistant_session_runtime_relative(workspace_path: PathBuf) -> PathBuf { + let canonical = dunce::canonicalize(&workspace_path).unwrap_or(workspace_path); + let slug = PathManager::build_project_runtime_slug(&canonical.to_string_lossy()); + PathBuf::from("projects").join(slug).join("sessions") +} + +fn native_path_key(path: &Path) -> String { + let key = path + .to_string_lossy() + .replace('\\', "/") + .trim_end_matches('/') + .to_string(); + #[cfg(windows)] + { + key.to_ascii_lowercase() + } + #[cfg(not(windows))] + { + key + } +} + +fn plan_sessions( + roots: &MigrationRoots, + target_sessions: &HashMap>, + assistant_path_relocations: &BTreeMap, + conflicts: &mut Vec, +) -> LegacyMigrationResult<(Vec, Vec)> { + let session_roots = find_session_roots(&roots.legacy_home_root)?; + let mut sessions = Vec::new(); + let mut skipped_paths = Vec::new(); + let mut source_ids = HashMap::::new(); + for sessions_root in session_roots { + let source_runtime_relative = sessions_root + .strip_prefix(&roots.legacy_home_root) + .map_err(|_| LegacyMigrationError::PathEscape(sessions_root.clone()))? + .to_path_buf(); + for session_dir in child_directories(&sessions_root)? { + let session_id = file_name(&session_dir)?; + validate_session_id(&session_id).map_err(|error| { + LegacyMigrationError::UnsupportedSource(format!( + "legacy Session id is unsafe: {error}" + )) + })?; + let metadata_path = session_dir.join("metadata.json"); + match fs::symlink_metadata(&metadata_path) { + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + skipped_paths.push(relative_display(&roots.legacy_home_root, &session_dir)); + continue; + } + Err(error) => return Err(io_error(&metadata_path, error)), + } + let metadata_file: StoredSessionMetadataFile = + read_bounded_json(&roots.legacy_home_root, &metadata_path)?; + if metadata_file.schema_version > SESSION_STORAGE_SCHEMA_VERSION { + return Err(LegacyMigrationError::UnsupportedSource(format!( + "Session {session_id} uses unsupported schema {}", + metadata_file.schema_version + ))); + } + if metadata_file.metadata.session_id != session_id { + return Err(LegacyMigrationError::UnsupportedSource(format!( + "Session directory id does not match its metadata: {session_id}" + ))); + } + let turns = read_session_turns(&roots.legacy_home_root, &session_dir, &session_id)?; + let mut session_metadata = metadata_file.metadata; + let relocated_assistant_path = relocate_assistant_session_metadata( + &mut session_metadata, + assistant_path_relocations, + ); + let runtime_relative = relocated_assistant_path + .clone() + .map(assistant_session_runtime_relative) + .unwrap_or_else(|| source_runtime_relative.clone()); + let bundle = OfflineSessionBundle { + metadata: session_metadata, + turns, + }; + bundle + .validate() + .map_err(|error| owner_error("validate legacy Session", error))?; + let auxiliary_files = collect_auxiliary_session_files( + &roots.legacy_home_root, + &session_dir, + &mut skipped_paths, + )?; + let state_bytes_override = if relocated_assistant_path.is_some() { + relocate_assistant_session_state( + &roots.legacy_home_root, + &session_dir.join("state.json"), + assistant_path_relocations, + )? + } else { + None + }; + let expected_hash = + expected_session_hash(&bundle, &auxiliary_files, state_bytes_override.as_deref())?; + if auxiliary_files + .len() + .saturating_add(bundle.turns.len()) + .saturating_add(1) + > MAX_SESSION_FILES + { + return Err(LegacyMigrationError::ResourceLimit(format!( + "Session contains more than {MAX_SESSION_FILES} files: {}", + session_dir.display() + ))); + } + if expected_bundle_bytes(&bundle, &auxiliary_files, state_bytes_override.as_deref())? + > MAX_SESSION_BYTES + { + return Err(LegacyMigrationError::ResourceLimit(format!( + "Session exceeds {MAX_SESSION_BYTES} bytes: {}", + session_dir.display() + ))); + } + if let Some(previous_hash) = + source_ids.insert(session_id.clone(), expected_hash.clone()) + { + if previous_hash != expected_hash { + return Err(LegacyMigrationError::UnsupportedSource(format!( + "legacy Session id appears with different contents: {session_id}" + ))); + } + skipped_paths.push(relative_display(&roots.legacy_home_root, &session_dir)); + continue; + } + + let target_same_path = roots + .target_home_root + .join(&runtime_relative) + .join(&session_id); + let action = match target_sessions.get(&session_id) { + None => SessionImportAction::Import, + Some(paths) + if paths.len() == 1 + && paths[0] == target_same_path + && hash_tree(&target_same_path)? == expected_hash => + { + SessionImportAction::Duplicate + } + Some(_) => { + conflicts.push(MigrationConflict { + domain: MigrationDomainId::WorkspaceSessions, + code: "session_target_wins".to_string(), + source_summary: format!("legacy Session {session_id}"), + target_summary: format!("current Session {session_id}"), + resolution: ConflictResolution::TargetWins, + }); + SessionImportAction::TargetWins + } + }; + sessions.push(PlannedSession { + runtime_relative: runtime_relative.clone(), + bundle, + auxiliary_files, + state_bytes_override, + action, + expected_hash, + }); + } + } + sessions.sort_by(|left, right| { + (&left.runtime_relative, &left.bundle.metadata.session_id) + .cmp(&(&right.runtime_relative, &right.bundle.metadata.session_id)) + }); + Ok((sessions, skipped_paths)) +} + +fn plan_runtime_events( + roots: &MigrationRoots, + sessions: &[PlannedSession], + conflicts: &mut Vec, + skipped_paths: &mut Vec, +) -> LegacyMigrationResult> { + let source_root = roots.legacy_home_root.join("runtime-events"); + if !source_root.exists() { + return Ok(Vec::new()); + } + reject_linked_directory(&source_root)?; + let known_sessions = sessions + .iter() + .map(|session| (session.bundle.metadata.session_id.as_str(), session.action)) + .collect::>(); + let mut planned = Vec::new(); + for entry in read_dir_sorted(&source_root)? { + let path = entry.path(); + let metadata = fs::symlink_metadata(&path).map_err(|error| io_error(&path, error))?; + if metadata.file_type().is_symlink() || is_reparse_point(&metadata) { + return Err(LegacyMigrationError::LinkedPath(path)); + } + if !metadata.is_file() || path.extension().and_then(|value| value.to_str()) != Some("jsonl") + { + skipped_paths.push(relative_display(&roots.legacy_home_root, &path)); + continue; + } + if metadata.len() > MAX_RUNTIME_EVENT_BYTES { + return Err(LegacyMigrationError::ResourceLimit(format!( + "runtime event log exceeds {MAX_RUNTIME_EVENT_BYTES} bytes: {}", + relative_display(&roots.legacy_home_root, &path) + ))); + } + validate_regular_file(&roots.legacy_home_root, &path)?; + let session_id = path + .file_stem() + .and_then(|value| value.to_str()) + .ok_or_else(|| { + LegacyMigrationError::UnsupportedSource( + "runtime event log name is not valid UTF-8".to_string(), + ) + })? + .to_string(); + let Some(session_action) = known_sessions.get(session_id.as_str()).copied() else { + skipped_paths.push(relative_display(&roots.legacy_home_root, &path)); + continue; + }; + let summary = validate_runtime_event_log(&path, &session_id) + .map_err(|error| owner_error("read legacy runtime event log", error))?; + let expected_hash = hash_file(&path)?; + let target = roots + .target_home_root + .join("runtime-events") + .join(format!("{session_id}.jsonl")); + let action = if session_action == SessionImportAction::TargetWins { + SessionImportAction::TargetWins + } else if !target.exists() { + SessionImportAction::Import + } else if hash_file(&target)? == expected_hash { + SessionImportAction::Duplicate + } else { + conflicts.push(MigrationConflict { + domain: MigrationDomainId::WorkspaceSessions, + code: "runtime_event_target_wins".to_string(), + source_summary: format!("legacy runtime event log for {session_id}"), + target_summary: format!("current runtime event log for {session_id}"), + resolution: ConflictResolution::TargetWins, + }); + SessionImportAction::TargetWins + }; + planned.push(PlannedRuntimeEvent { + session_id, + source_path: path, + action, + expected_hash, + turn_ids: summary.turn_ids, + }); + } + planned.sort_by(|left, right| left.session_id.cmp(&right.session_id)); + Ok(planned) +} + +fn read_session_turns( + legacy_home_root: &Path, + session_dir: &Path, + session_id: &str, +) -> LegacyMigrationResult> { + let turns_dir = session_dir.join("turns"); + if !turns_dir.exists() { + return Ok(Vec::new()); + } + reject_linked_directory(&turns_dir)?; + let mut turns = Vec::new(); + for entry in read_dir_sorted(&turns_dir)? { + let path = entry.path(); + validate_regular_file(legacy_home_root, &path)?; + let file_name = file_name(&path)?; + let file_index = file_name + .strip_prefix("turn-") + .and_then(|value| value.strip_suffix(".json")) + .and_then(|value| value.parse::().ok()) + .ok_or_else(|| { + LegacyMigrationError::UnsupportedSource(format!( + "unsupported legacy Turn filename: {file_name}" + )) + })?; + let stored: StoredDialogTurnFile = read_bounded_json(legacy_home_root, &path)?; + if stored.schema_version > SESSION_STORAGE_SCHEMA_VERSION { + return Err(LegacyMigrationError::UnsupportedSource(format!( + "Session {session_id} Turn uses unsupported schema {}", + stored.schema_version + ))); + } + if stored.turn.session_id != session_id || stored.turn.turn_index != file_index { + return Err(LegacyMigrationError::UnsupportedSource(format!( + "legacy Turn identity does not match its storage path in Session {session_id}" + ))); + } + turns.push(stored.turn); + } + turns.sort_by_key(|turn| turn.turn_index); + Ok(turns) +} + +fn collect_auxiliary_session_files( + legacy_home_root: &Path, + session_dir: &Path, + skipped_paths: &mut Vec, +) -> LegacyMigrationResult> { + let mut files = Vec::new(); + for entry in read_dir_sorted(session_dir)? { + let path = entry.path(); + let name = file_name(&path)?; + let metadata = fs::symlink_metadata(&path).map_err(|error| io_error(&path, error))?; + if metadata.file_type().is_symlink() || is_reparse_point(&metadata) { + return Err(LegacyMigrationError::LinkedPath(path)); + } + if metadata.is_file() { + if name == "metadata.json" { + continue; + } + if SESSION_REBUILDABLE_ROOT_FILES.contains(&name.as_str()) { + continue; + } + if SESSION_ROOT_FILES.contains(&name.as_str()) { + validate_owned_file(legacy_home_root, session_dir, &path, &mut files)?; + } else { + skipped_paths.push(relative_display(legacy_home_root, &path)); + } + } else if metadata.is_dir() { + if name == "turns" { + continue; + } + if SESSION_OWNED_DIRECTORIES.contains(&name.as_str()) { + collect_owned_directory(legacy_home_root, session_dir, &path, 0, &mut files)?; + } else { + skipped_paths.push(relative_display(legacy_home_root, &path)); + } + } else { + skipped_paths.push(relative_display(legacy_home_root, &path)); + } + } + enforce_session_limits(session_dir, &files)?; + Ok(files) +} + +fn collect_owned_directory( + legacy_home_root: &Path, + session_dir: &Path, + directory: &Path, + depth: usize, + files: &mut Vec<(PathBuf, PathBuf)>, +) -> LegacyMigrationResult<()> { + if depth > MAX_RUNTIME_DEPTH { + return Err(LegacyMigrationError::ResourceLimit(format!( + "Session directory depth exceeds {MAX_RUNTIME_DEPTH}: {}", + relative_display(legacy_home_root, directory) + ))); + } + reject_linked_directory(directory)?; + for entry in read_dir_sorted(directory)? { + let path = entry.path(); + let metadata = fs::symlink_metadata(&path).map_err(|error| io_error(&path, error))?; + if metadata.file_type().is_symlink() || is_reparse_point(&metadata) { + return Err(LegacyMigrationError::LinkedPath(path)); + } + if metadata.is_dir() { + collect_owned_directory(legacy_home_root, session_dir, &path, depth + 1, files)?; + } else if metadata.is_file() { + validate_owned_file(legacy_home_root, session_dir, &path, files)?; + } + } + Ok(()) +} + +fn validate_owned_file( + legacy_home_root: &Path, + session_dir: &Path, + path: &Path, + files: &mut Vec<(PathBuf, PathBuf)>, +) -> LegacyMigrationResult<()> { + validate_regular_file(legacy_home_root, path)?; + let relative = path + .strip_prefix(session_dir) + .map_err(|_| LegacyMigrationError::PathEscape(path.to_path_buf()))? + .to_path_buf(); + files.push((relative, path.to_path_buf())); + Ok(()) +} + +fn enforce_session_limits( + session_dir: &Path, + files: &[(PathBuf, PathBuf)], +) -> LegacyMigrationResult<()> { + let mut total = 0u64; + for (_, path) in files { + let size = fs::metadata(path) + .map_err(|error| io_error(path, error))? + .len(); + if size > MAX_SESSION_FILE_BYTES { + return Err(LegacyMigrationError::ResourceLimit(format!( + "Session file exceeds {MAX_SESSION_FILE_BYTES} bytes: {}", + path.display() + ))); + } + total = total.saturating_add(size); + } + if total > MAX_SESSION_BYTES { + return Err(LegacyMigrationError::ResourceLimit(format!( + "Session exceeds {MAX_SESSION_BYTES} bytes: {}", + session_dir.display() + ))); + } + Ok(()) +} + +fn index_target_sessions( + roots: &MigrationRoots, +) -> LegacyMigrationResult>> { + let mut by_id = HashMap::>::new(); + for sessions_root in find_session_roots(&roots.target_home_root)? { + for session_dir in child_directories(&sessions_root)? { + by_id + .entry(file_name(&session_dir)?) + .or_default() + .push(session_dir); + } + } + for paths in by_id.values_mut() { + paths.sort(); + } + Ok(by_id) +} + +fn find_session_roots(home_root: &Path) -> LegacyMigrationResult> { + let mut found = Vec::new(); + let mut visited = 0usize; + for name in ["projects", "remote_ssh", "personal_assistant"] { + let root = home_root.join(name); + if root.exists() { + find_session_roots_recursive(&root, 0, &mut visited, &mut found)?; + } + } + found.sort(); + Ok(found) +} + +fn find_session_roots_recursive( + directory: &Path, + depth: usize, + visited: &mut usize, + found: &mut Vec, +) -> LegacyMigrationResult<()> { + if depth > MAX_RUNTIME_DEPTH { + return Err(LegacyMigrationError::ResourceLimit(format!( + "workspace runtime depth exceeds {MAX_RUNTIME_DEPTH}: {}", + directory.display() + ))); + } + *visited = visited.saturating_add(1); + if *visited > MAX_RUNTIME_DIRECTORIES { + return Err(LegacyMigrationError::ResourceLimit(format!( + "workspace runtime contains more than {MAX_RUNTIME_DIRECTORIES} directories" + ))); + } + reject_linked_directory(directory)?; + if directory.file_name().and_then(|value| value.to_str()) == Some("sessions") { + found.push(directory.to_path_buf()); + return Ok(()); + } + for child in child_directories(directory)? { + find_session_roots_recursive(&child, depth + 1, visited, found)?; + } + Ok(()) +} + +fn child_directories(directory: &Path) -> LegacyMigrationResult> { + let mut paths = Vec::new(); + for entry in read_dir_sorted(directory)? { + let path = entry.path(); + let metadata = fs::symlink_metadata(&path).map_err(|error| io_error(&path, error))?; + if metadata.file_type().is_symlink() || is_reparse_point(&metadata) { + return Err(LegacyMigrationError::LinkedPath(path)); + } + if metadata.is_dir() { + paths.push(path); + } + } + Ok(paths) +} + +fn read_dir_sorted(directory: &Path) -> LegacyMigrationResult> { + let mut entries = fs::read_dir(directory) + .map_err(|error| io_error(directory, error))? + .collect::, _>>() + .map_err(|error| io_error(directory, error))?; + entries.sort_by_key(|entry| entry.file_name()); + Ok(entries) +} + +fn reject_linked_directory(path: &Path) -> LegacyMigrationResult<()> { + let metadata = fs::symlink_metadata(path).map_err(|error| io_error(path, error))?; + if metadata.file_type().is_symlink() || is_reparse_point(&metadata) { + return Err(LegacyMigrationError::LinkedPath(path.to_path_buf())); + } + if !metadata.is_dir() { + return Err(LegacyMigrationError::UnsupportedSource(format!( + "expected a directory at {}", + path.display() + ))); + } + Ok(()) +} + +#[cfg(windows)] +fn is_reparse_point(metadata: &fs::Metadata) -> bool { + use std::os::windows::fs::MetadataExt; + metadata.file_attributes() & 0x0400 != 0 +} + +#[cfg(not(windows))] +fn is_reparse_point(_metadata: &fs::Metadata) -> bool { + false +} + +fn merge_reference_list( + target: Option<&Vec>, + source: &[String], + id_map: &BTreeMap, + workspaces: &HashMap, +) -> Vec { + let mut merged = Vec::new(); + let mut seen = HashSet::new(); + for id in target + .into_iter() + .flatten() + .cloned() + .chain(source.iter().filter_map(|id| id_map.get(id)).cloned()) + { + if workspaces.contains_key(&id) && seen.insert(id.clone()) { + merged.push(id); + } + } + merged +} + +fn expected_session_hash( + bundle: &OfflineSessionBundle, + auxiliary_files: &[(PathBuf, PathBuf)], + state_bytes_override: Option<&[u8]>, +) -> LegacyMigrationResult { + let mut entries = Vec::new(); + entries.push(( + PathBuf::from("metadata.json"), + serde_json::to_vec(&StoredSessionMetadataFile::new(bundle.metadata.clone())) + .map_err(json_error)?, + )); + for turn in &bundle.turns { + entries.push(( + PathBuf::from("turns").join(format!("turn-{:04}.json", turn.turn_index)), + serde_json::to_vec(&StoredDialogTurnFile::new(turn.clone())).map_err(json_error)?, + )); + } + for (relative, source) in auxiliary_files { + if relative == Path::new("state.json") { + if let Some(bytes) = state_bytes_override { + entries.push((relative.clone(), bytes.to_vec())); + continue; + } + } + entries.push(( + relative.clone(), + fs::read(source).map_err(|error| io_error(source, error))?, + )); + } + Ok(hash_entries(entries)) +} + +fn expected_session_bytes(session: &PlannedSession) -> LegacyMigrationResult { + expected_bundle_bytes( + &session.bundle, + &session.auxiliary_files, + session.state_bytes_override.as_deref(), + ) +} + +fn expected_bundle_bytes( + bundle: &OfflineSessionBundle, + auxiliary_files: &[(PathBuf, PathBuf)], + state_bytes_override: Option<&[u8]>, +) -> LegacyMigrationResult { + let metadata_bytes = + serde_json::to_vec(&StoredSessionMetadataFile::new(bundle.metadata.clone())) + .map_err(json_error)? + .len() as u64; + let turn_bytes = bundle.turns.iter().try_fold(0u64, |total, turn| { + serde_json::to_vec(&StoredDialogTurnFile::new(turn.clone())) + .map(|bytes| total.saturating_add(bytes.len() as u64)) + .map_err(json_error) + })?; + let auxiliary_bytes = auxiliary_files.iter().try_fold( + 0u64, + |total, (relative, path)| -> LegacyMigrationResult { + if relative == Path::new("state.json") { + if let Some(bytes) = state_bytes_override { + return Ok(total.saturating_add(bytes.len() as u64)); + } + } + let bytes = fs::metadata(path) + .map_err(|error| io_error(path, error))? + .len(); + Ok(total.saturating_add(bytes)) + }, + )?; + Ok(metadata_bytes + .saturating_add(turn_bytes) + .saturating_add(auxiliary_bytes)) +} + +pub(crate) fn target_wins_session_ids( + roots: &MigrationRoots, +) -> LegacyMigrationResult> { + Ok(plan_workspace_sessions(roots)? + .sessions + .into_iter() + .filter(|session| session.action == SessionImportAction::TargetWins) + .map(|session| session.bundle.metadata.session_id) + .collect()) +} + +fn hash_entries(mut entries: Vec<(PathBuf, Vec)>) -> String { + entries.sort_by(|left, right| left.0.cmp(&right.0)); + let mut hasher = Sha256::new(); + for (relative, bytes) in entries { + hasher.update(relative.to_string_lossy().replace('\\', "/").as_bytes()); + hasher.update([0]); + hasher.update(bytes); + hasher.update([0]); + } + format!("sha256:{}", hex::encode(hasher.finalize())) +} + +fn hash_tree_with_size(root: &Path) -> LegacyMigrationResult<(String, u64)> { + let mut entries = Vec::new(); + collect_tree_entries(root, root, 0, &mut entries)?; + let logical_bytes = entries + .iter() + .map(|(_, bytes)| bytes.len() as u64) + .sum::(); + Ok((hash_entries(entries), logical_bytes)) +} + +fn hash_tree(root: &Path) -> LegacyMigrationResult { + hash_tree_with_size(root).map(|(hash, _)| hash) +} + +fn collect_tree_entries( + root: &Path, + directory: &Path, + depth: usize, + entries: &mut Vec<(PathBuf, Vec)>, +) -> LegacyMigrationResult<()> { + if depth > MAX_RUNTIME_DEPTH { + return Err(LegacyMigrationError::ResourceLimit(format!( + "target tree depth exceeds {MAX_RUNTIME_DEPTH}: {}", + root.display() + ))); + } + reject_linked_directory(directory)?; + for entry in read_dir_sorted(directory)? { + let path = entry.path(); + let metadata = fs::symlink_metadata(&path).map_err(|error| io_error(&path, error))?; + if metadata.file_type().is_symlink() || is_reparse_point(&metadata) { + return Err(LegacyMigrationError::LinkedPath(path)); + } + if metadata.is_dir() { + collect_tree_entries(root, &path, depth + 1, entries)?; + } else if metadata.is_file() { + let relative = path + .strip_prefix(root) + .map_err(|_| LegacyMigrationError::PathEscape(path.clone()))? + .to_path_buf(); + entries.push(( + relative, + fs::read(&path).map_err(|error| io_error(&path, error))?, + )); + } + if entries.len() > MAX_SESSION_FILES { + return Err(LegacyMigrationError::ResourceLimit(format!( + "target tree contains more than {MAX_SESSION_FILES} files: {}", + root.display() + ))); + } + } + Ok(()) +} + +fn require_tree_hash(path: &Path, expected: &str) -> LegacyMigrationResult<()> { + let actual = hash_tree(path)?; + if actual != expected { + return Err(LegacyMigrationError::InvalidRequest(format!( + "Session tree hash mismatch at {}", + path.display() + ))); + } + Ok(()) +} + +fn hash_file(path: &Path) -> LegacyMigrationResult { + fs::read(path) + .map(|bytes| format!("sha256:{}", hex::encode(Sha256::digest(bytes)))) + .map_err(|error| io_error(path, error)) +} + +fn require_file_hash(path: &Path, expected: &str) -> LegacyMigrationResult<()> { + if hash_file(path)? != expected { + return Err(LegacyMigrationError::InvalidRequest(format!( + "file hash mismatch at {}", + path.display() + ))); + } + Ok(()) +} + +fn install_directory_idempotent( + staged: &Path, + target: &Path, + expected_hash: &str, + run_id: &str, +) -> LegacyMigrationResult<()> { + if target.exists() { + if hash_tree(target)? == expected_hash { + return Ok(()); + } + return Err(LegacyMigrationError::InvalidRequest(format!( + "target changed after planning: {}", + target.display() + ))); + } + let parent = target.parent().ok_or_else(|| { + LegacyMigrationError::InvalidRequest(format!("target has no parent: {}", target.display())) + })?; + fs::create_dir_all(parent).map_err(|error| io_error(parent, error))?; + let temp = parent.join(format!( + ".migration-{}-{}", + safe_component(run_id), + target.file_name().unwrap_or_default().to_string_lossy() + )); + if temp.exists() { + fs::remove_dir_all(&temp).map_err(|error| io_error(&temp, error))?; + } + let install_result = copy_tree(staged, &temp) + .and_then(|()| fs::rename(&temp, target).map_err(|error| io_error(target, error))); + if install_result.is_err() && temp.exists() { + fs::remove_dir_all(&temp).map_err(|error| io_error(&temp, error))?; + } + install_result +} + +fn copy_tree(source: &Path, target: &Path) -> LegacyMigrationResult<()> { + reject_linked_directory(source)?; + fs::create_dir_all(target).map_err(|error| io_error(target, error))?; + for entry in read_dir_sorted(source)? { + let source_path = entry.path(); + let target_path = target.join(entry.file_name()); + let metadata = + fs::symlink_metadata(&source_path).map_err(|error| io_error(&source_path, error))?; + if metadata.file_type().is_symlink() || is_reparse_point(&metadata) { + return Err(LegacyMigrationError::LinkedPath(source_path)); + } + if metadata.is_dir() { + copy_tree(&source_path, &target_path)?; + } else if metadata.is_file() { + let bytes = fs::read(&source_path).map_err(|error| io_error(&source_path, error))?; + atomic_write_bytes(&target_path, &bytes)?; + } + } + Ok(()) +} + +fn install_file_idempotent( + staged: &Path, + target: &Path, + expected_hash: &str, +) -> LegacyMigrationResult<()> { + if target.exists() { + if hash_file(target)? == expected_hash { + return Ok(()); + } + return Err(LegacyMigrationError::InvalidRequest(format!( + "target changed after planning: {}", + target.display() + ))); + } + let bytes = fs::read(staged).map_err(|error| io_error(staged, error))?; + atomic_write_bytes(target, &bytes) +} + +fn remove_directory_if_matches(staged: &Path, target: &Path) -> LegacyMigrationResult<()> { + if staged.exists() && target.exists() && hash_tree(staged)? == hash_tree(target)? { + fs::remove_dir_all(target).map_err(|error| io_error(target, error))?; + } + Ok(()) +} + +fn remove_file_if_matches(staged: &Path, target: &Path) -> LegacyMigrationResult<()> { + if staged.exists() && target.exists() && hash_file(staged)? == hash_file(target)? { + fs::remove_file(target).map_err(|error| io_error(target, error))?; + } + Ok(()) +} + +fn verify_planned_file_state( + path: &Path, + expected_exists: bool, + expected_hash: Option<&str>, +) -> LegacyMigrationResult<()> { + if path.exists() != expected_exists { + return Err(LegacyMigrationError::InvalidRequest(format!( + "target changed after planning: {}", + path.display() + ))); + } + if let Some(expected_hash) = expected_hash { + require_file_hash(path, expected_hash)?; + } + Ok(()) +} + +fn orphaned_session_relationship_count(manifest: &WorkspaceSessionsManifest) -> usize { + let sessions = manifest + .sessions + .iter() + .map(|entry| entry.session_id.as_str()) + .collect::>(); + manifest + .sessions + .iter() + .filter(|entry| entry.action == SessionImportAction::Import) + .filter_map(|entry| entry.relationship.as_ref()) + .filter_map(|relationship| relationship.parent_session_id.as_deref()) + .filter(|parent_session_id| !sessions.contains(parent_session_id)) + .count() +} + +fn session_manifest_entry(session: &PlannedSession) -> SessionManifestEntry { + SessionManifestEntry { + runtime_relative: session + .runtime_relative + .to_string_lossy() + .replace('\\', "/"), + session_id: session.bundle.metadata.session_id.clone(), + action: session.action, + expected_hash: session.expected_hash.clone(), + turn_ids: session + .bundle + .turns + .iter() + .map(|turn| turn.turn_id.clone()) + .collect(), + relationship: session.bundle.metadata.relationship.clone(), + } +} + +fn assistant_workspace_manifest_entry( + workspace: &PlannedAssistantWorkspace, +) -> AssistantWorkspaceManifestEntry { + AssistantWorkspaceManifestEntry { + relative_path: workspace.relative_path.to_string_lossy().replace('\\', "/"), + action: workspace.action, + expected_hash: workspace.expected_hash.clone(), + } +} + +fn imported_assistant_workspaces( + manifest: &WorkspaceSessionsManifest, +) -> impl Iterator { + manifest + .assistant_workspaces + .iter() + .filter(|entry| entry.action == SessionImportAction::Import) +} + +fn imported_sessions( + manifest: &WorkspaceSessionsManifest, +) -> impl Iterator { + manifest + .sessions + .iter() + .filter(|entry| entry.action == SessionImportAction::Import) +} + +fn imported_runtime_events( + manifest: &WorkspaceSessionsManifest, +) -> impl Iterator { + manifest + .runtime_events + .iter() + .filter(|entry| entry.action == SessionImportAction::Import) +} + +pub(crate) fn read_workspace_sessions_manifest( + context: &DomainContext<'_>, +) -> LegacyMigrationResult { + read_bounded_json( + &context.layout.stage_root(), + &workspace_sessions_manifest_path(context), + ) +} + +fn workspace_sessions_manifest_path(context: &DomainContext<'_>) -> PathBuf { + stage_domain_dir(context, "workspace-sessions").join("manifest.json") +} + +fn source_workspace_data_path(roots: &MigrationRoots) -> PathBuf { + roots.legacy_user_root.join("data/workspace_data.json") +} + +fn target_workspace_data_path(roots: &MigrationRoots) -> PathBuf { + roots.target_user_root.join("data/workspace_data.json") +} + +fn path_from_manifest(value: &str) -> LegacyMigrationResult { + let path = PathBuf::from(value); + if path.as_os_str().is_empty() + || path.is_absolute() + || path.components().any(|component| { + matches!( + component, + Component::ParentDir | Component::RootDir | Component::Prefix(_) + ) + }) + { + return Err(LegacyMigrationError::PathEscape(path)); + } + Ok(path) +} + +fn file_name(path: &Path) -> LegacyMigrationResult { + path.file_name() + .and_then(|value| value.to_str()) + .map(str::to_string) + .ok_or_else(|| { + LegacyMigrationError::UnsupportedSource(format!( + "path component is not valid UTF-8: {}", + path.display() + )) + }) +} + +fn safe_component(value: &str) -> String { + value + .chars() + .map(|character| { + if character.is_ascii_alphanumeric() || matches!(character, '-' | '_') { + character + } else { + '_' + } + }) + .collect() +} + +fn offline_runtime() -> LegacyMigrationResult { + tokio::runtime::Builder::new_current_thread() + .enable_time() + .build() + .map_err(|error| { + LegacyMigrationError::InvalidRequest(format!( + "failed to initialize offline Session writer: {error}" + )) + }) +} + +fn owner_error(context: &str, error: impl std::fmt::Display) -> LegacyMigrationError { + LegacyMigrationError::InvalidRequest(format!("{context}: {error}")) +} + +fn json_error(error: serde_json::Error) -> LegacyMigrationError { + LegacyMigrationError::InvalidRequest(format!("JSON conversion failed: {error}")) +} + +#[cfg(test)] +mod tests { + use super::*; + use openbitfun_core_types::SessionExecutionTarget; + + #[test] + fn remote_workspace_paths_are_normalized_with_posix_semantics() { + let mut workspace: WorkspaceInfo = serde_json::from_value(serde_json::json!({ + "id": "legacy-remote", + "name": "Remote fixture", + "rootPath": "\\srv\\repo\\", + "workspaceType": "Other", + "workspaceKind": "remote", + "status": "Inactive", + "languages": [], + "openedAt": "2026-01-01T00:00:00Z", + "lastAccessed": "2026-01-01T00:00:00Z", + "description": null, + "tags": [], + "statistics": null, + "relatedPaths": [], + "metadata": { + "sshHost": "fixture.example", + "connectionId": "fixture-connection" + } + })) + .unwrap(); + + normalize_legacy_workspace_for_current(&mut workspace).unwrap(); + assert_eq!(workspace.root_path.to_string_lossy(), "/srv/repo"); + assert!(current_workspace_storage_id(&workspace) + .unwrap() + .starts_with("remote_")); + } + + #[test] + fn personal_assistant_tree_and_session_paths_are_rehomed_together() { + let temp = test_tempdir("personal-assistant"); + let roots = fixture_roots(temp.path()); + let fixture = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../services/legacy-migration/tests/fixtures/v0.2.19"); + copy_tree(&fixture.join("user-root"), &roots.legacy_user_root).unwrap(); + copy_tree(&fixture.join("home"), &roots.legacy_home_root).unwrap(); + + let source_assistant = roots + .legacy_home_root + .join("personal_assistant/workspace-assistant"); + atomic_write_bytes(&source_assistant.join("IDENTITY.md"), b"Assistant identity").unwrap(); + atomic_write_bytes( + &source_assistant.join("user-content/page.html"), + b"

preserved

", + ) + .unwrap(); + let target_assistant = roots + .target_home_root + .join("personal_assistant/workspace-assistant"); + + let workspace_path = source_workspace_data_path(&roots); + let mut workspace_data: serde_json::Value = + serde_json::from_slice(&fs::read(&workspace_path).unwrap()).unwrap(); + // Use a native absolute path instead of the archived Windows path. + workspace_data["workspaces"]["workspace-1"]["rootPath"] = + serde_json::json!(roots.legacy_home_root.join("fixture-workspace")); + workspace_data["workspaces"]["assistant-legacy"] = serde_json::json!({ + "id": "assistant-legacy", + "name": "Personal assistant", + "rootPath": source_assistant, + "workspaceType": "Other", + "workspaceKind": "assistant", + "status": "Inactive", + "languages": [], + "openedAt": "2026-01-01T00:00:00Z", + "lastAccessed": "2026-01-01T00:00:00Z", + "description": null, + "tags": [], + "statistics": null, + "relatedPaths": [], + "metadata": { "sshHost": "localhost" } + }); + workspace_data["recent_assistant_workspaces"] = serde_json::json!(["assistant-legacy"]); + atomic_write_json(&workspace_path, &workspace_data).unwrap(); + + let metadata_path = roots + .legacy_home_root + .join("projects/c--fixture-workspace/sessions/session-1/metadata.json"); + 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.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())); + atomic_write_json(&metadata_path, &metadata).unwrap(); + let session_dir = metadata_path.parent().unwrap(); + atomic_write_json( + &session_dir.join("state.json"), + &serde_json::json!({ + "schema_version": 1, + "config": { + "workspace_path": source_display.clone(), + "project_workspace_path": source_display.clone(), + "execution_target": { + "kind": "local", + "rootPath": source_display.clone() + }, + "unknown_future_field": "preserved" + }, + "historical_tool_path": source_display.clone() + }), + ) + .unwrap(); + atomic_write_json( + &session_dir.join("prompt_cache.json"), + &serde_json::json!({ + "schema_version": 1, + "user_context": { + "content": format!("Current Working Directory: {source_display}") + } + }), + ) + .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); + let assistant = plan + .workspace_data + .workspaces + .values() + .find(|workspace| workspace.workspace_kind == WorkspaceKind::Assistant) + .unwrap(); + assert_eq!(assistant.root_path, target_assistant); + assert!(!plan.requires_relocation.contains(&assistant.id)); + + let session = plan + .sessions + .iter() + .find(|session| session.bundle.metadata.session_id == "session-1") + .unwrap(); + let target_key = native_path_key(&target_assistant); + assert_eq!( + session + .bundle + .metadata + .workspace_path + .as_deref() + .map(Path::new) + .map(native_path_key), + Some(target_key.clone()) + ); + assert_eq!( + session + .bundle + .metadata + .project_workspace_path + .as_deref() + .map(Path::new) + .map(native_path_key), + Some(target_key.clone()) + ); + assert_eq!( + session + .bundle + .metadata + .execution_target + .as_ref() + .map(|target| native_path_key(Path::new(&target.root_path))), + Some(target_key) + ); + assert_eq!( + session.runtime_relative, + assistant_session_runtime_relative(target_assistant.clone()) + ); + assert!(!session + .auxiliary_files + .iter() + .any(|(relative, _)| relative == Path::new("prompt_cache.json"))); + assert!(!plan + .skipped_paths + .iter() + .any(|path| path.ends_with("prompt_cache.json"))); + + let migrated_state: serde_json::Value = serde_json::from_slice( + session + .state_bytes_override + .as_deref() + .expect("assistant Session state should be rewritten"), + ) + .unwrap(); + for pointer in [ + "/config/workspace_path", + "/config/project_workspace_path", + "/config/execution_target/rootPath", + ] { + assert_eq!( + migrated_state + .pointer(pointer) + .and_then(serde_json::Value::as_str) + .map(Path::new) + .map(native_path_key), + Some(native_path_key(&target_assistant)), + "{pointer} should use the migrated assistant workspace" + ); + } + assert_eq!( + migrated_state + .pointer("/config/unknown_future_field") + .and_then(serde_json::Value::as_str), + Some("preserved") + ); + assert_eq!( + migrated_state + .pointer("/historical_tool_path") + .and_then(serde_json::Value::as_str), + Some(source_display.as_str()) + ); + } + + fn fixture_roots(root: &Path) -> MigrationRoots { + let legacy_user_root = root.join("legacy-user"); + MigrationRoots { + legacy_skills_root: legacy_user_root.join("skills"), + legacy_user_root, + legacy_home_root: root.join("legacy-home"), + 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-skills"), + target_ssh_root: root.join("target-ssh"), + } + } + + fn test_tempdir(label: &str) -> tempfile::TempDir { + let root = std::env::var_os("OPENBITFUN_TEST_TMPDIR") + .map(PathBuf::from) + .unwrap_or_else(std::env::temp_dir); + fs::create_dir_all(&root).unwrap(); + tempfile::Builder::new() + .prefix(&format!("openbitfun-migration-{label}-")) + .tempdir_in(root) + .unwrap() + } +} diff --git a/src/crates/assembly/core/src/lib.rs b/src/crates/assembly/core/src/lib.rs index ac70cf81ae..e4145462fa 100644 --- a/src/crates/assembly/core/src/lib.rs +++ b/src/crates/assembly/core/src/lib.rs @@ -30,6 +30,8 @@ pub mod function_agents; // Function-based agents pub mod infrastructure; // AI clients, storage, logging, events #[cfg(feature = "external-sources")] mod instruction_sources; +#[cfg(feature = "legacy-migration")] +pub mod legacy_migration; #[cfg(feature = "tools-miniapp")] pub mod miniapp; // AI-generated instant apps (Zero-Dialect Runtime) #[cfg(feature = "agent-runtime")] diff --git a/src/crates/assembly/core/src/service/coordination_persistence.rs b/src/crates/assembly/core/src/service/coordination_persistence.rs new file mode 100644 index 0000000000..c60b6931e4 --- /dev/null +++ b/src/crates/assembly/core/src/service/coordination_persistence.rs @@ -0,0 +1,177 @@ +//! Physical schema owner for the durable Agent coordination database. +//! +//! Runtime coordination and offline legacy import both open this database, but +//! neither should carry a private copy of its versioning and repair rules. + +use crate::util::errors::{OpenBitFunError, OpenBitFunResult}; +use rusqlite::Connection; + +pub(crate) const COORDINATION_SCHEMA_VERSION: i64 = 2; + +pub(crate) fn initialize_coordination_schema(connection: &Connection) -> OpenBitFunResult<()> { + let version = connection + .query_row("PRAGMA user_version", [], |row| row.get::<_, i64>(0)) + .map_err(db_error)?; + if version > COORDINATION_SCHEMA_VERSION { + return Err(OpenBitFunError::service(format!( + "Agent coordination database schema {version} is newer than supported schema {COORDINATION_SCHEMA_VERSION}" + ))); + } + if version == 0 { + connection + .execute_batch( + r#" +CREATE TABLE coordination_sessions ( + parent_session_id TEXT PRIMARY KEY, + next_auto_agent_seq INTEGER NOT NULL DEFAULT 1, + updated_at_ms INTEGER NOT NULL +); + +CREATE TABLE agents ( + agent_pk INTEGER PRIMARY KEY AUTOINCREMENT, + parent_session_id TEXT NOT NULL, + agent_id TEXT NOT NULL, + child_session_id TEXT, + next_bg_seq INTEGER NOT NULL DEFAULT 1, + state TEXT NOT NULL CHECK (state IN ('active', 'historical')), + created_at_ms INTEGER NOT NULL, + UNIQUE(parent_session_id, agent_id), + UNIQUE(parent_session_id, child_session_id) +); + +CREATE TABLE background_tasks ( + task_pk INTEGER PRIMARY KEY AUTOINCREMENT, + parent_session_id TEXT NOT NULL, + agent_pk INTEGER NOT NULL, + bg_task_id TEXT NOT NULL, + bg_ordinal INTEGER NOT NULL, + parent_dialog_turn_id TEXT NOT NULL, + parent_tool_call_id TEXT NOT NULL, + child_dialog_turn_id TEXT NOT NULL, + status TEXT NOT NULL CHECK ( + status IN ('running', 'completed', 'partial_timeout', 'failed', 'cancelled', 'interrupted') + ), + error_code TEXT, + error_message TEXT, + execution_owner_token TEXT NOT NULL, + created_at_ms INTEGER NOT NULL, + terminal_at_ms INTEGER, + delivered_at_ms INTEGER, + delivered_parent_dialog_turn_id TEXT, + UNIQUE(parent_session_id, bg_task_id), + UNIQUE(agent_pk, bg_ordinal), + FOREIGN KEY(agent_pk) REFERENCES agents(agent_pk) ON DELETE CASCADE +); + +CREATE INDEX idx_background_tasks_wait + ON background_tasks(parent_session_id, delivered_at_ms, status, task_pk); +CREATE INDEX idx_background_tasks_parent_turn + ON background_tasks(parent_session_id, parent_dialog_turn_id); + +PRAGMA user_version = 1; + "#, + ) + .map_err(db_error)?; + } + if version < 2 { + connection + .execute_batch( + r#" +CREATE TABLE swarm_trees ( + root_session_id TEXT PRIMARY KEY, + created_at_ms INTEGER NOT NULL +); + +CREATE TABLE swarm_nodes ( + session_id TEXT PRIMARY KEY, + root_session_id TEXT NOT NULL, + parent_session_id TEXT, + agent_type TEXT NOT NULL, + depth INTEGER NOT NULL, + created_at_ms INTEGER NOT NULL, + FOREIGN KEY(root_session_id) REFERENCES swarm_trees(root_session_id) ON DELETE CASCADE +); + +CREATE INDEX idx_swarm_nodes_root ON swarm_nodes(root_session_id); +CREATE INDEX idx_swarm_nodes_parent ON swarm_nodes(parent_session_id); +PRAGMA user_version = 2; + "#, + ) + .map_err(db_error)?; + } + ensure_v2_additive_columns(connection)?; + Ok(()) +} + +/// Schema v2 shipped after the background-task delivery columns were added to +/// schema v1. A retired build could nevertheless leave a database stamped as +/// v2 without those additive columns. Keep the repair keyed to the physical +/// shape so reopening such a database is safe and idempotent. +fn ensure_v2_additive_columns(connection: &Connection) -> OpenBitFunResult<()> { + for (column, declaration) in [ + ("delivered_at_ms", "INTEGER"), + ("delivered_parent_dialog_turn_id", "TEXT"), + ] { + if !coordination_table_has_column(connection, "background_tasks", column)? { + connection + .execute_batch(&format!( + "ALTER TABLE background_tasks ADD COLUMN {column} {declaration};" + )) + .map_err(db_error)?; + } + } + connection + .execute_batch( + r#" +CREATE INDEX IF NOT EXISTS idx_background_tasks_wait + ON background_tasks(parent_session_id, delivered_at_ms, status, task_pk); +CREATE INDEX IF NOT EXISTS idx_background_tasks_parent_turn + ON background_tasks(parent_session_id, parent_dialog_turn_id); + "#, + ) + .map_err(db_error)?; + Ok(()) +} + +pub(crate) fn coordination_table_has_column( + connection: &Connection, + table: &str, + expected_column: &str, +) -> OpenBitFunResult { + let mut statement = connection + .prepare(&format!("PRAGMA table_info({table})")) + .map_err(db_error)?; + let columns = statement + .query_map([], |row| row.get::<_, String>(1)) + .map_err(db_error)?; + for column in columns { + if column.map_err(db_error)? == expected_column { + return Ok(true); + } + } + Ok(false) +} + +pub(crate) fn validate_coordination_agent_id(agent_id: &str) -> OpenBitFunResult<()> { + let valid = !agent_id.is_empty() + && agent_id.len() <= 32 + && agent_id + .bytes() + .enumerate() + .all(|(index, byte)| match byte { + b'a'..=b'z' => true, + b'0'..=b'9' | b'_' | b'-' => index > 0, + _ => false, + }); + if valid { + Ok(()) + } else { + Err(OpenBitFunError::tool( + "agent_id must match [a-z][a-z0-9_-]{0,31}".to_string(), + )) + } +} + +fn db_error(error: rusqlite::Error) -> OpenBitFunError { + OpenBitFunError::io(format!("Agent coordination database error: {error}")) +} diff --git a/src/crates/assembly/core/src/service/mod.rs b/src/crates/assembly/core/src/service/mod.rs index 812fd9f6a4..0470e535c3 100644 --- a/src/crates/assembly/core/src/service/mod.rs +++ b/src/crates/assembly/core/src/service/mod.rs @@ -11,6 +11,8 @@ pub(crate) mod bootstrap; // Workspace persona bootstrap helpers #[cfg(feature = "canvas-runtime")] pub mod canvas; // Canvas service compatibility facade pub mod config; // Config management +#[cfg(any(feature = "agent-runtime", feature = "legacy-migration"))] +pub(crate) mod coordination_persistence; #[cfg(all(feature = "agent-runtime", feature = "scheduled-jobs"))] pub mod cron; // Scheduled jobs #[cfg(feature = "dispatch-store")] @@ -39,6 +41,8 @@ pub mod runtime; // Managed runtime and capability management pub mod search; // Workspace search via managed flashgrep daemon #[cfg(feature = "local-storage")] pub mod session; // Session persistence +#[cfg(any(feature = "agent-runtime", feature = "legacy-migration"))] +pub(crate) mod session_projection_format; #[cfg(feature = "agent-runtime")] pub mod session_projection_store; // Durable append-only log of the executing Turn #[cfg(feature = "agent-runtime")] @@ -51,6 +55,9 @@ pub mod token_usage; // Token usage tracking pub mod web_search; // Provider-neutral WebSearch runtime and local credentials #[cfg(feature = "workspace-runtime")] pub mod workspace; // Workspace management // Diff calculation and merge service +#[cfg(all(feature = "legacy-migration", not(feature = "workspace-runtime")))] +#[path = "workspace/mod.rs"] +pub(crate) mod workspace; #[cfg(feature = "workspace-runtime")] pub mod workspace_runtime; // Workspace runtime layout / migration / initialization #[cfg(all(feature = "agent-runtime", feature = "git"))] diff --git a/src/crates/assembly/core/src/service/session_projection_format.rs b/src/crates/assembly/core/src/service/session_projection_format.rs new file mode 100644 index 0000000000..dddfd7579a --- /dev/null +++ b/src/crates/assembly/core/src/service/session_projection_format.rs @@ -0,0 +1,121 @@ +//! Stable on-disk envelope for in-flight Session runtime events. + +use crate::util::errors::{OpenBitFunError, OpenBitFunResult}; +use openbitfun_events::AgenticEvent; +use std::collections::BTreeSet; +use std::io::{BufRead, BufReader}; +use std::path::Path; + +#[derive(Debug, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct LoggedEvent { + pub(crate) stream_id: String, + pub(crate) cursor: u64, + pub(crate) event: AgenticEvent, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct RuntimeEventLogSummary { + pub(crate) stream_id: String, + pub(crate) event_count: usize, + pub(crate) turn_ids: BTreeSet, +} + +/// Validate an imported JSONL file through the same envelope and durable-prefix +/// semantics used by the runtime store. Migration keeps the original bytes and +/// ordering; a torn tail is retained but is not considered part of the readable +/// event prefix. +pub(crate) fn validate_runtime_event_log( + path: &Path, + expected_session_id: &str, +) -> OpenBitFunResult { + let file = std::fs::File::open(path).map_err(|error| { + OpenBitFunError::io(format!( + "Failed to open runtime event log {}: {error}", + path.display() + )) + })?; + let mut stream_id: Option = None; + let mut event_count = 0usize; + let mut turn_ids = BTreeSet::new(); + for (line_index, line) in BufReader::new(file).lines().enumerate() { + let line = line.map_err(|error| { + OpenBitFunError::io(format!( + "Failed to read runtime event log {} at line {}: {error}", + path.display(), + line_index + 1 + )) + })?; + if line.trim().is_empty() { + continue; + } + let Ok(record) = serde_json::from_str::(&line) else { + break; + }; + if let Some(session_id) = record.event.session_id() { + if session_id != expected_session_id { + return Err(OpenBitFunError::validation(format!( + "Runtime event log {} contains a different Session id", + path.display() + ))); + } + } + if let Some(turn_id) = record.event.turn_id() { + turn_ids.insert(turn_id.to_string()); + } + match stream_id.as_deref() { + Some(current) if current != record.stream_id => { + turn_ids.clear(); + event_count = 0; + stream_id = Some(record.stream_id); + } + None => stream_id = Some(record.stream_id), + _ => {} + } + event_count = event_count.saturating_add(1); + } + let stream_id = stream_id.ok_or_else(|| { + OpenBitFunError::validation(format!( + "Runtime event log {} contains no events", + path.display() + )) + })?; + Ok(RuntimeEventLogSummary { + stream_id, + event_count, + turn_ids, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use std::path::PathBuf; + + #[test] + fn imported_log_keeps_the_runtime_readers_torn_tail_semantics() { + let root = std::env::var_os("OPENBITFUN_TEST_TMPDIR") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from("E:/tmp")); + fs::create_dir_all(&root).unwrap(); + let temp = tempfile::Builder::new() + .prefix("openbitfun-runtime-event-") + .tempdir_in(root) + .unwrap(); + let path = temp.path().join("session-1.jsonl"); + fs::write( + &path, + concat!( + "{\"streamId\":\"stream-1\",\"cursor\":1,\"event\":{\"type\":\"TextChunk\",\"session_id\":\"session-1\",\"turn_id\":\"turn-1\",\"round_id\":\"round-1\",\"text\":\"durable\"}}\n", + "{\"streamId\":\"stream-1\",\"cursor\":" + ), + ) + .unwrap(); + + let summary = validate_runtime_event_log(&path, "session-1").unwrap(); + assert_eq!(summary.stream_id, "stream-1"); + assert_eq!(summary.event_count, 1); + assert_eq!(summary.turn_ids, BTreeSet::from(["turn-1".to_string()])); + } +} diff --git a/src/crates/assembly/core/src/service/session_projection_store.rs b/src/crates/assembly/core/src/service/session_projection_store.rs index 3887b890f6..3b7e2fbc71 100644 --- a/src/crates/assembly/core/src/service/session_projection_store.rs +++ b/src/crates/assembly/core/src/service/session_projection_store.rs @@ -10,6 +10,7 @@ //! Turn reaches a terminal state the Session record owns it and this log is //! dropped, so the two never describe the same thing at the same time. +use super::session_projection_format::LoggedEvent; use openbitfun_agent_runtime::sdk::{SessionEventProjectionStore, StoredSessionEvents}; use openbitfun_events::AgenticEvent; use std::collections::HashMap; @@ -18,16 +19,6 @@ use std::io::{BufRead, BufReader, Write}; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; -/// One appended line. `streamId` identifies the Runtime process that wrote it, -/// so a log left by an older process is never mistaken for current progress. -#[derive(serde::Serialize, serde::Deserialize)] -#[serde(rename_all = "camelCase")] -struct LoggedEvent { - stream_id: String, - cursor: u64, - event: AgenticEvent, -} - /// Open append handles, keyed by Session. Holding the handle is what makes a /// per-event write an append to an already-open file rather than an open/close /// cycle per token. diff --git a/src/crates/assembly/core/src/service/workspace/manager.rs b/src/crates/assembly/core/src/service/workspace/manager.rs index cc2aa04b20..891bd1013e 100644 --- a/src/crates/assembly/core/src/service/workspace/manager.rs +++ b/src/crates/assembly/core/src/service/workspace/manager.rs @@ -1,10 +1,15 @@ //! Workspace manager. +pub use super::types::{ + GitInfo, PrimaryAssistantKey, WorkspaceIdentity, WorkspaceInfo, WorkspaceKind, + WorkspaceStatistics, WorkspaceStatus, WorkspaceType, WorkspaceWorktreeInfo, +}; #[cfg(feature = "git")] use super::worktree_topology::global_worktree_topology_service; use super::WorktreeTopologyFreshness; use crate::util::{errors::*, FrontMatterMarkdown}; use log::warn; +pub use openbitfun_runtime_ports::RelatedPath; use openbitfun_services_core::workspace_identity::{ canonicalize_local_workspace_root, local_workspace_stable_storage_id, normalize_local_workspace_root_for_stable_id, normalize_remote_workspace_path, @@ -16,108 +21,8 @@ use std::collections::HashMap; use std::path::{Path, PathBuf}; use tokio::fs; -pub use openbitfun_runtime_ports::RelatedPath; - -/// Workspace type. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] -pub enum WorkspaceType { - RustProject, - NodeProject, - PythonProject, - JavaProject, - CppProject, - WebProject, - MobileProject, - Other, -} - -/// Workspace status. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub enum WorkspaceStatus { - Active, - Inactive, - Loading, - Error, - Archived, -} - -/// Workspace lifecycle kind. -#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, Hash)] -#[serde(rename_all = "lowercase")] -pub enum WorkspaceKind { - #[default] - Normal, - Assistant, - Remote, -} - -/// Stable identity of the assistant workspace that owns the primary role. -/// -/// Local workspace ids are derived from canonical storage paths, so the primary -/// selection is persisted using the assistant identity instead of that path-derived id. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(tag = "kind", rename_all = "snake_case")] -pub enum PrimaryAssistantKey { - BuiltIn, - Named { assistant_id: String }, -} - -impl PrimaryAssistantKey { - pub fn from_workspace(workspace: &WorkspaceInfo) -> Option { - if workspace.workspace_kind != WorkspaceKind::Assistant { - return None; - } - - Some(match workspace.assistant_id.as_deref() { - Some(assistant_id) if !assistant_id.trim().is_empty() => Self::Named { - assistant_id: assistant_id.trim().to_string(), - }, - _ => Self::BuiltIn, - }) - } - - pub fn matches(&self, workspace: &WorkspaceInfo) -> bool { - if workspace.workspace_kind != WorkspaceKind::Assistant { - return false; - } - - match (self, workspace.assistant_id.as_deref()) { - (Self::BuiltIn, None) => true, - (Self::Named { assistant_id }, Some(candidate)) => assistant_id == candidate, - _ => false, - } - } -} - pub(crate) const IDENTITY_FILE_NAME: &str = "IDENTITY.md"; -/// Parsed agent identity fields from `IDENTITY.md` frontmatter. -#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct WorkspaceIdentity { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub name: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub creature: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub vibe: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub avatar: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub emoji: Option, -} - -/// Git worktree metadata attached to a workspace. -#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct WorkspaceWorktreeInfo { - pub path: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub branch: Option, - pub main_repo_path: String, - pub is_main: bool, -} - #[derive(Debug, Clone, Default, Deserialize)] #[serde(default)] struct WorkspaceIdentityFrontmatter { @@ -175,6 +80,7 @@ impl WorkspaceIdentity { && self.emoji.is_none() } + #[cfg(any(feature = "agent-runtime", test))] pub(crate) fn collect_changed_fields( previous: Option<&WorkspaceIdentity>, current: Option<&WorkspaceIdentity>, @@ -222,62 +128,6 @@ fn normalize_identity_field(value: Option) -> Option { }) } -/// Workspace metadata. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct WorkspaceInfo { - pub id: String, - pub name: String, - #[serde(rename = "rootPath")] - pub root_path: PathBuf, - #[serde(rename = "workspaceType")] - pub workspace_type: WorkspaceType, - #[serde(rename = "workspaceKind", default)] - pub workspace_kind: WorkspaceKind, - #[serde( - rename = "assistantId", - default, - skip_serializing_if = "Option::is_none" - )] - pub assistant_id: Option, - pub status: WorkspaceStatus, - pub languages: Vec, - #[serde(rename = "openedAt")] - pub opened_at: chrono::DateTime, - #[serde(rename = "lastAccessed")] - pub last_accessed: chrono::DateTime, - pub description: Option, - pub tags: Vec, - pub statistics: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub identity: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub worktree: Option, - #[serde(rename = "relatedPaths", default)] - pub related_paths: Vec, - pub metadata: HashMap, -} - -/// Workspace statistics. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct WorkspaceStatistics { - pub total_files: usize, - pub total_directories: usize, - pub total_size_bytes: u64, - pub file_extensions: HashMap, - pub last_modified: Option>, - pub git_info: Option, -} - -/// Git information. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct GitInfo { - pub is_git_repo: bool, - pub current_branch: Option, - pub remote_url: Option, - pub has_uncommitted_changes: bool, - pub total_commits: Option, -} - /// Options for scanning a workspace. #[derive(Debug, Clone)] pub struct ScanOptions { @@ -343,14 +193,6 @@ impl Default for WorkspaceOpenOptions { } impl WorkspaceInfo { - /// SSH connection id persisted in [`WorkspaceInfo::metadata`] for remote workspaces. - pub fn remote_ssh_connection_id(&self) -> Option<&str> { - self.metadata - .get("connectionId") - .and_then(|v| v.as_str()) - .filter(|s| !s.is_empty()) - } - /// Creates a new workspace record. pub async fn new(root_path: PathBuf, options: WorkspaceOpenOptions) -> OpenBitFunResult { Self::new_inner(root_path, options, true).await diff --git a/src/crates/assembly/core/src/service/workspace/mod.rs b/src/crates/assembly/core/src/service/workspace/mod.rs index 375cbd7238..c4d29adb60 100644 --- a/src/crates/assembly/core/src/service/workspace/mod.rs +++ b/src/crates/assembly/core/src/service/workspace/mod.rs @@ -2,37 +2,50 @@ //! //! Full workspace management system: open, manage, scan, statistics, etc. +#[cfg(feature = "workspace-runtime")] pub mod factory; #[cfg(feature = "workspace-watch")] pub mod identity_watch; +#[cfg(feature = "workspace-runtime")] pub mod manager; +pub(crate) mod persistence; +#[cfg(feature = "workspace-runtime")] pub mod provider; +#[cfg(feature = "workspace-runtime")] pub mod service; +pub(crate) mod types; #[cfg(feature = "git")] pub mod worktree_topology; #[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[cfg(feature = "workspace-runtime")] pub enum WorktreeTopologyFreshness { Cached, ForceRefresh, } // Re-export main components +#[cfg(feature = "workspace-runtime")] pub use factory::WorkspaceFactory; #[cfg(feature = "workspace-watch")] pub use identity_watch::WorkspaceIdentityWatchService; +#[cfg(feature = "workspace-runtime")] pub use manager::{ GitInfo, PrimaryAssistantKey, RelatedPath, ScanOptions, WorkspaceIdentity, WorkspaceInfo, WorkspaceKind, WorkspaceManager, WorkspaceManagerConfig, WorkspaceManagerStatistics, WorkspaceOpenOptions, WorkspaceStatistics, WorkspaceStatus, WorkspaceSummary, WorkspaceType, WorkspaceWorktreeInfo, }; +#[cfg(feature = "workspace-runtime")] pub use provider::{WorkspaceCleanupResult, WorkspaceProvider, WorkspaceSystemSummary}; +#[cfg(feature = "workspace-runtime")] pub use service::{ get_global_workspace_service, set_global_workspace_service, BatchImportResult, BatchRemoveResult, WorkspaceActivityMode, WorkspaceCreateOptions, WorkspaceExport, WorkspaceHealthStatus, WorkspaceIdentityChangedEvent, WorkspaceImportResult, WorkspaceInfoUpdates, WorkspaceQuickSummary, WorkspaceService, }; +#[cfg(all(feature = "legacy-migration", not(feature = "workspace-runtime")))] +pub(crate) use types::{PrimaryAssistantKey, WorkspaceInfo, WorkspaceKind}; #[cfg(feature = "git")] pub use worktree_topology::{global_worktree_topology_service, WorktreeTopologyService}; diff --git a/src/crates/assembly/core/src/service/workspace/persistence.rs b/src/crates/assembly/core/src/service/workspace/persistence.rs new file mode 100644 index 0000000000..a8ffc22517 --- /dev/null +++ b/src/crates/assembly/core/src/service/workspace/persistence.rs @@ -0,0 +1,230 @@ +//! Current Workspace registry persistence contract and validation. + +use super::types::{PrimaryAssistantKey, WorkspaceInfo, WorkspaceKind}; +use crate::util::errors::{OpenBitFunError, OpenBitFunResult}; +use openbitfun_core_types::product_identity::product_id; +use openbitfun_services_core::workspace_identity::{ + canonicalize_local_workspace_root, local_workspace_stable_storage_id, + normalize_remote_workspace_path, remote_workspace_stable_id, LOCAL_WORKSPACE_SSH_HOST, +}; +use serde::{Deserialize, Serialize}; +use std::collections::{HashMap, HashSet}; +use std::path::Path; + +pub(crate) const WORKSPACE_PERSISTENCE_FORMAT_VERSION: u32 = 1; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) struct WorkspacePersistenceData { + #[serde(default)] + pub(crate) format_version: u32, + #[serde(default)] + pub(crate) product_id: String, + pub(crate) workspaces: HashMap, + #[serde(default)] + pub(crate) opened_workspace_ids: Vec, + pub(crate) current_workspace_id: Option, + #[serde(default)] + pub(crate) recent_workspaces: Vec, + #[serde(default)] + pub(crate) recent_assistant_workspaces: Vec, + #[serde(default)] + pub(crate) primary_assistant_key: Option, + pub(crate) saved_at: chrono::DateTime, +} + +pub(crate) fn current_workspace_storage_id(workspace: &WorkspaceInfo) -> OpenBitFunResult { + match workspace.workspace_kind { + WorkspaceKind::Remote => { + let ssh_host = workspace + .metadata + .get("sshHost") + .and_then(|value| value.as_str()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + unsupported_workspace_persistence(format!( + "remote workspace '{}' is missing sshHost", + workspace.id + )) + })?; + workspace.remote_ssh_connection_id().ok_or_else(|| { + unsupported_workspace_persistence(format!( + "remote workspace '{}' is missing connectionId", + workspace.id + )) + })?; + + let stored_root = workspace.root_path.to_string_lossy().replace('\\', "/"); + let normalized_root = normalize_remote_workspace_path(&stored_root); + if !normalized_root.starts_with('/') { + return Err(unsupported_workspace_persistence(format!( + "remote workspace '{}' does not use an absolute POSIX root", + workspace.id + ))); + } + if stored_root != normalized_root { + return Err(unsupported_workspace_persistence(format!( + "remote workspace '{}' rootPath is not normalized", + workspace.id + ))); + } + Ok(remote_workspace_stable_id(ssh_host, &normalized_root)) + } + WorkspaceKind::Normal | WorkspaceKind::Assistant => { + let ssh_host = workspace + .metadata + .get("sshHost") + .and_then(|value| value.as_str()) + .map(str::trim); + if ssh_host != Some(LOCAL_WORKSPACE_SSH_HOST) { + return Err(unsupported_workspace_persistence(format!( + "local workspace '{}' does not declare sshHost=localhost", + workspace.id + ))); + } + expected_persisted_local_workspace_id(&workspace.root_path).map_err(|error| { + unsupported_workspace_persistence(format!( + "local workspace '{}' is not canonical: {error}", + workspace.id + )) + }) + } + } +} + +pub(crate) fn validate_workspace_persistence_data( + data: &WorkspacePersistenceData, + miniapps_root: &Path, +) -> OpenBitFunResult<()> { + if data.format_version != WORKSPACE_PERSISTENCE_FORMAT_VERSION { + return Err(unsupported_workspace_persistence(format!( + "format_version {} is not supported; expected {}", + data.format_version, WORKSPACE_PERSISTENCE_FORMAT_VERSION + ))); + } + if data.product_id != product_id() { + return Err(unsupported_workspace_persistence(format!( + "product_id '{}' does not match '{}'", + data.product_id, + product_id() + ))); + } + + for (storage_id, workspace) in &data.workspaces { + if storage_id != &workspace.id { + return Err(unsupported_workspace_persistence(format!( + "workspace map key '{storage_id}' does not match record id '{}'", + workspace.id + ))); + } + let expected_id = current_workspace_storage_id(workspace)?; + if storage_id != &expected_id { + return Err(unsupported_workspace_persistence(format!( + "workspace id '{storage_id}' is not canonical; expected '{expected_id}'" + ))); + } + } + validate_workspace_reference_list( + &data.workspaces, + &data.opened_workspace_ids, + "opened_workspace_ids", + )?; + validate_workspace_reference_list( + &data.workspaces, + &data.recent_workspaces, + "recent_workspaces", + )?; + validate_workspace_reference_list( + &data.workspaces, + &data.recent_assistant_workspaces, + "recent_assistant_workspaces", + )?; + + for id in &data.recent_workspaces { + let workspace = &data.workspaces[id]; + if workspace.workspace_kind == WorkspaceKind::Assistant { + return Err(unsupported_workspace_persistence(format!( + "recent_workspaces contains assistant workspace '{id}'" + ))); + } + if workspace.root_path.starts_with(miniapps_root) { + return Err(unsupported_workspace_persistence(format!( + "recent_workspaces contains MiniApp-owned workspace '{id}'" + ))); + } + } + for id in &data.recent_assistant_workspaces { + if data.workspaces[id].workspace_kind != WorkspaceKind::Assistant { + return Err(unsupported_workspace_persistence(format!( + "recent_assistant_workspaces contains non-assistant workspace '{id}'" + ))); + } + } + + if let Some(current_id) = data.current_workspace_id.as_deref() { + if !data.workspaces.contains_key(current_id) { + return Err(unsupported_workspace_persistence(format!( + "current_workspace_id references unknown workspace id '{current_id}'" + ))); + } + if !data.opened_workspace_ids.iter().any(|id| id == current_id) { + return Err(unsupported_workspace_persistence(format!( + "current workspace '{current_id}' is not present in opened_workspace_ids" + ))); + } + } + + Ok(()) +} + +fn expected_persisted_local_workspace_id(root_path: &Path) -> Result { + if !root_path.is_absolute() { + return Err(format!( + "local workspace rootPath is not absolute: {}", + root_path.display() + )); + } + + let normalized_root = if root_path.exists() { + let (canonical_root, normalized_root) = canonicalize_local_workspace_root(root_path)?; + if canonical_root != root_path { + return Err(format!( + "local workspace rootPath is not canonical: {}", + root_path.display() + )); + } + normalized_root + } else { + root_path.to_string_lossy().replace('\\', "/") + }; + + Ok(local_workspace_stable_storage_id(&normalized_root)) +} + +fn validate_workspace_reference_list( + workspaces: &HashMap, + ids: &[String], + field: &str, +) -> OpenBitFunResult<()> { + let mut seen = HashSet::new(); + for id in ids { + if !seen.insert(id.as_str()) { + return Err(unsupported_workspace_persistence(format!( + "{field} contains duplicate workspace id '{id}'" + ))); + } + if !workspaces.contains_key(id) { + return Err(unsupported_workspace_persistence(format!( + "{field} references unknown workspace id '{id}'" + ))); + } + } + Ok(()) +} + +pub(crate) fn unsupported_workspace_persistence(detail: impl AsRef) -> OpenBitFunError { + OpenBitFunError::config(format!( + "Unsupported workspace persistence format: {}. The persisted file was left unchanged; explicit data migration is required", + detail.as_ref() + )) +} diff --git a/src/crates/assembly/core/src/service/workspace/service.rs b/src/crates/assembly/core/src/service/workspace/service.rs index ca78026db6..6620b1d9b8 100644 --- a/src/crates/assembly/core/src/service/workspace/service.rs +++ b/src/crates/assembly/core/src/service/workspace/service.rs @@ -7,6 +7,10 @@ use super::manager::{ WorkspaceManager, WorkspaceManagerConfig, WorkspaceManagerStatistics, WorkspaceOpenOptions, WorkspaceStatus, WorkspaceSummary, WorkspaceType, }; +use super::persistence::{ + unsupported_workspace_persistence, validate_workspace_persistence_data, + WorkspacePersistenceData, WORKSPACE_PERSISTENCE_FORMAT_VERSION, +}; use super::WorktreeTopologyFreshness; use crate::infrastructure::storage::{PersistenceService, StorageOptions}; use crate::infrastructure::{try_get_path_manager_arc, PathManager}; @@ -27,8 +31,7 @@ use log::{info, warn}; use openbitfun_core_types::product_identity::product_id; use openbitfun_services_core::workspace_identity::{ canonicalize_local_workspace_root, local_workspace_roots_equal, - local_workspace_stable_storage_id, normalize_remote_workspace_path, remote_workspace_stable_id, - LOCAL_WORKSPACE_SSH_HOST, + normalize_remote_workspace_path, remote_workspace_stable_id, }; use serde::{Deserialize, Serialize}; @@ -39,7 +42,6 @@ use tokio::fs; use tokio::sync::RwLock; const MAX_WORKSPACE_NAME_CHARS: usize = 80; -const WORKSPACE_PERSISTENCE_FORMAT_VERSION: u32 = 1; /// Workspace service. pub struct WorkspaceService { @@ -143,210 +145,6 @@ impl WorkspaceService { Ok(name.to_string()) } - fn unsupported_workspace_persistence(detail: impl AsRef) -> OpenBitFunError { - OpenBitFunError::config(format!( - "Unsupported workspace persistence format: {}. The persisted file was left unchanged; explicit data migration is required", - detail.as_ref() - )) - } - - fn expected_persisted_local_workspace_id(root_path: &Path) -> Result { - if !root_path.is_absolute() { - return Err(format!( - "local workspace rootPath is not absolute: {}", - root_path.display() - )); - } - - let normalized_root = if root_path.exists() { - let (canonical_root, normalized_root) = canonicalize_local_workspace_root(root_path)?; - if canonical_root != root_path { - return Err(format!( - "local workspace rootPath is not canonical: {}", - root_path.display() - )); - } - normalized_root - } else { - // A missing local root is still valid history. New OpenBitFun records persist the - // canonical absolute path, so its stored spelling remains the stable-id input even - // when the directory is temporarily unavailable. - root_path.to_string_lossy().replace('\\', "/") - }; - - Ok(local_workspace_stable_storage_id(&normalized_root)) - } - - fn validate_persisted_workspace_record( - &self, - storage_id: &str, - workspace: &WorkspaceInfo, - ) -> OpenBitFunResult<()> { - if storage_id != workspace.id { - return Err(Self::unsupported_workspace_persistence(format!( - "workspace map key '{storage_id}' does not match record id '{}'", - workspace.id - ))); - } - - let expected_id = match workspace.workspace_kind { - WorkspaceKind::Remote => { - let ssh_host = workspace - .metadata - .get("sshHost") - .and_then(|value| value.as_str()) - .map(str::trim) - .filter(|value| !value.is_empty()) - .ok_or_else(|| { - Self::unsupported_workspace_persistence(format!( - "remote workspace '{storage_id}' is missing sshHost" - )) - })?; - workspace.remote_ssh_connection_id().ok_or_else(|| { - Self::unsupported_workspace_persistence(format!( - "remote workspace '{storage_id}' is missing connectionId" - )) - })?; - - let stored_root = workspace.root_path.to_string_lossy().replace('\\', "/"); - let normalized_root = normalize_remote_workspace_path(&stored_root); - if !normalized_root.starts_with('/') { - return Err(Self::unsupported_workspace_persistence(format!( - "remote workspace '{storage_id}' does not use an absolute POSIX root" - ))); - } - if stored_root != normalized_root { - return Err(Self::unsupported_workspace_persistence(format!( - "remote workspace '{storage_id}' rootPath is not normalized" - ))); - } - remote_workspace_stable_id(ssh_host, &normalized_root) - } - WorkspaceKind::Normal | WorkspaceKind::Assistant => { - let ssh_host = workspace - .metadata - .get("sshHost") - .and_then(|value| value.as_str()) - .map(str::trim); - if ssh_host != Some(LOCAL_WORKSPACE_SSH_HOST) { - return Err(Self::unsupported_workspace_persistence(format!( - "local workspace '{storage_id}' does not declare sshHost=localhost" - ))); - } - Self::expected_persisted_local_workspace_id(&workspace.root_path).map_err( - |error| { - Self::unsupported_workspace_persistence(format!( - "local workspace '{storage_id}' is not canonical: {error}" - )) - }, - )? - } - }; - - if storage_id != expected_id { - return Err(Self::unsupported_workspace_persistence(format!( - "workspace id '{storage_id}' is not canonical; expected '{expected_id}'" - ))); - } - - Ok(()) - } - - fn validate_workspace_reference_list( - workspaces: &std::collections::HashMap, - ids: &[String], - field: &str, - ) -> OpenBitFunResult<()> { - let mut seen = HashSet::new(); - for id in ids { - if !seen.insert(id.as_str()) { - return Err(Self::unsupported_workspace_persistence(format!( - "{field} contains duplicate workspace id '{id}'" - ))); - } - if !workspaces.contains_key(id) { - return Err(Self::unsupported_workspace_persistence(format!( - "{field} references unknown workspace id '{id}'" - ))); - } - } - Ok(()) - } - - fn validate_workspace_persistence_data( - &self, - data: &WorkspacePersistenceData, - ) -> OpenBitFunResult<()> { - if data.format_version != WORKSPACE_PERSISTENCE_FORMAT_VERSION { - return Err(Self::unsupported_workspace_persistence(format!( - "format_version {} is not supported; expected {}", - data.format_version, WORKSPACE_PERSISTENCE_FORMAT_VERSION - ))); - } - if data.product_id != product_id() { - return Err(Self::unsupported_workspace_persistence(format!( - "product_id '{}' does not match '{}'", - data.product_id, - product_id() - ))); - } - - for (storage_id, workspace) in &data.workspaces { - self.validate_persisted_workspace_record(storage_id, workspace)?; - } - Self::validate_workspace_reference_list( - &data.workspaces, - &data.opened_workspace_ids, - "opened_workspace_ids", - )?; - Self::validate_workspace_reference_list( - &data.workspaces, - &data.recent_workspaces, - "recent_workspaces", - )?; - Self::validate_workspace_reference_list( - &data.workspaces, - &data.recent_assistant_workspaces, - "recent_assistant_workspaces", - )?; - - for id in &data.recent_workspaces { - let workspace = &data.workspaces[id]; - if workspace.workspace_kind == WorkspaceKind::Assistant { - return Err(Self::unsupported_workspace_persistence(format!( - "recent_workspaces contains assistant workspace '{id}'" - ))); - } - if self.is_miniapp_owned_path(&workspace.root_path) { - return Err(Self::unsupported_workspace_persistence(format!( - "recent_workspaces contains MiniApp-owned workspace '{id}'" - ))); - } - } - for id in &data.recent_assistant_workspaces { - if data.workspaces[id].workspace_kind != WorkspaceKind::Assistant { - return Err(Self::unsupported_workspace_persistence(format!( - "recent_assistant_workspaces contains non-assistant workspace '{id}'" - ))); - } - } - - if let Some(current_id) = data.current_workspace_id.as_deref() { - if !data.workspaces.contains_key(current_id) { - return Err(Self::unsupported_workspace_persistence(format!( - "current_workspace_id references unknown workspace id '{current_id}'" - ))); - } - if !data.opened_workspace_ids.iter().any(|id| id == current_id) { - return Err(Self::unsupported_workspace_persistence(format!( - "current workspace '{current_id}' is not present in opened_workspace_ids" - ))); - } - } - - Ok(()) - } - fn collect_startup_restored_workspaces(manager: &WorkspaceManager) -> Vec { let mut targets = Vec::new(); let mut seen_workspace_ids = HashSet::new(); @@ -1972,7 +1770,7 @@ impl WorkspaceService { })?; if let Some(data) = workspace_data { - self.validate_workspace_persistence_data(&data)?; + validate_workspace_persistence_data(&data, &self.path_manager.miniapps_dir())?; let mut manager = self.manager.write().await; *manager.get_workspaces_mut() = data.workspaces; @@ -1983,7 +1781,7 @@ impl WorkspaceService { if let Some(current_id) = data.current_workspace_id { if let Err(e) = manager.set_current_workspace(current_id) { - return Err(Self::unsupported_workspace_persistence(format!( + return Err(unsupported_workspace_persistence(format!( "current workspace could not be restored: {e}" ))); } @@ -2383,26 +2181,6 @@ pub struct WorkspaceQuickSummary { pub workspace_types: std::collections::HashMap, } -/// Workspace persistence data. -#[derive(Debug, Serialize, Deserialize)] -struct WorkspacePersistenceData { - #[serde(default)] - pub format_version: u32, - #[serde(default)] - pub product_id: String, - pub workspaces: std::collections::HashMap, - #[serde(default)] - pub opened_workspace_ids: Vec, - pub current_workspace_id: Option, - #[serde(default)] - pub recent_workspaces: Vec, - #[serde(default)] - pub recent_assistant_workspaces: Vec, - #[serde(default)] - pub primary_assistant_key: Option, - pub saved_at: chrono::DateTime, -} - // ── Global workspace service singleton ────────────────────────────── static GLOBAL_WORKSPACE_SERVICE: std::sync::OnceLock> = diff --git a/src/crates/assembly/core/src/service/workspace/types.rs b/src/crates/assembly/core/src/service/workspace/types.rs new file mode 100644 index 0000000000..07739a356a --- /dev/null +++ b/src/crates/assembly/core/src/service/workspace/types.rs @@ -0,0 +1,175 @@ +//! Persisted Workspace record types shared by the live service and offline import. + +#![cfg_attr( + all(feature = "legacy-migration", not(feature = "workspace-runtime")), + allow(unreachable_pub) +)] + +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::path::PathBuf; + +pub(crate) use openbitfun_runtime_ports::RelatedPath; + +/// Workspace type. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] +pub enum WorkspaceType { + RustProject, + NodeProject, + PythonProject, + JavaProject, + CppProject, + WebProject, + MobileProject, + Other, +} + +/// Workspace status. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum WorkspaceStatus { + Active, + Inactive, + Loading, + Error, + Archived, +} + +/// Workspace lifecycle kind. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, Hash)] +#[serde(rename_all = "lowercase")] +pub enum WorkspaceKind { + #[default] + Normal, + Assistant, + Remote, +} + +/// Stable identity of the assistant workspace that owns the primary role. +/// +/// Local workspace ids are derived from canonical storage paths, so the primary +/// selection is persisted using the assistant identity instead of that path-derived id. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum PrimaryAssistantKey { + BuiltIn, + Named { assistant_id: String }, +} + +#[cfg(feature = "workspace-runtime")] +impl PrimaryAssistantKey { + pub fn from_workspace(workspace: &WorkspaceInfo) -> Option { + if workspace.workspace_kind != WorkspaceKind::Assistant { + return None; + } + Some(match workspace.assistant_id.as_deref() { + Some(assistant_id) if !assistant_id.trim().is_empty() => Self::Named { + assistant_id: assistant_id.trim().to_string(), + }, + _ => Self::BuiltIn, + }) + } + + pub fn matches(&self, workspace: &WorkspaceInfo) -> bool { + if workspace.workspace_kind != WorkspaceKind::Assistant { + return false; + } + match (self, workspace.assistant_id.as_deref()) { + (Self::BuiltIn, None) => true, + (Self::Named { assistant_id }, Some(candidate)) => assistant_id == candidate, + _ => false, + } + } +} + +/// Parsed agent identity fields from `IDENTITY.md` frontmatter. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct WorkspaceIdentity { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub creature: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub vibe: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub avatar: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub emoji: Option, +} + +/// Git worktree metadata attached to a workspace. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct WorkspaceWorktreeInfo { + pub path: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub branch: Option, + pub main_repo_path: String, + pub is_main: bool, +} + +/// Workspace metadata. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WorkspaceInfo { + pub id: String, + pub name: String, + #[serde(rename = "rootPath")] + pub root_path: PathBuf, + #[serde(rename = "workspaceType")] + pub workspace_type: WorkspaceType, + #[serde(rename = "workspaceKind", default)] + pub workspace_kind: WorkspaceKind, + #[serde( + rename = "assistantId", + default, + skip_serializing_if = "Option::is_none" + )] + pub assistant_id: Option, + pub status: WorkspaceStatus, + pub languages: Vec, + #[serde(rename = "openedAt")] + pub opened_at: chrono::DateTime, + #[serde(rename = "lastAccessed")] + pub last_accessed: chrono::DateTime, + pub description: Option, + pub tags: Vec, + pub statistics: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub identity: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub worktree: Option, + #[serde(rename = "relatedPaths", default)] + pub related_paths: Vec, + pub metadata: HashMap, +} + +impl WorkspaceInfo { + /// SSH connection id persisted in [`WorkspaceInfo::metadata`] for remote workspaces. + pub fn remote_ssh_connection_id(&self) -> Option<&str> { + self.metadata + .get("connectionId") + .and_then(|value| value.as_str()) + .filter(|value| !value.is_empty()) + } +} + +/// Workspace statistics. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WorkspaceStatistics { + pub total_files: usize, + pub total_directories: usize, + pub total_size_bytes: u64, + pub file_extensions: HashMap, + pub last_modified: Option>, + pub git_info: Option, +} + +/// Git information. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GitInfo { + pub is_git_repo: bool, + pub current_branch: Option, + pub remote_url: Option, + pub has_uncommitted_changes: bool, + pub total_commits: Option, +} diff --git a/src/crates/assembly/product-capabilities/src/lib.rs b/src/crates/assembly/product-capabilities/src/lib.rs index 3e8c8397ae..93179474cd 100644 --- a/src/crates/assembly/product-capabilities/src/lib.rs +++ b/src/crates/assembly/product-capabilities/src/lib.rs @@ -164,6 +164,7 @@ impl ProductCapabilityPack { pub enum DeliveryProfile { ProductFull, Desktop, + DataMigrator, Cli, Server, Remote, @@ -178,6 +179,7 @@ impl DeliveryProfile { match self { Self::ProductFull => "product-full", Self::Desktop => "desktop", + Self::DataMigrator => "data-migrator", Self::Cli => "cli", Self::Server => "server", Self::Remote => "remote", @@ -192,6 +194,7 @@ impl DeliveryProfile { &[ Self::ProductFull, Self::Desktop, + Self::DataMigrator, Self::Cli, Self::Server, Self::Remote, @@ -252,6 +255,10 @@ const PRODUCT_DELIVERY_PROFILE_ENTRIES: &[ProductDeliveryProfileEntry] = &[ DeliveryProfile::Desktop, ProductCoreDependencyMode::ProductFullCompatibility, ), + ProductDeliveryProfileEntry::new( + DeliveryProfile::DataMigrator, + ProductCoreDependencyMode::ExplicitCoreCapabilityClosure, + ), ProductDeliveryProfileEntry::new( DeliveryProfile::Cli, ProductCoreDependencyMode::ExplicitCoreCapabilityClosure, @@ -943,6 +950,7 @@ pub fn product_extension_capabilities_for_profile( DeliveryProfile::Server | DeliveryProfile::Remote | DeliveryProfile::Acp + | DeliveryProfile::DataMigrator | DeliveryProfile::Web | DeliveryProfile::MobileWeb | DeliveryProfile::Sdk => PluginRuntimeUnavailableReason::UnsupportedProfile, @@ -1129,6 +1137,7 @@ fn product_capability_registry_for_profile(profile: DeliveryProfile) -> ProductC } DeliveryProfile::Server | DeliveryProfile::Remote + | DeliveryProfile::DataMigrator | DeliveryProfile::Web | DeliveryProfile::MobileWeb => { ProductCapabilityRegistry::new(EMPTY_PRODUCT_CAPABILITY_PACKS) diff --git a/src/crates/assembly/product-capabilities/tests/product_capability_contracts/product_capabilities.rs b/src/crates/assembly/product-capabilities/tests/product_capability_contracts/product_capabilities.rs index d3235e152b..e7efe94f4f 100644 --- a/src/crates/assembly/product-capabilities/tests/product_capability_contracts/product_capabilities.rs +++ b/src/crates/assembly/product-capabilities/tests/product_capability_contracts/product_capabilities.rs @@ -321,6 +321,10 @@ fn product_delivery_profile_matrix_documents_current_core_dependency_shape() { DeliveryProfile::Desktop, ProductCoreDependencyMode::ProductFullCompatibility, ), + ( + DeliveryProfile::DataMigrator, + ProductCoreDependencyMode::ExplicitCoreCapabilityClosure, + ), ( DeliveryProfile::Cli, ProductCoreDependencyMode::ExplicitCoreCapabilityClosure, @@ -366,6 +370,23 @@ fn product_assembly_plan_follows_core_dependency_matrix() { match entry.core_dependency_mode() { ProductCoreDependencyMode::ProductFullCompatibility | ProductCoreDependencyMode::ExplicitCoreCapabilityClosure => { + if entry.profile() == DeliveryProfile::DataMigrator { + assert!( + plan.capability_set().ids().is_empty(), + "data-migrator must not assemble Agent Runtime capabilities" + ); + assert!( + plan.capability_assembly() + .tool_provider_group_plan() + .is_empty(), + "data-migrator must not assemble runtime tool groups" + ); + assert!( + plan.feature_groups().is_empty(), + "data-migrator must not expose runtime feature groups" + ); + continue; + } assert!( !plan.capability_set().ids().is_empty(), "{} must retain runtime capabilities", @@ -412,6 +433,25 @@ fn product_assembly_plan_follows_core_dependency_matrix() { } } +#[test] +fn data_migrator_profile_is_a_minimal_non_agent_product_plan() { + let plan = product_assembly_plan_for_profile(DeliveryProfile::DataMigrator); + + assert_eq!(plan.profile().id(), "data-migrator"); + assert!(plan.capability_set().ids().is_empty()); + assert!(plan.capability_assembly().agent_ids().is_empty()); + assert!(plan.capability_assembly().service_requirements().is_empty()); + assert!(plan.feature_groups().is_empty()); + assert!(plan + .capability_assembly() + .tool_provider_group_plan() + .is_empty()); + assert_eq!( + plan.extension_capabilities().plugin_runtime(), + PluginRuntimeAvailability::disabled(PluginRuntimeUnavailableReason::UnsupportedProfile) + ); +} + #[test] fn product_assembly_plan_keeps_plugin_runtime_disabled_until_explicit_client_binding() { for profile in DeliveryProfile::all_current_product_profiles() { diff --git a/src/crates/contracts/product-domains/Cargo.toml b/src/crates/contracts/product-domains/Cargo.toml index 2629d8b354..c462256e93 100644 --- a/src/crates/contracts/product-domains/Cargo.toml +++ b/src/crates/contracts/product-domains/Cargo.toml @@ -34,6 +34,11 @@ name = "miniapp_contracts" path = "tests/miniapp_contracts.rs" required-features = ["miniapp"] +[[test]] +name = "legacy_migration_contracts" +path = "tests/legacy_migration_contracts.rs" +required-features = ["legacy-migration"] + [dependencies] serde = { workspace = true } serde_json = { workspace = true } @@ -54,7 +59,8 @@ plugin-source = ["hex", "sha2"] miniapp = ["dirs", "hex", "sha2", "which"] function-agents = ["log"] external-sources = ["hex", "hmac", "sha2", "url"] -product-full = ["appearance-market", "plugin-source", "miniapp", "function-agents", "external-sources"] +legacy-migration = [] +product-full = ["appearance-market", "plugin-source", "miniapp", "function-agents", "external-sources", "legacy-migration"] # Derive `ts_rs::TS` on the wire types (permissions, etc.) for downstream # TypeScript binding export. Orthogonal to the product feature groups; does not # pull any optional integration dependency. diff --git a/src/crates/contracts/product-domains/src/generated/product-control-catalog.json b/src/crates/contracts/product-domains/src/generated/product-control-catalog.json index 1a16b0b74a..dd663557c6 100644 --- a/src/crates/contracts/product-domains/src/generated/product-control-catalog.json +++ b/src/crates/contracts/product-domains/src/generated/product-control-catalog.json @@ -4,7 +4,7 @@ "title": "OpenBitFun Playbook", "origin": "https://playbook.openbitfun.com", "source": "src/shared/interactive-capabilities/catalog.json", - "digest": "d7a7419ddd673eb733ae8bd33dbb3dcd3b4d1ce0acfc953067a3403fe26d6699", + "digest": "c77c16c414ce9fe50929fe249d900489fdceedb8e646aaaca03893aa33d4fbc8", "ownerDigest": "c0e5c187cf62bc6ed06196ce8520b3eb427bf268cf24659b72d2552fb1d99c54", "searchAcceptance": [ { @@ -136,13 +136,13 @@ ], "counts": { "features": 22, - "settings": 21, - "userFacing": 43, - "documentedItems": 321, + "settings": 22, + "userFacing": 44, + "documentedItems": 326, "controlCoverage": { "direct": 48, "delegated": 61, - "interactive": 212, + "interactive": 217, "unsupported": 0 } }, @@ -18765,6 +18765,285 @@ "pageId": "data.archived" } }, + { + "id": "setting.data.migration:query", + "capabilityId": "setting.data.migration", + "itemIds": [ + "scan", + "scope", + "launch", + "report", + "reminder" + ], + "kind": "query", + "risk": "read", + "executionHost": "productHost", + "availability": { + "desktop": { + "available": true + }, + "cli": { + "available": true + }, + "peer": { + "available": true, + "requiredCapabilities": [ + "product_control_v1" + ] + }, + "remoteControl": { + "available": true + }, + "detachedDispatch": { + "available": true + } + }, + "inputSchema": { + "type": "object", + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "additionalProperties": true + }, + "valueSource": { + "kind": "static" + }, + "presentationTarget": { + "kind": "settings", + "pageId": "data.migration" + } + }, + { + "id": "setting.data.migration:open:scan", + "capabilityId": "setting.data.migration", + "itemIds": [ + "scan" + ], + "kind": "open", + "risk": "ui", + "executionHost": "presentationSurface", + "availability": { + "desktop": { + "available": true + }, + "cli": { + "available": false, + "reason": "This delivery profile has no live presentation surface" + }, + "peer": { + "available": true, + "requiredCapabilities": [ + "product_control_v1", + "product_control_presentation_v1" + ] + }, + "remoteControl": { + "available": true + }, + "detachedDispatch": { + "available": false, + "reason": "This delivery profile has no live presentation surface" + } + }, + "inputSchema": { + "type": "object", + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "additionalProperties": true + }, + "openReason": "unstructuredInteraction", + "presentationTarget": { + "kind": "settings", + "pageId": "data.migration" + } + }, + { + "id": "setting.data.migration:open:scope", + "capabilityId": "setting.data.migration", + "itemIds": [ + "scope" + ], + "kind": "open", + "risk": "ui", + "executionHost": "presentationSurface", + "availability": { + "desktop": { + "available": true + }, + "cli": { + "available": false, + "reason": "This delivery profile has no live presentation surface" + }, + "peer": { + "available": true, + "requiredCapabilities": [ + "product_control_v1", + "product_control_presentation_v1" + ] + }, + "remoteControl": { + "available": true + }, + "detachedDispatch": { + "available": false, + "reason": "This delivery profile has no live presentation surface" + } + }, + "inputSchema": { + "type": "object", + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "additionalProperties": true + }, + "openReason": "visualSelection", + "presentationTarget": { + "kind": "settings", + "pageId": "data.migration" + } + }, + { + "id": "setting.data.migration:open:launch", + "capabilityId": "setting.data.migration", + "itemIds": [ + "launch" + ], + "kind": "open", + "risk": "ui", + "executionHost": "presentationSurface", + "availability": { + "desktop": { + "available": true + }, + "cli": { + "available": false, + "reason": "This delivery profile has no live presentation surface" + }, + "peer": { + "available": true, + "requiredCapabilities": [ + "product_control_v1", + "product_control_presentation_v1" + ] + }, + "remoteControl": { + "available": true + }, + "detachedDispatch": { + "available": false, + "reason": "This delivery profile has no live presentation surface" + } + }, + "inputSchema": { + "type": "object", + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "additionalProperties": true + }, + "openReason": "unstructuredInteraction", + "presentationTarget": { + "kind": "settings", + "pageId": "data.migration" + } + }, + { + "id": "setting.data.migration:open:report", + "capabilityId": "setting.data.migration", + "itemIds": [ + "report" + ], + "kind": "open", + "risk": "ui", + "executionHost": "presentationSurface", + "availability": { + "desktop": { + "available": true + }, + "cli": { + "available": false, + "reason": "This delivery profile has no live presentation surface" + }, + "peer": { + "available": true, + "requiredCapabilities": [ + "product_control_v1", + "product_control_presentation_v1" + ] + }, + "remoteControl": { + "available": true + }, + "detachedDispatch": { + "available": false, + "reason": "This delivery profile has no live presentation surface" + } + }, + "inputSchema": { + "type": "object", + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "additionalProperties": true + }, + "openReason": "unstructuredInteraction", + "presentationTarget": { + "kind": "settings", + "pageId": "data.migration" + } + }, + { + "id": "setting.data.migration:open:reminder", + "capabilityId": "setting.data.migration", + "itemIds": [ + "reminder" + ], + "kind": "open", + "risk": "ui", + "executionHost": "presentationSurface", + "availability": { + "desktop": { + "available": true + }, + "cli": { + "available": false, + "reason": "This delivery profile has no live presentation surface" + }, + "peer": { + "available": true, + "requiredCapabilities": [ + "product_control_v1", + "product_control_presentation_v1" + ] + }, + "remoteControl": { + "available": true + }, + "detachedDispatch": { + "available": false, + "reason": "This delivery profile has no live presentation surface" + } + }, + "inputSchema": { + "type": "object", + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "additionalProperties": true + }, + "openReason": "unstructuredInteraction", + "presentationTarget": { + "kind": "settings", + "pageId": "data.migration" + } + }, { "id": "setting.data.diagnostics:query", "capabilityId": "setting.data.diagnostics", @@ -29955,6 +30234,156 @@ ], "docsUrl": "https://playbook.openbitfun.com/capabilities/setting.data.archived/" }, + { + "id": "setting.data.migration", + "kind": "setting", + "categoryId": "data", + "titleZh": "旧版数据迁移", + "titleEn": "Legacy data migration", + "summaryZh": "从本机旧版 安装扫描并导入受支持的数据,查看去敏报告,同时保持旧来源不变。", + "summaryEn": "Scan and import supported data from a local legacy installation, inspect redacted reports, and leave the legacy source unchanged.", + "keywordsZh": [ + "旧版数据迁移", + "旧版数据", + "迁移报告", + "导入旧数据", + "Data Migrator" + ], + "keywordsEn": [ + "legacy data migration", + "legacy data", + "migration report", + "import old data", + "Data Migrator" + ], + "highlightsZh": [ + "只读扫描本机旧版数据", + "按五个高层数据组选择迁移范围", + "通过独立 Data Migrator 导入并查看去敏报告" + ], + "highlightsEn": [ + "Read-only scan of local legacy data", + "Choose migration scope across five high-level data groups", + "Import through the standalone Data Migrator and inspect redacted reports" + ], + "items": [ + { + "id": "scan", + "titleZh": "只读扫描本机旧版数据来源及所选数据组", + "titleEn": "Read-only scan the local legacy source and selected data groups", + "control": { + "kind": "open", + "reasonCode": "unstructuredInteraction", + "reasonZh": "“只读扫描本机旧版数据来源及所选数据组”:扫描依赖当前设备上的旧版数据、实时范围选择和结果状态;Agent 会打开精确入口,并把选择与扫描保留在用户可见界面。", + "reasonEn": "Read-only scan the local legacy source and selected data groups: Scanning depends on legacy data on the current device, live scope selection, and result state; the Agent opens the exact entry and keeps selection and scanning visible to the user." + } + }, + { + "id": "scope", + "titleZh": "选择设置、扩展、会话、记忆和远程连接迁移范围", + "titleEn": "Choose settings, extensions, sessions, memory, and remote-connection migration scope", + "control": { + "kind": "open", + "reasonCode": "visualSelection", + "reasonZh": "迁移范围是影响本机持久数据的五组可见选择;Agent 会打开精确入口,由用户确认所需范围。", + "reasonEn": "Migration scope is a visible five-group selection affecting local persisted data; the Agent opens the exact entry so the user can confirm the intended scope." + } + }, + { + "id": "launch", + "titleZh": "确认关闭影响后启动独立 Data Migrator", + "titleEn": "Launch the standalone Data Migrator after confirming shutdown impact", + "control": { + "kind": "open", + "reasonCode": "unstructuredInteraction", + "reasonZh": "“确认关闭影响后启动独立 Data Migrator”:启动迁移器会停止正在运行的 Agent 和终端任务、关闭 Desktop,并交接到独立本机进程;Agent 只打开入口,确认和启动保留在用户可见界面。", + "reasonEn": "Launch the standalone Data Migrator after confirming shutdown impact: Launching the migrator can stop running agents and terminal tasks, close Desktop, and hand off to a separate local process; the Agent only opens the entry while confirmation and launch remain visible to the user." + } + }, + { + "id": "report", + "titleZh": "查看最近运行结果和各领域去敏状态", + "titleEn": "Inspect the latest run result and redacted per-domain status", + "control": { + "kind": "open", + "reasonCode": "unstructuredInteraction", + "reasonZh": "“查看最近运行结果和各领域去敏状态”:报告取决于本机最近一次迁移运行和各领域实时状态;Agent 会打开精确入口,并把报告查看与失败组重试保留在用户可见界面。", + "reasonEn": "Inspect the latest run result and redacted per-domain status: Reports depend on the most recent local migration run and live per-domain state; the Agent opens the exact entry and keeps report review and failed-group retry visible to the user." + } + }, + { + "id": "reminder", + "titleZh": "恢复已关闭的首次启动迁移提醒", + "titleEn": "Restore the first-start migration reminder after it was disabled", + "control": { + "kind": "open", + "reasonCode": "unstructuredInteraction", + "reasonZh": "“恢复已关闭的首次启动迁移提醒”:提醒偏好与当前本机旧数据来源绑定;Agent 会打开精确入口,由用户在可见界面决定是否恢复提醒。", + "reasonEn": "Restore the first-start migration reminder after it was disabled: The reminder preference is bound to the current local legacy source; the Agent opens the exact entry so the user can decide visibly whether to restore it." + } + } + ], + "stepsZh": [ + "打开设置", + "进入“数据 > 旧版数据迁移”", + "扫描来源、选择范围并在确认关闭影响后启动迁移器" + ], + "stepsEn": [ + "Open Settings", + "Go to Data > Legacy data migration", + "Scan the source, choose scope, and launch the migrator after confirming shutdown impact" + ], + "agentExamplesZh": [ + "打开旧版数据迁移", + "带我查看数据迁移报告" + ], + "agentExamplesEn": [ + "Open legacy data migration", + "Show me the data migration report" + ], + "destination": { + "kind": "settings", + "pageId": "data.migration" + }, + "operations": [], + "options": [], + "searchTerms": [ + "setting.data.migration", + "旧版数据迁移", + "Legacy data migration", + "数据与诊断", + "Data & diagnostics", + "旧版数据", + "迁移报告", + "导入旧数据", + "Data Migrator", + "legacy data migration", + "legacy data", + "migration report", + "import old data", + "只读扫描本机旧版数据", + "按五个高层数据组选择迁移范围", + "通过独立 Data Migrator 导入并查看去敏报告", + "Read-only scan of local legacy data", + "Choose migration scope across five high-level data groups", + "Import through the standalone Data Migrator and inspect redacted reports", + "只读扫描本机旧版数据来源及所选数据组", + "Read-only scan the local legacy source and selected data groups", + "选择设置、扩展、会话、记忆和远程连接迁移范围", + "Choose settings, extensions, sessions, memory, and remote-connection migration scope", + "确认关闭影响后启动独立 Data Migrator", + "Launch the standalone Data Migrator after confirming shutdown impact", + "查看最近运行结果和各领域去敏状态", + "Inspect the latest run result and redacted per-domain status", + "恢复已关闭的首次启动迁移提醒", + "Restore the first-start migration reminder after it was disabled", + "打开旧版数据迁移", + "带我查看数据迁移报告", + "Open legacy data migration", + "Show me the data migration report" + ], + "docsUrl": "https://playbook.openbitfun.com/capabilities/setting.data.migration/" + }, { "id": "setting.data.diagnostics", "kind": "setting", diff --git a/src/crates/contracts/product-domains/src/generated/remote-surface-registry.json b/src/crates/contracts/product-domains/src/generated/remote-surface-registry.json index fe0d6941e8..535a2bd35b 100644 --- a/src/crates/contracts/product-domains/src/generated/remote-surface-registry.json +++ b/src/crates/contracts/product-domains/src/generated/remote-surface-registry.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "digest": "fnv1a64:4cdcd7ff4dcd85a9", + "digest": "fnv1a64:f5390eecb4710d17", "retiredCommandPrefixes": [ { "prefix": "lsp_", @@ -2585,6 +2585,30 @@ "reason": "the controller keeps this command; peer hosts refuse it before dispatch" } }, + { + "id": "get_legacy_migration_report", + "surface": "tauri_command", + "remoteWorkspace": "LocalOnly", + "peer": { + "kind": "controller_local" + }, + "cliPeer": { + "kind": "unsupported", + "reason": "the controller keeps this command; peer hosts refuse it before dispatch" + } + }, + { + "id": "get_legacy_migration_status", + "surface": "tauri_command", + "remoteWorkspace": "LocalOnly", + "peer": { + "kind": "controller_local" + }, + "cliPeer": { + "kind": "unsupported", + "reason": "the controller keeps this command; peer hosts refuse it before dispatch" + } + }, { "id": "get_mcp_prompt", "surface": "tauri_command", @@ -5139,6 +5163,18 @@ "reason": "the CLI peer host has no handler for this command" } }, + { + "id": "prepare_legacy_migration", + "surface": "tauri_command", + "remoteWorkspace": "LocalOnly", + "peer": { + "kind": "controller_local" + }, + "cliPeer": { + "kind": "unsupported", + "reason": "the controller keeps this command; peer hosts refuse it before dispatch" + } + }, { "id": "preview_commit_message", "surface": "tauri_command", @@ -6457,6 +6493,18 @@ "kind": "handled" } }, + { + "id": "scan_legacy_migration", + "surface": "tauri_command", + "remoteWorkspace": "LocalOnly", + "peer": { + "kind": "controller_local" + }, + "cliPeer": { + "kind": "unsupported", + "reason": "the controller keeps this command; peer hosts refuse it before dispatch" + } + }, { "id": "scan_workspace_info", "surface": "tauri_command", @@ -6793,6 +6841,18 @@ "reason": "the CLI peer host has no handler for this command" } }, + { + "id": "set_legacy_migration_prompt_preference", + "surface": "tauri_command", + "remoteWorkspace": "LocalOnly", + "peer": { + "kind": "controller_local" + }, + "cliPeer": { + "kind": "unsupported", + "reason": "the controller keeps this command; peer hosts refuse it before dispatch" + } + }, { "id": "set_macos_edit_menu_mode", "surface": "tauri_command", diff --git a/src/crates/contracts/product-domains/src/legacy_migration/mod.rs b/src/crates/contracts/product-domains/src/legacy_migration/mod.rs new file mode 100644 index 0000000000..635b255534 --- /dev/null +++ b/src/crates/contracts/product-domains/src/legacy_migration/mod.rs @@ -0,0 +1,533 @@ +//! Stable, platform-agnostic contracts for importing retired product data. +//! +//! This module describes persisted state and the handoff protocol. Filesystem, +//! process, SQLite, credential-vault, and UI behavior belong to service and app +//! owners. + +use serde::{Deserialize, Serialize}; +use std::collections::BTreeSet; + +pub const CURRENT_MIGRATION_FORMAT_VERSION: u32 = 1; +pub const CURRENT_MIGRATOR_PROTOCOL_VERSION: u32 = 1; +pub const MIN_MIGRATOR_PROTOCOL_VERSION: u32 = 1; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum MigrationGroupId { + SettingsAndCredentials, + AgentsSkillsAndMiniapps, + WorkspacesSessionsAndTasks, + Memory, + RemoteConnectionsAndDevices, +} + +#[derive( + Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, +)] +#[serde(rename_all = "snake_case")] +pub enum MigrationDomainId { + #[default] + Settings, + Credentials, + Skills, + Miniapps, + Agents, + WorkspaceSessions, + AgentCoordination, + StructuredMemory, + FileMemory, + RemoteConnectDevices, + RemoteSsh, + CrossReferenceRepair, +} + +impl MigrationDomainId { + pub const fn group(self) -> Option { + match self { + Self::Settings | Self::Credentials => Some(MigrationGroupId::SettingsAndCredentials), + Self::Skills | Self::Miniapps | Self::Agents => { + Some(MigrationGroupId::AgentsSkillsAndMiniapps) + } + Self::WorkspaceSessions | Self::AgentCoordination => { + Some(MigrationGroupId::WorkspacesSessionsAndTasks) + } + Self::StructuredMemory | Self::FileMemory => Some(MigrationGroupId::Memory), + Self::RemoteConnectDevices | Self::RemoteSsh => { + Some(MigrationGroupId::RemoteConnectionsAndDevices) + } + Self::CrossReferenceRepair => None, + } + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct MigrationSelection { + pub groups: BTreeSet, +} + +impl MigrationSelection { + pub fn all() -> Self { + Self { + groups: BTreeSet::from([ + MigrationGroupId::SettingsAndCredentials, + MigrationGroupId::AgentsSkillsAndMiniapps, + MigrationGroupId::WorkspacesSessionsAndTasks, + MigrationGroupId::Memory, + MigrationGroupId::RemoteConnectionsAndDevices, + ]), + } + } + + /// Expand the five user-visible groups into their fixed dependency order. + pub fn expanded_domains(&self) -> Vec { + let mut domains = Vec::new(); + if self + .groups + .contains(&MigrationGroupId::SettingsAndCredentials) + { + domains.extend([MigrationDomainId::Settings, MigrationDomainId::Credentials]); + } + if self + .groups + .contains(&MigrationGroupId::AgentsSkillsAndMiniapps) + { + domains.extend([ + MigrationDomainId::Skills, + MigrationDomainId::Miniapps, + MigrationDomainId::Agents, + ]); + } + if self + .groups + .contains(&MigrationGroupId::WorkspacesSessionsAndTasks) + { + domains.extend([ + MigrationDomainId::WorkspaceSessions, + MigrationDomainId::AgentCoordination, + ]); + } + if self.groups.contains(&MigrationGroupId::Memory) { + domains.extend([ + MigrationDomainId::StructuredMemory, + MigrationDomainId::FileMemory, + ]); + } + if self + .groups + .contains(&MigrationGroupId::RemoteConnectionsAndDevices) + { + domains.extend([ + MigrationDomainId::RemoteConnectDevices, + MigrationDomainId::RemoteSsh, + ]); + } + if !domains.is_empty() { + domains.push(MigrationDomainId::CrossReferenceRepair); + } + domains + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LegacyRootDescriptor { + pub kind: LegacyRootKind, + pub display_path: String, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum LegacyRootKind { + #[default] + ProductData, + ProductHome, + RemoteSsh, + ManagedWebview, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LegacySourceDescriptor { + pub source_id: String, + pub source_fingerprint: String, + pub product_id: String, + pub product_version: String, + pub platform: String, + pub roots: Vec, + pub readable: bool, + pub supported: bool, + pub approximate_bytes: u64, + pub already_migrated: bool, + pub diagnostics: Vec, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum FindingSeverity { + #[default] + Info, + Warning, + Blocking, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct ScanFinding { + pub domain: MigrationDomainId, + pub code: String, + pub severity: FindingSeverity, + pub entity_count: u64, + pub logical_bytes: u64, + pub source_schema: Option, + pub migratable: bool, + pub detail: String, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ConflictResolution { + #[default] + TargetWins, + SourceImported, + SourceRemapped, + DuplicateSkipped, + ItemSkipped, + RequiresUserAction, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct MigrationConflict { + pub domain: MigrationDomainId, + pub code: String, + pub source_summary: String, + pub target_summary: String, + pub resolution: ConflictResolution, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct MigrationPlanStep { + pub sequence: u32, + pub domain: MigrationDomainId, + pub estimated_write_bytes: u64, + pub source_schema: Option, + pub target_schema: Option, + pub dependencies: Vec, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct MigrationPlan { + pub format_version: u32, + pub run_id: String, + pub source_fingerprint: String, + pub selection: MigrationSelection, + pub steps: Vec, + pub findings: Vec, + pub conflicts: Vec, + pub estimated_write_bytes: u64, + pub plan_hash: String, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum MigrationPhase { + #[default] + Discover, + Scan, + Plan, + Acquire, + Stage, + ValidateStage, + Commit, + ValidateCommit, + Finalize, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct MigrationProgressEvent { + pub run_id: String, + pub domain: Option, + pub phase: MigrationPhase, + pub processed: u64, + pub total: u64, + pub safe_to_cancel: bool, + pub code: String, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum MigrationRunStatus { + #[default] + Discovered, + Scanned, + Planned, + WaitingForProcesses, + Staging, + ValidatingStage, + Committing, + ValidatingCommit, + Completed, + CompletedWithWarnings, + Cancelled, + FailedRecoverable, + FailedManualActionRequired, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum MigrationDomainState { + #[default] + NotStarted, + Staged, + Committed, + Verified, + Failed, + Skipped, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct MigrationDomainResult { + pub domain: MigrationDomainId, + pub state: MigrationDomainState, + pub imported: u64, + pub skipped: u64, + pub conflicts: u64, + pub warnings: Vec, + /// Non-sensitive logical identifiers whose credentials must be entered again. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub requires_reauthentication: Vec, + /// Non-sensitive logical identifiers whose filesystem location must be repaired. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub requires_relocation: Vec, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct MigrationDiagnostic { + pub code: String, + pub severity: FindingSeverity, + pub domain: Option, + pub relative_path: Option, + pub message: String, + pub action: Option, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct MigrationRunReport { + pub format_version: u32, + pub run_id: String, + pub source_fingerprint: String, + pub plan_hash: String, + pub status: MigrationRunStatus, + pub started_at_ms: i64, + pub finished_at_ms: Option, + pub domain_results: Vec, + pub diagnostics: Vec, + pub requires_reauthentication: Vec, + pub requires_relocation: Vec, +} + +/// Content-free projection that may be used for migration release observation. +/// +/// Keep this shape limited to the result code, per-domain state, elapsed time, +/// and failure phase. In particular, it must not grow run identifiers, source +/// fingerprints, paths, counts, diagnostics, or user-authored content. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct MigrationReleaseObservation { + pub result_code: String, + pub domain_states: Vec, + pub duration_ms: u64, + pub failure_phase: Option, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct MigrationDomainObservation { + pub domain: MigrationDomainId, + pub state: MigrationDomainState, +} + +/// A content-free diagnostic code included in an explicit failure export. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct MigrationFailureDiagnosticCode { + pub code: String, + pub severity: FindingSeverity, + pub domain: Option, +} + +/// Sanitized journal evidence included in an explicit failure export. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct MigrationFailureJournalEntry { + pub sequence: u64, + pub status: MigrationRunStatus, + pub phase: MigrationPhase, + pub domain: Option, + pub domain_state: Option, + pub code: String, +} + +/// Shareable failure evidence without source identity, paths, secrets, or text. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct MigrationFailureDiagnostics { + pub format_version: u32, + pub observation: MigrationReleaseObservation, + pub diagnostic_codes: Vec, + pub journal: Vec, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum MigrationPromptChoice { + #[default] + Unset, + MigrateNow, + RemindLater, + DoNotRemind, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct MigrationOnboardingState { + pub format_version: u32, + pub source_fingerprint: String, + pub detected_at_ms: Option, + /// Most recent explicit read-only scan started from a product entry point. + pub last_scanned_at_ms: Option, + pub choice: MigrationPromptChoice, + pub last_prompted_version: Option, + pub run_id: Option, + /// Most recent run whose report can be shown after a later handoff starts. + pub last_report_run_id: Option, + pub handled_run_id: Option, +} + +#[derive( + Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, +)] +#[serde(rename_all = "snake_case")] +pub enum MigratorRequestMode { + #[default] + Onboarding, + Execute, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum MigratorRequestOrigin { + #[default] + FirstLaunch, + Settings, + Installer, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum MigratorProtocolCapability { + ReadOnlyScan, + OfflineExecute, + JournalRecovery, + SafeCancellation, + TrustedRestart, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct MigratorProtocolCapabilities { + pub protocol_version: u32, + pub minimum_compatible_version: u32, + pub supported_modes: BTreeSet, + pub capabilities: BTreeSet, +} + +impl MigratorProtocolCapabilities { + pub fn current() -> Self { + Self { + protocol_version: CURRENT_MIGRATOR_PROTOCOL_VERSION, + minimum_compatible_version: MIN_MIGRATOR_PROTOCOL_VERSION, + supported_modes: BTreeSet::from([ + MigratorRequestMode::Onboarding, + MigratorRequestMode::Execute, + ]), + capabilities: BTreeSet::from([ + MigratorProtocolCapability::ReadOnlyScan, + MigratorProtocolCapability::OfflineExecute, + MigratorProtocolCapability::JournalRecovery, + MigratorProtocolCapability::SafeCancellation, + MigratorProtocolCapability::TrustedRestart, + ]), + } + } + + pub fn accepts(&self, version: u32, mode: MigratorRequestMode) -> bool { + version >= self.minimum_compatible_version + && version <= self.protocol_version + && self.supported_modes.contains(&mode) + } + + pub fn accepts_request(&self, request: &MigratorHandoffRequest) -> bool { + self.accepts(request.protocol_version, request.mode) + && request.required_capabilities.is_subset(&self.capabilities) + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct MigratorHandoffRequest { + pub protocol_version: u32, + pub mode: MigratorRequestMode, + pub origin: MigratorRequestOrigin, + pub run_id: String, + pub nonce: String, + pub source_id: Option, + pub source_fingerprint: Option, + pub selection: MigrationSelection, + pub caller_process_id: u32, + pub product_id: String, + pub release_channel: String, + pub created_at_ms: i64, + pub expires_at_ms: i64, + pub required_capabilities: BTreeSet, +} + +impl MigratorHandoffRequest { + pub fn is_expired_at(&self, now_ms: i64) -> bool { + now_ms > self.expires_at_ms + } +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum MigrationRemoteStance { + #[default] + ControllerLocal, + UnsupportedRemoteControl, + UnsupportedDetachedDispatch, +} + +/// Append-only recovery evidence emitted by the offline migration engine. +/// +/// New fields must remain additive and defaultable because a newer migrator can +/// resume a journal created by an older installed build. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct MigrationJournalEvent { + pub format_version: u32, + pub sequence: u64, + pub recorded_at_ms: i64, + pub run_id: String, + pub status: MigrationRunStatus, + pub phase: MigrationPhase, + pub domain: Option, + pub domain_state: Option, + pub code: String, +} diff --git a/src/crates/contracts/product-domains/src/lib.rs b/src/crates/contracts/product-domains/src/lib.rs index dfc4f377c4..8f9359d800 100644 --- a/src/crates/contracts/product-domains/src/lib.rs +++ b/src/crates/contracts/product-domains/src/lib.rs @@ -53,3 +53,6 @@ pub mod miniapp; #[cfg(feature = "function-agents")] pub mod function_agents; + +#[cfg(feature = "legacy-migration")] +pub mod legacy_migration; diff --git a/src/crates/contracts/product-domains/src/remote_surface/table.rs b/src/crates/contracts/product-domains/src/remote_surface/table.rs index 6fcc483ed5..f9a5639a16 100644 --- a/src/crates/contracts/product-domains/src/remote_surface/table.rs +++ b/src/crates/contracts/product-domains/src/remote_surface/table.rs @@ -299,6 +299,8 @@ pub(super) const OPERATIONS: &[OperationDefinition] = &[ op("get_global_skill_settings", Agnostic, Proxied, CLI_NOT_IMPLEMENTED), op("get_health_status", Agnostic, Proxied, CLI_NOT_IMPLEMENTED), op("get_latest_insights", LocalOnly, ControllerLocal, REFUSED), + op("get_legacy_migration_report", LocalOnly, ControllerLocal, REFUSED), + op("get_legacy_migration_status", LocalOnly, ControllerLocal, REFUSED), op("get_mcp_prompt", Unaudited, Proxied, CLI_NOT_IMPLEMENTED), op("get_mcp_remote_oauth_session", Unaudited, Proxied, CLI_NOT_IMPLEMENTED), op("get_mcp_server_status", Unaudited, Proxied, CLI_NOT_IMPLEMENTED), @@ -514,6 +516,7 @@ pub(super) const OPERATIONS: &[OperationDefinition] = &[ op("plan_external_hook_import_command", Unsupported, Proxied, CLI_NOT_IMPLEMENTED), op("plan_external_mcp_import_command", Unsupported, Proxied, CLI_NOT_IMPLEMENTED), op("predownload_acp_client_adapter", Unaudited, Proxied, CLI_NOT_IMPLEMENTED), + op("prepare_legacy_migration", LocalOnly, ControllerLocal, REFUSED), op("preview_commit_message", Unaudited, Proxied, CLI_NOT_IMPLEMENTED), op("probe_acp_client_requirements", Unaudited, Proxied, CLI_NOT_IMPLEMENTED), op("product_control_invoke", Agnostic, Proxied, HANDLED), @@ -625,6 +628,7 @@ pub(super) const OPERATIONS: &[OperationDefinition] = &[ op("save_session_metadata", Unaudited, Proxied, HANDLED), op("save_session_turn", Unaudited, Proxied, HANDLED), op("save_web_search_credential", Agnostic, Proxied, HANDLED), + op("scan_legacy_migration", LocalOnly, ControllerLocal, REFUSED), op("scan_workspace_info", Unsupported, Proxied, CLI_NOT_IMPLEMENTED), op("search_build_index", Routed, Proxied, CLI_NO_DESKTOP_IDE_SURFACE), op("search_file_contents", Routed, Proxied, CLI_NO_DESKTOP_IDE_SURFACE), @@ -654,6 +658,7 @@ pub(super) const OPERATIONS: &[OperationDefinition] = &[ op("set_external_tool_target_decision_command", Unsupported, Proxied, HANDLED), op("set_external_tool_targets_enabled_command", Unsupported, Proxied, HANDLED), op("set_global_skill_disabled", Agnostic, Proxied, CLI_NOT_IMPLEMENTED), + op("set_legacy_migration_prompt_preference", LocalOnly, ControllerLocal, REFUSED), op("set_macos_edit_menu_mode", LocalOnly, Proxied, CLI_NOT_IMPLEMENTED), op("set_main_window_transient_geometry", LocalOnly, ControllerLocal, REFUSED), op("set_miniapp_draft_storage", Unaudited, Proxied, CLI_NOT_IMPLEMENTED), diff --git a/src/crates/contracts/product-domains/tests/legacy_migration_contracts.rs b/src/crates/contracts/product-domains/tests/legacy_migration_contracts.rs new file mode 100644 index 0000000000..d07b5cd583 --- /dev/null +++ b/src/crates/contracts/product-domains/tests/legacy_migration_contracts.rs @@ -0,0 +1,94 @@ +use openbitfun_product_domains::legacy_migration::{ + MigrationDomainId, MigrationDomainResult, MigrationGroupId, MigrationOnboardingState, + MigrationPromptChoice, MigrationSelection, MigratorHandoffRequest, + MigratorProtocolCapabilities, MigratorProtocolCapability, MigratorRequestMode, + CURRENT_MIGRATOR_PROTOCOL_VERSION, +}; +use std::collections::BTreeSet; + +#[test] +fn session_group_always_expands_coordination_database_dependency() { + let selection = MigrationSelection { + groups: BTreeSet::from([MigrationGroupId::WorkspacesSessionsAndTasks]), + }; + + assert_eq!( + selection.expanded_domains(), + vec![ + MigrationDomainId::WorkspaceSessions, + MigrationDomainId::AgentCoordination, + MigrationDomainId::CrossReferenceRepair, + ] + ); +} + +#[test] +fn persisted_onboarding_shape_accepts_old_payloads() { + let state: MigrationOnboardingState = + serde_json::from_str(r#"{"choice":"remind_later"}"#).expect("additive fields must default"); + + assert_eq!(state.choice, MigrationPromptChoice::RemindLater); + assert_eq!(state.format_version, 0); + assert!(state.run_id.is_none()); + assert!(state.last_scanned_at_ms.is_none()); + assert!(state.last_report_run_id.is_none()); +} + +#[test] +fn persisted_domain_result_defaults_new_repair_lists() { + let result: MigrationDomainResult = serde_json::from_value(serde_json::json!({ + "domain": "credentials", + "state": "staged", + "imported": 1 + })) + .expect("new repair lists must remain additive"); + + assert!(result.requires_reauthentication.is_empty()); + assert!(result.requires_relocation.is_empty()); +} + +#[test] +fn handoff_rejects_expired_or_future_protocol_requests() { + let request: MigratorHandoffRequest = serde_json::from_value(serde_json::json!({ + "protocolVersion": CURRENT_MIGRATOR_PROTOCOL_VERSION, + "mode": "onboarding", + "runId": "run-1", + "nonce": "nonce-1", + "createdAtMs": 10, + "expiresAtMs": 20 + })) + .expect("request should accept additive defaults"); + + let capabilities = MigratorProtocolCapabilities::current(); + assert!(capabilities.accepts_request(&request)); + assert!(!capabilities.accepts( + CURRENT_MIGRATOR_PROTOCOL_VERSION + 1, + MigratorRequestMode::Onboarding + )); + assert!(request.is_expired_at(21)); +} + +#[test] +fn handoff_capability_negotiation_is_additive_and_fail_closed() { + let old_request: MigratorHandoffRequest = serde_json::from_value(serde_json::json!({ + "protocolVersion": CURRENT_MIGRATOR_PROTOCOL_VERSION, + "mode": "execute" + })) + .expect("older requests must default the additive capability set"); + assert!(old_request.required_capabilities.is_empty()); + assert!(MigratorProtocolCapabilities::current().accepts_request(&old_request)); + + let mut required = BTreeSet::new(); + required.insert(MigratorProtocolCapability::JournalRecovery); + let request = MigratorHandoffRequest { + required_capabilities: required, + protocol_version: CURRENT_MIGRATOR_PROTOCOL_VERSION, + mode: MigratorRequestMode::Execute, + ..MigratorHandoffRequest::default() + }; + assert!(MigratorProtocolCapabilities::current().accepts_request(&request)); + + let mut limited = MigratorProtocolCapabilities::current(); + limited.capabilities.clear(); + assert!(!limited.accepts_request(&request)); +} diff --git a/src/crates/execution/agent-runtime/Cargo.toml b/src/crates/execution/agent-runtime/Cargo.toml index b234c51de3..655f77b920 100644 --- a/src/crates/execution/agent-runtime/Cargo.toml +++ b/src/crates/execution/agent-runtime/Cargo.toml @@ -17,6 +17,13 @@ required-features = ["agent-runtime"] [features] default = [] +definition-contracts = [ + "dep:openbitfun-core-types", + "dep:regex", + "dep:serde", + "dep:serde_yaml", + "dep:thiserror", +] native-hook-settings = ["dep:regex", "dep:serde_json"] native-hook-runtime = [ "native-hook-settings", @@ -33,6 +40,7 @@ native-hook-runtime = [ "tokio/time", ] agent-runtime = [ + "definition-contracts", "native-hook-runtime", "dep:async-trait", "dep:openbitfun-agent-stream", diff --git a/src/crates/execution/agent-runtime/src/lib.rs b/src/crates/execution/agent-runtime/src/lib.rs index 51af96e764..a73173eb32 100644 --- a/src/crates/execution/agent-runtime/src/lib.rs +++ b/src/crates/execution/agent-runtime/src/lib.rs @@ -9,7 +9,7 @@ pub mod agents; pub mod checkpoint; #[cfg(feature = "agent-runtime")] pub mod context_profile; -#[cfg(feature = "agent-runtime")] +#[cfg(any(feature = "agent-runtime", feature = "definition-contracts"))] pub mod custom_agent; #[cfg(feature = "agent-runtime")] pub mod custom_subagent; @@ -39,7 +39,7 @@ pub mod output_surface; pub mod permission; #[cfg(feature = "agent-runtime")] pub mod post_call_hooks; -#[cfg(feature = "agent-runtime")] +#[cfg(any(feature = "agent-runtime", feature = "definition-contracts"))] pub mod prompt; #[cfg(feature = "agent-runtime")] pub mod prompt_cache; @@ -67,7 +67,7 @@ pub mod session_state_manager; pub mod side_question; #[cfg(feature = "agent-runtime")] pub mod skill_agent_snapshot; -#[cfg(feature = "agent-runtime")] +#[cfg(any(feature = "agent-runtime", feature = "definition-contracts"))] pub mod skills; #[cfg(feature = "agent-runtime")] pub mod subagent_task; diff --git a/src/crates/execution/agent-runtime/src/skills/mod.rs b/src/crates/execution/agent-runtime/src/skills/mod.rs index 1a11d20e2a..fad7e3c1c6 100644 --- a/src/crates/execution/agent-runtime/src/skills/mod.rs +++ b/src/crates/execution/agent-runtime/src/skills/mod.rs @@ -5,16 +5,24 @@ //! rendering. Product hosts still own filesystem/config IO and registry //! scanning. +#[cfg(feature = "agent-runtime")] mod catalog; +#[cfg(feature = "agent-runtime")] mod keys; +#[cfg(feature = "agent-runtime")] mod policy; +#[cfg(feature = "agent-runtime")] mod resolver; mod roots; +#[cfg(feature = "agent-runtime")] mod selection; mod types; +#[cfg(feature = "agent-runtime")] pub use catalog::builtin_skill_group_key; +#[cfg(feature = "agent-runtime")] pub use policy::resolve_builtin_default_enabled; +#[cfg(feature = "agent-runtime")] pub use resolver::{ normalize_user_mode_skill_overrides, resolve_skill_default_enabled_for_mode, resolve_skill_state_for_mode, ModeSkillState, UserModeSkillOverrides, @@ -26,6 +34,7 @@ pub use roots::{ OPENBITFUN_USER_SKILL_SLOT, PROJECT_SKILL_KEY_PREFIX, PROJECT_SKILL_ROOTS, USER_CONFIG_SKILL_ROOTS, USER_HOME_SKILL_ROOTS, USER_SKILL_KEY_PREFIX, }; +#[cfg(feature = "agent-runtime")] pub use selection::{ annotate_shadowed_skills, build_mode_skill_infos, filter_candidates_for_mode, filter_implicitly_invocable_skills, filter_user_invocable_skills, is_skill_globally_enabled, diff --git a/src/crates/services/AGENTS-CN.md b/src/crates/services/AGENTS-CN.md index 97c644d195..bbf721940c 100644 --- a/src/crates/services/AGENTS-CN.md +++ b/src/crates/services/AGENTS-CN.md @@ -10,6 +10,7 @@ |---|---|---| | `services-core` | 不包含产品组装决策的本地 service primitive,包括 session storage、metadata store CRUD/index rebuild、metadata 构造/计数/索引/字段 mutation、lineage 规则和 JSON file IO | [AGENTS.md](services-core/AGENTS.md) | | `services-integrations` | MCP、git、remote、file watch、MiniApp runtime、产品领域 port 具体实现,以及平台无关的 Remote Connect primitives | [AGENTS.md](services-integrations/AGENTS.md) | +| `legacy-migration` | 离线旧产品发现、加锁、一致性 SQLite 快照、暂存、journal 恢复和 owner adapter 编排 | — | | `relay-service` | standalone 与 embedded 宿主共享的 Remote Connect relay 状态、存储及 HTTP/WebSocket 路由 | [AGENTS.md](relay-service/AGENTS.md) | | `page-function-runtime` | OpenBitFun Pages 嵌入式 JS Page Function runtime(rquickjs) | [AGENTS.md](page-function-runtime/AGENTS.md) | | `terminal` | PTY、shell integration 与 terminal session infrastructure | [AGENTS.md](terminal/AGENTS.md) | diff --git a/src/crates/services/AGENTS.md b/src/crates/services/AGENTS.md index 79530cdfc4..2edd3bceb8 100644 --- a/src/crates/services/AGENTS.md +++ b/src/crates/services/AGENTS.md @@ -13,6 +13,7 @@ OS/network capabilities. |---|---|---| | `services-core` | Reusable local service primitives, process-wide TLS provider selection, managed process-tree lifecycle, filesystem helpers, session storage layout/indexing/deletion, metadata store CRUD/index rebuild, metadata construction/counter/index/field mutation/lineage rules, and JSON file IO without product assembly decisions | [AGENTS.md](services-core/AGENTS.md) | | `services-integrations` | Concrete MCP, git, remote, file-watch, MiniApp runtime, review-platform provider service, product-domain port implementations, and platform-neutral Remote Connect primitives | [AGENTS.md](services-integrations/AGENTS.md) | +| `legacy-migration` | Offline retired-product discovery, locking, consistent SQLite snapshots, staging, journal recovery, and owner-adapter orchestration | — | | `miniapp-market-service` | Concrete SQLite, artifact storage, GitHub OAuth, package validation, and HTTP behavior for the MiniApp market | [README.md](miniapp-market-service/README.md) | | `skin-market-service` | Concrete SQLite, artifact storage, Appearance package validation, review, and HTTP behavior for the Skin market | [README.md](skin-market-service/README.md) | | `relay-service` | Reusable Remote Connect relay state, storage, and HTTP/WebSocket routes shared by standalone and embedded hosts | [AGENTS.md](relay-service/AGENTS.md) | diff --git a/src/crates/services/legacy-migration/Cargo.toml b/src/crates/services/legacy-migration/Cargo.toml new file mode 100644 index 0000000000..ad085cb0e6 --- /dev/null +++ b/src/crates/services/legacy-migration/Cargo.toml @@ -0,0 +1,54 @@ +[package] +name = "openbitfun-legacy-migration" +version.workspace = true +authors.workspace = true +edition.workspace = true +description = "Offline legacy BitFun data migration service" +autotests = false + +[lib] +name = "openbitfun_legacy_migration" +crate-type = ["rlib"] + +[[example]] +name = "legacy-migration-dry-run" +path = "examples/dry_run.rs" + +[[test]] +name = "migration_engine_contracts" +path = "tests/migration_engine_contracts.rs" + +[dependencies] +chrono = { workspace = true } +dirs = { workspace = true } +fs2 = { workspace = true } +hex = { workspace = true } +openbitfun-product-domains = { path = "../../contracts/product-domains", features = ["legacy-migration"] } +openbitfun-services-core = { path = "../services-core", features = ["process-runtime", "product-identity"] } +rusqlite = { workspace = true, features = ["backup", "bundled"] } +semver = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +sha2 = { workspace = true } +thiserror = { workspace = true } +uuid = { workspace = true } + +[target.'cfg(windows)'.dependencies] +windows = { workspace = true, features = [ + "Win32_Foundation", + "Win32_Security", + "Win32_Security_Authorization", + "Win32_Security_Cryptography", + "Win32_Storage_FileSystem", + "Win32_System_Diagnostics_ToolHelp", + "Win32_System_Threading", +] } + +[target.'cfg(unix)'.dependencies] +libc = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } + +[lints] +workspace = true diff --git a/src/crates/services/legacy-migration/examples/dry_run.rs b/src/crates/services/legacy-migration/examples/dry_run.rs new file mode 100644 index 0000000000..f3edf935a6 --- /dev/null +++ b/src/crates/services/legacy-migration/examples/dry_run.rs @@ -0,0 +1,18 @@ +use openbitfun_legacy_migration::{probe_legacy_source, MigrationRoots, ProbeLimits}; + +fn main() { + let result = MigrationRoots::resolve_current_user() + .and_then(|roots| probe_legacy_source(&roots, ProbeLimits::default())); + match result { + Ok(source) => { + println!( + "{}", + serde_json::to_string_pretty(&source).expect("probe result should serialize") + ); + } + Err(error) => { + eprintln!("Legacy migration dry run failed: {error}"); + std::process::exit(1); + } + } +} diff --git a/src/crates/services/legacy-migration/src/diagnostics.rs b/src/crates/services/legacy-migration/src/diagnostics.rs new file mode 100644 index 0000000000..578e491d4a --- /dev/null +++ b/src/crates/services/legacy-migration/src/diagnostics.rs @@ -0,0 +1,253 @@ +use crate::{atomic_write_json, LegacyMigrationError, LegacyMigrationResult, MigrationLayout}; +use openbitfun_product_domains::legacy_migration::{ + FindingSeverity, MigrationDiagnostic, MigrationDomainObservation, MigrationDomainState, + MigrationFailureDiagnosticCode, MigrationFailureDiagnostics, MigrationFailureJournalEntry, + MigrationJournalEvent, MigrationPhase, MigrationReleaseObservation, MigrationRunReport, + MigrationRunStatus, +}; +use std::fs; +use std::path::{Path, PathBuf}; + +const FAILURE_DIAGNOSTICS_FORMAT_VERSION: u32 = 1; +const MAX_DIAGNOSTIC_JOURNAL_BYTES: u64 = 4 * 1024 * 1024; +const MAX_DIAGNOSTIC_JOURNAL_ENTRIES: usize = 10_000; +const REDACTED_CODE: &str = "redacted_code"; + +pub(crate) fn persist_release_observation( + layout: &MigrationLayout, + report: &MigrationRunReport, + result_code: &str, + failure_phase: Option, + observed_at_ms: i64, +) -> LegacyMigrationResult<()> { + let observation = release_observation(report, result_code, failure_phase, observed_at_ms); + atomic_write_json(&layout.release_observation_path(), &observation) +} + +pub fn release_observation( + report: &MigrationRunReport, + result_code: &str, + failure_phase: Option, + observed_at_ms: i64, +) -> MigrationReleaseObservation { + MigrationReleaseObservation { + result_code: sanitize_code(result_code), + domain_states: report + .domain_results + .iter() + .map(|result| MigrationDomainObservation { + domain: result.domain, + state: result.state, + }) + .collect(), + duration_ms: elapsed_ms(report.started_at_ms, observed_at_ms), + failure_phase, + } +} + +/// Write a shareable failure-only diagnostic artifact next to the run metadata. +/// +/// The export deliberately projects trusted enums and sanitized result codes. +/// It never copies the request, plan, source fingerprint, diagnostic messages, +/// actions, paths, repair identifiers, entity counts, or user-authored content. +pub fn export_failure_diagnostics( + layout: &MigrationLayout, + report: &MigrationRunReport, +) -> LegacyMigrationResult { + if !matches!( + report.status, + MigrationRunStatus::FailedRecoverable | MigrationRunStatus::FailedManualActionRequired + ) { + return Err(LegacyMigrationError::InvalidRequest( + "failure diagnostics require a failed migration report".to_string(), + )); + } + + let mut journal = read_sanitized_journal(&layout.journal_path())?; + let failure = journal + .entries + .iter() + .rev() + .find(|entry| { + matches!( + entry.status, + MigrationRunStatus::FailedRecoverable + | MigrationRunStatus::FailedManualActionRequired + ) || entry.domain_state == Some(MigrationDomainState::Failed) + }) + .cloned(); + let result_code = failure + .as_ref() + .map(|entry| entry.code.as_str()) + .unwrap_or_else(|| default_result_code(report.status)); + let failure_phase = failure.as_ref().map(|entry| entry.phase); + let observed_at_ms = journal + .last_recorded_at_ms + .or(report.finished_at_ms) + .unwrap_or(report.started_at_ms); + + let mut diagnostic_codes = report + .diagnostics + .iter() + .chain( + report + .domain_results + .iter() + .flat_map(|result| result.warnings.iter()), + ) + .map(sanitize_diagnostic) + .collect::>(); + diagnostic_codes.append(&mut journal.supplemental_diagnostics); + deduplicate_diagnostics(&mut diagnostic_codes); + + let diagnostics = MigrationFailureDiagnostics { + format_version: FAILURE_DIAGNOSTICS_FORMAT_VERSION, + observation: release_observation(report, result_code, failure_phase, observed_at_ms), + diagnostic_codes, + journal: journal.entries, + }; + let path = layout.failure_diagnostics_path(); + atomic_write_json(&path, &diagnostics)?; + Ok(path) +} + +struct SanitizedJournal { + entries: Vec, + supplemental_diagnostics: Vec, + last_recorded_at_ms: Option, +} + +fn read_sanitized_journal(path: &Path) -> LegacyMigrationResult { + let metadata = match fs::metadata(path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Ok(SanitizedJournal { + entries: Vec::new(), + supplemental_diagnostics: vec![diagnostic_code( + "journal_unavailable", + FindingSeverity::Warning, + )], + last_recorded_at_ms: None, + }); + } + Err(error) => return Err(LegacyMigrationError::io(path, error)), + }; + if metadata.len() > MAX_DIAGNOSTIC_JOURNAL_BYTES { + return Ok(SanitizedJournal { + entries: Vec::new(), + supplemental_diagnostics: vec![diagnostic_code( + "journal_size_limit_reached", + FindingSeverity::Warning, + )], + last_recorded_at_ms: None, + }); + } + + let bytes = fs::read(path).map_err(|error| LegacyMigrationError::io(path, error))?; + let mut entries = Vec::new(); + let mut supplemental_diagnostics = Vec::new(); + let mut last_recorded_at_ms = None; + for line in bytes + .split(|byte| *byte == b'\n') + .filter(|line| !line.is_empty()) + { + if entries.len() >= MAX_DIAGNOSTIC_JOURNAL_ENTRIES { + supplemental_diagnostics.push(diagnostic_code( + "journal_entry_limit_reached", + FindingSeverity::Warning, + )); + break; + } + match serde_json::from_slice::(line) { + Ok(event) => { + last_recorded_at_ms = Some( + last_recorded_at_ms.map_or(event.recorded_at_ms, |current: i64| { + current.max(event.recorded_at_ms) + }), + ); + entries.push(MigrationFailureJournalEntry { + sequence: event.sequence, + status: event.status, + phase: event.phase, + domain: event.domain, + domain_state: event.domain_state, + code: sanitize_code(&event.code), + }); + } + Err(_) => supplemental_diagnostics.push(diagnostic_code( + "journal_entry_invalid", + FindingSeverity::Warning, + )), + } + } + + Ok(SanitizedJournal { + entries, + supplemental_diagnostics, + last_recorded_at_ms, + }) +} + +fn sanitize_diagnostic(diagnostic: &MigrationDiagnostic) -> MigrationFailureDiagnosticCode { + MigrationFailureDiagnosticCode { + code: sanitize_code(&diagnostic.code), + severity: diagnostic.severity, + domain: diagnostic.domain, + } +} + +fn diagnostic_code(code: &str, severity: FindingSeverity) -> MigrationFailureDiagnosticCode { + MigrationFailureDiagnosticCode { + code: code.to_string(), + severity, + domain: None, + } +} + +fn deduplicate_diagnostics(diagnostics: &mut Vec) { + let mut unique = Vec::with_capacity(diagnostics.len()); + for diagnostic in diagnostics.drain(..) { + if !unique.contains(&diagnostic) { + unique.push(diagnostic); + } + } + *diagnostics = unique; +} + +fn sanitize_code(code: &str) -> String { + if !code.is_empty() + && code.len() <= 64 + && code + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_') + { + code.to_string() + } else { + REDACTED_CODE.to_string() + } +} + +fn default_result_code(status: MigrationRunStatus) -> &'static str { + match status { + MigrationRunStatus::Discovered => "discovered", + MigrationRunStatus::Scanned => "scanned", + MigrationRunStatus::Planned => "planned", + MigrationRunStatus::WaitingForProcesses => "waiting_for_processes", + MigrationRunStatus::Staging => "staging", + MigrationRunStatus::ValidatingStage => "validating_stage", + MigrationRunStatus::Committing => "committing", + MigrationRunStatus::ValidatingCommit => "validating_commit", + MigrationRunStatus::Completed => "completed", + MigrationRunStatus::CompletedWithWarnings => "completed_with_warnings", + MigrationRunStatus::Cancelled => "cancelled", + MigrationRunStatus::FailedRecoverable => "failed_recoverable", + MigrationRunStatus::FailedManualActionRequired => "failed_manual_action_required", + } +} + +fn elapsed_ms(started_at_ms: i64, observed_at_ms: i64) -> u64 { + if started_at_ms <= 0 || observed_at_ms <= started_at_ms { + return 0; + } + u64::try_from(observed_at_ms.saturating_sub(started_at_ms)).unwrap_or(u64::MAX) +} diff --git a/src/crates/services/legacy-migration/src/engine.rs b/src/crates/services/legacy-migration/src/engine.rs new file mode 100644 index 0000000000..81d5a61ff9 --- /dev/null +++ b/src/crates/services/legacy-migration/src/engine.rs @@ -0,0 +1,1075 @@ +use crate::{ + atomic_write_json, diagnostics::persist_release_observation, probe_legacy_source, + LegacyMigrationError, LegacyMigrationResult, MigrationLayout, MigrationLock, MigrationRoots, + ProbeLimits, +}; +use openbitfun_product_domains::legacy_migration::{ + FindingSeverity, LegacySourceDescriptor, MigrationConflict, MigrationDiagnostic, + MigrationDomainId, MigrationDomainResult, MigrationDomainState, MigrationJournalEvent, + MigrationPhase, MigrationPlan, MigrationPlanStep, MigrationProgressEvent, MigrationRunReport, + MigrationRunStatus, MigrationSelection, CURRENT_MIGRATION_FORMAT_VERSION, +}; +use serde::Serialize; +use sha2::{Digest, Sha256}; +use std::collections::{BTreeMap, BTreeSet}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DomainScan { + pub finding: openbitfun_product_domains::legacy_migration::ScanFinding, + pub conflicts: Vec, + pub target_schema: Option, + pub dependencies: Vec, +} + +impl DomainScan { + pub fn estimated_write_bytes(&self) -> u64 { + self.finding.logical_bytes + } +} + +pub struct DomainContext<'a> { + pub roots: &'a MigrationRoots, + pub layout: &'a MigrationLayout, + pub plan: &'a MigrationPlan, + pub step: &'a MigrationPlanStep, +} + +/// Owner-provided legacy reader, converter, writer, and validator. +/// +/// `commit` must be idempotent for the same immutable plan. A process can die +/// after the owner writes its target but before the engine records completion, +/// so recovery is allowed to call `commit` again. Implementations must never +/// execute imported content and must keep the legacy source read-only. +pub trait LegacyDomainAdapter: Send + Sync { + fn domain(&self) -> MigrationDomainId; + + fn scan(&self, roots: &MigrationRoots) -> LegacyMigrationResult; + + fn stage(&self, context: &DomainContext<'_>) -> LegacyMigrationResult; + + fn validate_stage(&self, context: &DomainContext<'_>) -> LegacyMigrationResult<()>; + + fn commit(&self, context: &DomainContext<'_>) -> LegacyMigrationResult<()>; + + fn validate_commit(&self, context: &DomainContext<'_>) -> LegacyMigrationResult<()>; + + /// Refresh non-sensitive result metadata discovered only while committing. + /// + /// Secret-bearing adapters deliberately defer decryption until `commit`. + /// They can persist a redacted outcome and surface warnings or + /// reauthentication identifiers here after the owner has validated the + /// installed data. + fn finalize_result( + &self, + _context: &DomainContext<'_>, + staged: &MigrationDomainResult, + ) -> LegacyMigrationResult { + Ok(staged.clone()) + } + + fn rollback_unverified(&self, _context: &DomainContext<'_>) -> LegacyMigrationResult<()> { + Ok(()) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CrashPoint { + AfterPlanPersisted, + AfterLockAcquired, + BeforeStage(MigrationDomainId), + AfterStage(MigrationDomainId), + AfterStageValidated(MigrationDomainId), + BeforeCommit(MigrationDomainId), + AfterCommit(MigrationDomainId), + AfterCommitValidated(MigrationDomainId), + BeforeFinalize, +} + +pub trait CrashInjector { + fn should_crash(&self, point: CrashPoint) -> bool; +} + +#[derive(Debug, Default, Clone, Copy)] +pub struct NoCrashInjection; + +impl CrashInjector for NoCrashInjection { + fn should_crash(&self, _point: CrashPoint) -> bool { + false + } +} + +#[derive(Debug, Clone, Default)] +pub struct CancellationToken { + cancelled: Arc, +} + +impl CancellationToken { + pub fn cancel(&self) { + self.cancelled.store(true, Ordering::Release); + } + + pub fn is_cancelled(&self) -> bool { + self.cancelled.load(Ordering::Acquire) + } + + fn check(&self) -> LegacyMigrationResult<()> { + if self.is_cancelled() { + Err(LegacyMigrationError::Cancelled) + } else { + Ok(()) + } + } +} + +pub struct MigrationEngine { + roots: MigrationRoots, + adapters: BTreeMap>, +} + +impl MigrationEngine { + pub fn new( + roots: MigrationRoots, + adapters: impl IntoIterator>, + ) -> LegacyMigrationResult { + roots.validate_distinct()?; + let mut by_domain = BTreeMap::new(); + for adapter in adapters { + let domain = adapter.domain(); + if by_domain.insert(domain, adapter).is_some() { + return Err(LegacyMigrationError::InvalidRequest(format!( + "duplicate migration adapter for {domain:?}" + ))); + } + } + Ok(Self { + roots, + adapters: by_domain, + }) + } + + pub fn scan( + &self, + selection: &MigrationSelection, + cancellation: &CancellationToken, + ) -> LegacyMigrationResult> { + let mut scans = Vec::new(); + for domain in selection.expanded_domains() { + cancellation.check()?; + let adapter = self.adapter(domain)?; + let scan = adapter + .scan(&self.roots) + .map_err(|error| domain_error(domain, error))?; + if scan.finding.domain != domain { + return Err(LegacyMigrationError::InvalidPlan(format!( + "adapter {domain:?} returned a finding for {:?}", + scan.finding.domain + ))); + } + scans.push(scan); + } + Ok(scans) + } + + pub fn plan( + &self, + source: &LegacySourceDescriptor, + selection: MigrationSelection, + cancellation: &CancellationToken, + ) -> LegacyMigrationResult { + self.plan_with_run_id( + source, + selection, + uuid::Uuid::new_v4().to_string(), + cancellation, + ) + } + + /// Build an immutable plan for a run id authenticated by the handoff file. + pub fn plan_with_run_id( + &self, + source: &LegacySourceDescriptor, + selection: MigrationSelection, + run_id: impl Into, + cancellation: &CancellationToken, + ) -> LegacyMigrationResult { + if !source.readable || !source.supported { + return Err(LegacyMigrationError::UnsupportedSource( + "legacy source is not readable and supported".to_string(), + )); + } + let scans = self.scan(&selection, cancellation)?; + let selected = selection + .expanded_domains() + .into_iter() + .collect::>(); + let mut steps = Vec::with_capacity(scans.len()); + let mut findings = Vec::with_capacity(scans.len()); + let mut conflicts = Vec::new(); + let mut estimated_write_bytes = 0u64; + + for (index, scan) in scans.into_iter().enumerate() { + for dependency in &scan.dependencies { + if !selected.contains(dependency) { + return Err(LegacyMigrationError::InvalidPlan(format!( + "{:?} requires unselected domain {dependency:?}", + scan.finding.domain + ))); + } + } + estimated_write_bytes = + estimated_write_bytes.saturating_add(scan.estimated_write_bytes()); + steps.push(MigrationPlanStep { + sequence: u32::try_from(index + 1).unwrap_or(u32::MAX), + domain: scan.finding.domain, + estimated_write_bytes: scan.estimated_write_bytes(), + source_schema: scan.finding.source_schema.clone(), + target_schema: scan.target_schema, + dependencies: scan.dependencies, + }); + findings.push(scan.finding); + conflicts.extend(scan.conflicts); + } + + let run_id = run_id.into(); + if uuid::Uuid::parse_str(&run_id).is_err() { + return Err(LegacyMigrationError::InvalidRequest( + "migration run id must be a UUID".to_string(), + )); + } + let mut plan = MigrationPlan { + format_version: CURRENT_MIGRATION_FORMAT_VERSION, + run_id, + source_fingerprint: source.source_fingerprint.clone(), + selection, + steps, + findings, + conflicts, + estimated_write_bytes, + plan_hash: String::new(), + }; + plan.plan_hash = compute_plan_hash(&plan)?; + Ok(plan) + } + + pub fn execute( + &self, + plan: &MigrationPlan, + cancellation: &CancellationToken, + crash_injector: &dyn CrashInjector, + ) -> LegacyMigrationResult { + self.execute_with_progress(plan, cancellation, crash_injector, |_| {}) + } + + pub fn execute_with_progress( + &self, + plan: &MigrationPlan, + cancellation: &CancellationToken, + crash_injector: &dyn CrashInjector, + mut progress: impl FnMut(MigrationProgressEvent), + ) -> LegacyMigrationResult { + self.validate_plan(plan)?; + let layout = MigrationLayout::new(&self.roots, &plan.run_id); + layout.initialize()?; + persist_or_validate_plan(&layout, plan)?; + inject(crash_injector, CrashPoint::AfterPlanPersisted)?; + + cancellation.check()?; + let _lock = MigrationLock::acquire(&layout)?; + inject(crash_injector, CrashPoint::AfterLockAcquired)?; + let current_source = + probe_legacy_source(&self.roots, ProbeLimits::default())?.ok_or_else(|| { + LegacyMigrationError::InvalidPlan( + "legacy source disappeared after the plan was created".to_string(), + ) + })?; + if current_source.source_fingerprint != plan.source_fingerprint { + return Err(LegacyMigrationError::InvalidPlan( + "legacy source changed after the plan was created".to_string(), + )); + } + + let mut report = load_or_create_report(&layout, plan)?; + if matches!( + report.status, + MigrationRunStatus::Completed | MigrationRunStatus::CompletedWithWarnings + ) { + let result_code = match report.status { + MigrationRunStatus::CompletedWithWarnings => "migration_completed_with_warnings", + _ => "migration_completed", + }; + persist_release_observation( + &layout, + &report, + result_code, + None, + report.finished_at_ms.unwrap_or_else(now_ms), + )?; + return Ok(report); + } + normalize_report(plan, &mut report); + let mut journal_sequence = journal_sequence(&layout)?; + transition( + &layout, + &mut report, + &mut journal_sequence, + MigrationRunStatus::Staging, + MigrationPhase::Acquire, + None, + None, + "lock_acquired", + )?; + + for (index, step) in plan.steps.iter().enumerate() { + let result_index = report + .domain_results + .iter() + .position(|result| result.domain == step.domain) + .expect("normalized report includes every plan step"); + if report.domain_results[result_index].state == MigrationDomainState::Verified { + continue; + } + let context = DomainContext { + roots: &self.roots, + layout: &layout, + plan, + step, + }; + let adapter = self.adapter(step.domain)?; + + let recovered_state = report.domain_results[result_index].state; + if matches!( + recovered_state, + MigrationDomainState::NotStarted | MigrationDomainState::Failed + ) { + cancellation.check().map_err(|error| { + let _ = record_cancelled(&layout, &mut report, &mut journal_sequence); + error + })?; + inject(crash_injector, CrashPoint::BeforeStage(step.domain))?; + emit_progress( + &mut progress, + plan, + step, + MigrationPhase::Stage, + index, + true, + "staging_domain", + ); + transition( + &layout, + &mut report, + &mut journal_sequence, + MigrationRunStatus::Staging, + MigrationPhase::Stage, + Some(step.domain), + Some(MigrationDomainState::NotStarted), + "stage_started", + )?; + let mut staged = match adapter.stage(&context) { + Ok(staged) => staged, + Err(error) => { + record_domain_failure( + &layout, + &mut report, + &mut journal_sequence, + result_index, + step.domain, + MigrationPhase::Stage, + &error, + )?; + let _ = adapter.rollback_unverified(&context); + return Err(domain_error(step.domain, error)); + } + }; + if staged.domain != step.domain { + return Err(LegacyMigrationError::InvalidPlan(format!( + "adapter {:?} staged a result for {:?}", + step.domain, staged.domain + ))); + } + staged.state = MigrationDomainState::Staged; + report.domain_results[result_index] = staged; + persist_report(&layout, &report)?; + journal( + &layout, + &report, + &mut journal_sequence, + MigrationPhase::Stage, + Some(step.domain), + Some(MigrationDomainState::Staged), + "stage_completed", + )?; + inject(crash_injector, CrashPoint::AfterStage(step.domain))?; + } + + if report.domain_results[result_index].state == MigrationDomainState::Staged { + cancellation.check().map_err(|error| { + let _ = record_cancelled(&layout, &mut report, &mut journal_sequence); + error + })?; + emit_progress( + &mut progress, + plan, + step, + MigrationPhase::ValidateStage, + index, + true, + "validating_staged_domain", + ); + transition( + &layout, + &mut report, + &mut journal_sequence, + MigrationRunStatus::ValidatingStage, + MigrationPhase::ValidateStage, + Some(step.domain), + Some(MigrationDomainState::Staged), + "stage_validation_started", + )?; + if let Err(error) = adapter.validate_stage(&context) { + record_domain_failure( + &layout, + &mut report, + &mut journal_sequence, + result_index, + step.domain, + MigrationPhase::ValidateStage, + &error, + )?; + let _ = adapter.rollback_unverified(&context); + return Err(domain_error(step.domain, error)); + } + journal( + &layout, + &report, + &mut journal_sequence, + MigrationPhase::ValidateStage, + Some(step.domain), + Some(MigrationDomainState::Staged), + "stage_validated", + )?; + inject(crash_injector, CrashPoint::AfterStageValidated(step.domain))?; + } + + if report.domain_results[result_index].state == MigrationDomainState::Staged { + cancellation.check().map_err(|error| { + let _ = record_cancelled(&layout, &mut report, &mut journal_sequence); + error + })?; + emit_progress( + &mut progress, + plan, + step, + MigrationPhase::Commit, + index, + false, + "committing_domain", + ); + transition( + &layout, + &mut report, + &mut journal_sequence, + MigrationRunStatus::Committing, + MigrationPhase::Commit, + Some(step.domain), + Some(MigrationDomainState::Staged), + "commit_intent", + )?; + inject(crash_injector, CrashPoint::BeforeCommit(step.domain))?; + if let Err(error) = adapter.commit(&context) { + record_domain_failure( + &layout, + &mut report, + &mut journal_sequence, + result_index, + step.domain, + MigrationPhase::Commit, + &error, + )?; + let _ = adapter.rollback_unverified(&context); + return Err(domain_error(step.domain, error)); + } + inject(crash_injector, CrashPoint::AfterCommit(step.domain))?; + report.domain_results[result_index].state = MigrationDomainState::Committed; + persist_report(&layout, &report)?; + journal( + &layout, + &report, + &mut journal_sequence, + MigrationPhase::Commit, + Some(step.domain), + Some(MigrationDomainState::Committed), + "commit_completed", + )?; + } + + if report.domain_results[result_index].state == MigrationDomainState::Committed { + emit_progress( + &mut progress, + plan, + step, + MigrationPhase::ValidateCommit, + index, + false, + "validating_committed_domain", + ); + transition( + &layout, + &mut report, + &mut journal_sequence, + MigrationRunStatus::ValidatingCommit, + MigrationPhase::ValidateCommit, + Some(step.domain), + Some(MigrationDomainState::Committed), + "commit_validation_started", + )?; + if let Err(error) = adapter.validate_commit(&context) { + record_domain_failure( + &layout, + &mut report, + &mut journal_sequence, + result_index, + step.domain, + MigrationPhase::ValidateCommit, + &error, + )?; + let _ = adapter.rollback_unverified(&context); + return Err(domain_error(step.domain, error)); + } + let mut finalized = + match adapter.finalize_result(&context, &report.domain_results[result_index]) { + Ok(finalized) => finalized, + Err(error) => { + record_domain_failure( + &layout, + &mut report, + &mut journal_sequence, + result_index, + step.domain, + MigrationPhase::ValidateCommit, + &error, + )?; + let _ = adapter.rollback_unverified(&context); + return Err(domain_error(step.domain, error)); + } + }; + if finalized.domain != step.domain { + let error = LegacyMigrationError::InvalidPlan(format!( + "adapter {:?} finalized a result for {:?}", + step.domain, finalized.domain + )); + record_domain_failure( + &layout, + &mut report, + &mut journal_sequence, + result_index, + step.domain, + MigrationPhase::ValidateCommit, + &error, + )?; + let _ = adapter.rollback_unverified(&context); + return Err(error); + } + finalized.state = MigrationDomainState::Verified; + report.domain_results[result_index] = finalized; + persist_report(&layout, &report)?; + journal( + &layout, + &report, + &mut journal_sequence, + MigrationPhase::ValidateCommit, + Some(step.domain), + Some(MigrationDomainState::Verified), + "commit_verified", + )?; + inject( + crash_injector, + CrashPoint::AfterCommitValidated(step.domain), + )?; + emit_progress( + &mut progress, + plan, + step, + MigrationPhase::ValidateCommit, + index + 1, + true, + "domain_verified", + ); + } + } + + inject(crash_injector, CrashPoint::BeforeFinalize)?; + report.requires_reauthentication = report + .domain_results + .iter() + .flat_map(|result| result.requires_reauthentication.iter().cloned()) + .collect::>() + .into_iter() + .collect(); + report.requires_relocation = report + .domain_results + .iter() + .flat_map(|result| result.requires_relocation.iter().cloned()) + .collect::>() + .into_iter() + .collect(); + report.finished_at_ms = Some(now_ms()); + let has_warnings = report.diagnostics.iter().any(|diagnostic| { + matches!( + diagnostic.severity, + FindingSeverity::Warning | FindingSeverity::Blocking + ) + }) || report.domain_results.iter().any(|result| { + result.warnings.iter().any(|diagnostic| { + matches!( + diagnostic.severity, + FindingSeverity::Warning | FindingSeverity::Blocking + ) + }) + }); + let final_status = if has_warnings { + MigrationRunStatus::CompletedWithWarnings + } else { + MigrationRunStatus::Completed + }; + let final_code = if has_warnings { + "migration_completed_with_warnings" + } else { + "migration_completed" + }; + transition( + &layout, + &mut report, + &mut journal_sequence, + final_status, + MigrationPhase::Finalize, + None, + None, + final_code, + )?; + emit_progress( + &mut progress, + plan, + plan.steps.last().unwrap_or(&MigrationPlanStep::default()), + MigrationPhase::Finalize, + plan.steps.len(), + true, + final_code, + ); + Ok(report) + } + + fn adapter( + &self, + domain: MigrationDomainId, + ) -> LegacyMigrationResult<&dyn LegacyDomainAdapter> { + self.adapters.get(&domain).map(Box::as_ref).ok_or_else(|| { + LegacyMigrationError::InvalidRequest(format!( + "no migration adapter registered for {domain:?}" + )) + }) + } + + fn validate_plan(&self, plan: &MigrationPlan) -> LegacyMigrationResult<()> { + if plan.format_version != CURRENT_MIGRATION_FORMAT_VERSION { + return Err(LegacyMigrationError::InvalidPlan(format!( + "unsupported plan format {}", + plan.format_version + ))); + } + if plan.run_id.is_empty() || plan.source_fingerprint.is_empty() { + return Err(LegacyMigrationError::InvalidPlan( + "plan identity is incomplete".to_string(), + )); + } + if compute_plan_hash(plan)? != plan.plan_hash { + return Err(LegacyMigrationError::InvalidPlan( + "plan hash does not match the immutable plan".to_string(), + )); + } + let expected = plan.selection.expanded_domains(); + let actual = plan + .steps + .iter() + .map(|step| step.domain) + .collect::>(); + if expected != actual { + return Err(LegacyMigrationError::InvalidPlan( + "plan steps do not match the selected dependency order".to_string(), + )); + } + for (index, step) in plan.steps.iter().enumerate() { + if step.sequence != u32::try_from(index + 1).unwrap_or(u32::MAX) { + return Err(LegacyMigrationError::InvalidPlan( + "plan step sequence is not contiguous".to_string(), + )); + } + self.adapter(step.domain)?; + } + Ok(()) + } +} + +pub fn compute_plan_hash(plan: &MigrationPlan) -> LegacyMigrationResult { + #[derive(Serialize)] + #[serde(rename_all = "camelCase")] + struct HashInput<'a> { + format_version: u32, + source_fingerprint: &'a str, + selection: &'a MigrationSelection, + steps: &'a [MigrationPlanStep], + findings: &'a [openbitfun_product_domains::legacy_migration::ScanFinding], + conflicts: &'a [MigrationConflict], + estimated_write_bytes: u64, + } + + let input = HashInput { + format_version: plan.format_version, + source_fingerprint: &plan.source_fingerprint, + selection: &plan.selection, + steps: &plan.steps, + findings: &plan.findings, + conflicts: &plan.conflicts, + estimated_write_bytes: plan.estimated_write_bytes, + }; + let bytes = serde_json::to_vec(&input) + .map_err(|error| LegacyMigrationError::json("", error))?; + Ok(format!("sha256:{}", hex::encode(Sha256::digest(bytes)))) +} + +fn persist_or_validate_plan( + layout: &MigrationLayout, + plan: &MigrationPlan, +) -> LegacyMigrationResult<()> { + if let Some(existing) = layout.read_json::(&layout.plan_path())? { + if existing != *plan { + return Err(LegacyMigrationError::InvalidPlan( + "run id already has a different immutable plan".to_string(), + )); + } + return Ok(()); + } + atomic_write_json(&layout.plan_path(), plan) +} + +fn load_or_create_report( + layout: &MigrationLayout, + plan: &MigrationPlan, +) -> LegacyMigrationResult { + if let Some(report) = layout.read_json::(&layout.report_path())? { + if report.run_id != plan.run_id + || report.source_fingerprint != plan.source_fingerprint + || report.plan_hash != plan.plan_hash + { + return Err(LegacyMigrationError::InvalidPlan( + "persisted report does not belong to this plan".to_string(), + )); + } + return Ok(report); + } + let report = MigrationRunReport { + format_version: CURRENT_MIGRATION_FORMAT_VERSION, + run_id: plan.run_id.clone(), + source_fingerprint: plan.source_fingerprint.clone(), + plan_hash: plan.plan_hash.clone(), + status: MigrationRunStatus::Planned, + started_at_ms: now_ms(), + domain_results: plan + .steps + .iter() + .map(|step| MigrationDomainResult { + domain: step.domain, + ..MigrationDomainResult::default() + }) + .collect(), + ..MigrationRunReport::default() + }; + persist_report(layout, &report)?; + persist_release_observation( + layout, + &report, + "migration_planned", + None, + report.started_at_ms, + )?; + Ok(report) +} + +fn normalize_report(plan: &MigrationPlan, report: &mut MigrationRunReport) { + for step in &plan.steps { + if !report + .domain_results + .iter() + .any(|result| result.domain == step.domain) + { + report.domain_results.push(MigrationDomainResult { + domain: step.domain, + ..MigrationDomainResult::default() + }); + } + } + report + .domain_results + .retain(|result| plan.steps.iter().any(|step| step.domain == result.domain)); + report.domain_results.sort_by_key(|result| { + plan.steps + .iter() + .position(|step| step.domain == result.domain) + .unwrap_or(usize::MAX) + }); +} + +fn transition( + layout: &MigrationLayout, + report: &mut MigrationRunReport, + sequence: &mut u64, + status: MigrationRunStatus, + phase: MigrationPhase, + domain: Option, + domain_state: Option, + code: &str, +) -> LegacyMigrationResult<()> { + report.status = status; + persist_report(layout, report)?; + journal(layout, report, sequence, phase, domain, domain_state, code) +} + +fn journal( + layout: &MigrationLayout, + report: &MigrationRunReport, + sequence: &mut u64, + phase: MigrationPhase, + domain: Option, + domain_state: Option, + code: &str, +) -> LegacyMigrationResult<()> { + *sequence = sequence.saturating_add(1); + let event = MigrationJournalEvent { + format_version: CURRENT_MIGRATION_FORMAT_VERSION, + sequence: *sequence, + recorded_at_ms: now_ms(), + run_id: report.run_id.clone(), + status: report.status, + phase, + domain, + domain_state, + code: code.to_string(), + }; + layout.append_journal(&event)?; + let failure_phase = matches!( + report.status, + MigrationRunStatus::FailedRecoverable | MigrationRunStatus::FailedManualActionRequired + ) + .then_some(phase); + persist_release_observation(layout, report, code, failure_phase, event.recorded_at_ms) +} + +fn record_domain_failure( + layout: &MigrationLayout, + report: &mut MigrationRunReport, + sequence: &mut u64, + result_index: usize, + domain: MigrationDomainId, + phase: MigrationPhase, + error: &LegacyMigrationError, +) -> LegacyMigrationResult<()> { + report.status = MigrationRunStatus::FailedRecoverable; + report.domain_results[result_index].state = MigrationDomainState::Failed; + report + .diagnostics + .push(domain_failure_diagnostic(domain, error)); + persist_report(layout, report)?; + journal( + layout, + report, + sequence, + phase, + Some(domain), + Some(MigrationDomainState::Failed), + "domain_failed_recoverable", + ) +} + +fn domain_failure_diagnostic( + domain: MigrationDomainId, + error: &LegacyMigrationError, +) -> MigrationDiagnostic { + let (code, message, action) = match error { + LegacyMigrationError::UnsupportedSource(_) => ( + "domain_source_unsupported", + "A migration source record does not match a supported legacy format.", + "Keep the legacy data unchanged and export diagnostics for review.", + ), + LegacyMigrationError::PathEscape(_) | LegacyMigrationError::LinkedPath(_) => ( + "domain_path_unsafe", + "A migration path failed its safety checks.", + "Review linked or redirected paths before retrying the migration.", + ), + LegacyMigrationError::ResourceLimit(_) => ( + "domain_resource_limit", + "A migration domain exceeded a configured safety limit.", + "Export diagnostics and review the size or shape of the legacy data.", + ), + LegacyMigrationError::Io { source, .. } => io_failure_diagnostic(source), + LegacyMigrationError::Json { .. } => ( + "domain_json_invalid", + "A migration-owned JSON document is unreadable or has an unexpected shape.", + "Keep the legacy data unchanged and export diagnostics for review.", + ), + LegacyMigrationError::Sqlite { .. } => ( + "domain_sqlite_failed", + "A migration-owned SQLite database could not be copied or validated.", + "Close processes using the database and retry the migration.", + ), + _ => ( + "domain_validation_failed", + "A migration-owned record failed validation.", + "Keep the legacy data unchanged and export diagnostics for review.", + ), + }; + MigrationDiagnostic { + code: code.to_string(), + severity: FindingSeverity::Blocking, + domain: Some(domain), + message: message.to_string(), + action: Some(action.to_string()), + ..MigrationDiagnostic::default() + } +} + +fn io_failure_diagnostic(error: &std::io::Error) -> (&'static str, &'static str, &'static str) { + if is_path_too_long(error) { + return ( + "domain_path_too_long", + "A migration-owned path exceeds the platform path limit.", + "Enable long-path support or use a shorter data root before retrying.", + ); + } + if is_storage_full(error) { + return ( + "domain_storage_full", + "The destination volume does not have enough free space.", + "Free destination disk space and retry the migration.", + ); + } + match error.kind() { + std::io::ErrorKind::PermissionDenied => ( + "domain_io_permission_denied", + "A migration-owned file or directory denied access.", + "Close programs using the data, check permissions, and retry.", + ), + std::io::ErrorKind::NotFound => ( + "domain_io_not_found", + "A migration-owned source or staged file was not found.", + "Run a new scan and retry after confirming the legacy data is still present.", + ), + std::io::ErrorKind::AlreadyExists => ( + "domain_io_conflict", + "A migration target changed after the plan was created.", + "Run a new scan and plan before retrying the migration.", + ), + std::io::ErrorKind::InvalidData | std::io::ErrorKind::InvalidInput => ( + "domain_io_invalid_data", + "A migration-owned file could not be processed safely.", + "Keep the legacy data unchanged and export diagnostics for review.", + ), + _ => ( + "domain_io_failed", + "A migration-owned file or directory could not be read or written.", + "Export diagnostics, check the destination storage, and retry.", + ), + } +} + +fn is_path_too_long(error: &std::io::Error) -> bool { + cfg!(windows) && error.raw_os_error() == Some(206) +} + +fn is_storage_full(error: &std::io::Error) -> bool { + match error.raw_os_error() { + Some(112) if cfg!(windows) => true, + Some(28) if cfg!(unix) => true, + _ => false, + } +} + +fn record_cancelled( + layout: &MigrationLayout, + report: &mut MigrationRunReport, + sequence: &mut u64, +) -> LegacyMigrationResult<()> { + transition( + layout, + report, + sequence, + MigrationRunStatus::Cancelled, + MigrationPhase::Stage, + None, + None, + "migration_cancelled", + ) +} + +fn persist_report( + layout: &MigrationLayout, + report: &MigrationRunReport, +) -> LegacyMigrationResult<()> { + atomic_write_json(&layout.report_path(), report) +} + +fn journal_sequence(layout: &MigrationLayout) -> LegacyMigrationResult { + let bytes = match std::fs::read(layout.journal_path()) { + Ok(bytes) => bytes, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(0), + Err(error) => return Err(LegacyMigrationError::io(layout.journal_path(), error)), + }; + Ok(bytes.iter().filter(|byte| **byte == b'\n').count() as u64) +} + +fn emit_progress( + progress: &mut impl FnMut(MigrationProgressEvent), + plan: &MigrationPlan, + step: &MigrationPlanStep, + phase: MigrationPhase, + processed: usize, + safe_to_cancel: bool, + code: &str, +) { + progress(MigrationProgressEvent { + run_id: plan.run_id.clone(), + domain: if phase == MigrationPhase::Finalize { + None + } else { + Some(step.domain) + }, + phase, + processed: processed as u64, + total: plan.steps.len() as u64, + safe_to_cancel, + code: code.to_string(), + }); +} + +fn inject(injector: &dyn CrashInjector, point: CrashPoint) -> LegacyMigrationResult<()> { + if injector.should_crash(point) { + Err(LegacyMigrationError::InjectedCrash(point)) + } else { + Ok(()) + } +} + +fn domain_error(domain: MigrationDomainId, error: LegacyMigrationError) -> LegacyMigrationError { + match error { + LegacyMigrationError::Domain { .. } => error, + error => LegacyMigrationError::Domain { + domain, + message: error.to_string(), + }, + } +} + +fn now_ms() -> i64 { + chrono::Utc::now().timestamp_millis() +} diff --git a/src/crates/services/legacy-migration/src/error.rs b/src/crates/services/legacy-migration/src/error.rs new file mode 100644 index 0000000000..ad189c8bde --- /dev/null +++ b/src/crates/services/legacy-migration/src/error.rs @@ -0,0 +1,82 @@ +use openbitfun_product_domains::legacy_migration::MigrationDomainId; +use std::path::PathBuf; + +pub type LegacyMigrationResult = Result; + +#[derive(Debug, thiserror::Error)] +pub enum LegacyMigrationError { + #[error("legacy migration path is unavailable: {0}")] + PathUnavailable(String), + #[error("legacy source and target resolve to the same path: {0}")] + SourceEqualsTarget(PathBuf), + #[error("legacy source format is unsupported: {0}")] + UnsupportedSource(String), + #[error("legacy migration request is invalid: {0}")] + InvalidRequest(String), + #[error("legacy migration plan is invalid: {0}")] + InvalidPlan(String), + #[error("legacy migration path escaped its declared root: {0}")] + PathEscape(PathBuf), + #[error("legacy migration refused a symbolic link or reparse point: {0}")] + LinkedPath(PathBuf), + #[error("legacy migration resource limit exceeded: {0}")] + ResourceLimit(String), + #[error("legacy migration is already running")] + LockUnavailable, + #[error("legacy migration was cancelled at a safe boundary")] + Cancelled, + #[error("legacy migration process inspection failed: {0}")] + ProcessInspection(String), + #[error("legacy migration executable is not trusted: {0}")] + UntrustedExecutable(PathBuf), + #[error("legacy migration trusted installation is unavailable: {0}")] + TrustedInstallationUnavailable(String), + #[error("legacy migration crash injection at {0:?}")] + InjectedCrash(crate::CrashPoint), + #[error("legacy migration domain {domain:?} failed: {message}")] + Domain { + domain: MigrationDomainId, + message: String, + }, + #[error("legacy migration I/O failed for {path}: {source}")] + Io { + path: PathBuf, + #[source] + source: std::io::Error, + }, + #[error("legacy migration JSON failed for {path}: {source}")] + Json { + path: PathBuf, + #[source] + source: serde_json::Error, + }, + #[error("legacy migration SQLite failed for {path}: {source}")] + Sqlite { + path: PathBuf, + #[source] + source: rusqlite::Error, + }, +} + +impl LegacyMigrationError { + pub(crate) fn io(path: impl Into, source: std::io::Error) -> Self { + Self::Io { + path: path.into(), + source, + } + } + + pub(crate) fn json(path: impl Into, source: serde_json::Error) -> Self { + Self::Json { + path: path.into(), + source, + } + } + + pub(crate) fn sqlite(path: impl Into, source: rusqlite::Error) -> Self { + Self::Sqlite { + path: path.into(), + source, + } + } +} diff --git a/src/crates/services/legacy-migration/src/handoff.rs b/src/crates/services/legacy-migration/src/handoff.rs new file mode 100644 index 0000000000..b717d67f64 --- /dev/null +++ b/src/crates/services/legacy-migration/src/handoff.rs @@ -0,0 +1,1076 @@ +use crate::{ + atomic_write_json, LegacyMigrationError, LegacyMigrationResult, MigrationLayout, MigrationRoots, +}; +use openbitfun_product_domains::legacy_migration::{ + MigrationPlan, MigratorHandoffRequest, MigratorProtocolCapabilities, MigratorRequestMode, +}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::ffi::OsStr; +use std::fs::{self, File, OpenOptions}; +use std::io::Write; +use std::path::{Component, Path, PathBuf}; + +const MAX_HANDOFF_REQUEST_BYTES: u64 = 64 * 1024; +const MAX_HANDOFF_LIFETIME_MS: i64 = 15 * 60 * 1000; +const MAX_CLOCK_SKEW_MS: i64 = 60 * 1000; +const NONCE_RECEIPT_FORMAT_VERSION: u32 = 1; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HandoffDisposition { + Fresh, + Recovery, +} + +#[derive(Debug, Clone)] +pub struct ValidatedHandoff { + request: MigratorHandoffRequest, + layout: MigrationLayout, + disposition: HandoffDisposition, +} + +impl ValidatedHandoff { + pub fn request(&self) -> &MigratorHandoffRequest { + &self.request + } + + pub fn layout(&self) -> &MigrationLayout { + &self.layout + } + + pub const fn disposition(&self) -> HandoffDisposition { + self.disposition + } +} + +#[derive(Debug, Clone)] +pub struct HandoffStore { + roots: MigrationRoots, + expected_product_id: String, + expected_release_channel: String, +} + +impl HandoffStore { + pub fn new( + roots: MigrationRoots, + expected_product_id: impl Into, + expected_release_channel: impl Into, + ) -> Self { + Self { + roots, + expected_product_id: expected_product_id.into(), + expected_release_channel: expected_release_channel.into(), + } + } + + pub fn write_request( + &self, + request: &MigratorHandoffRequest, + now_ms: i64, + ) -> LegacyMigrationResult { + self.validate_request(request, &request.run_id, now_ms)?; + let layout = MigrationLayout::new(&self.roots, &request.run_id); + initialize_private_layout(&layout)?; + ensure_path_chain_is_plain(layout.root(), &layout.request_path())?; + write_new_private_json(&layout.request_path(), request)?; + verify_current_user_owned(&layout.request_path())?; + Ok(layout.request_path()) + } + + pub fn load_request( + &self, + run_id: &str, + now_ms: i64, + ) -> LegacyMigrationResult { + validate_uuid("run id", run_id)?; + let layout = MigrationLayout::new(&self.roots, run_id); + ensure_path_chain_is_plain(layout.root(), &layout.request_path())?; + verify_current_user_owned(&layout.request_path())?; + let request = read_bounded_json::( + &layout.request_path(), + MAX_HANDOFF_REQUEST_BYTES, + )?; + self.validate_request(&request, run_id, now_ms)?; + + let disposition = match read_optional_bounded_json::( + &layout.consumed_nonce_path(), + MAX_HANDOFF_REQUEST_BYTES, + )? { + None => HandoffDisposition::Fresh, + Some(receipt) => { + validate_nonce_receipt(&receipt, &request)?; + let persisted_plan = layout + .read_json::(&layout.plan_path())? + .ok_or_else(|| { + LegacyMigrationError::InvalidRequest( + "handoff nonce was consumed before an immutable plan was stored" + .to_string(), + ) + })?; + validate_plan_binding(&persisted_plan, &request)?; + HandoffDisposition::Recovery + } + }; + + Ok(ValidatedHandoff { + request, + layout, + disposition, + }) + } + + /// Persist the immutable plan before consuming the one-time nonce. + /// + /// This ordering leaves a recoverable plan if the process stops immediately + /// after the nonce receipt is made durable. Reusing a receipt is accepted + /// only for that exact plan and request. + pub fn authorize_plan( + &self, + handoff: &ValidatedHandoff, + plan: &MigrationPlan, + now_ms: i64, + ) -> LegacyMigrationResult { + self.validate_request(&handoff.request, &handoff.request.run_id, now_ms)?; + validate_plan_binding(plan, &handoff.request)?; + if let Some(existing) = handoff + .layout + .read_json::(&handoff.layout.plan_path())? + { + if existing != *plan { + return Err(LegacyMigrationError::InvalidPlan( + "handoff run already has a different immutable plan".to_string(), + )); + } + } else { + atomic_write_json(&handoff.layout.plan_path(), plan)?; + } + + let receipt = ConsumedNonceReceipt::new(&handoff.request, now_ms); + match write_new_private_json(&handoff.layout.consumed_nonce_path(), &receipt) { + Ok(()) => Ok(HandoffDisposition::Fresh), + Err(LegacyMigrationError::Io { source, .. }) + if source.kind() == std::io::ErrorKind::AlreadyExists => + { + let existing = read_bounded_json::( + &handoff.layout.consumed_nonce_path(), + MAX_HANDOFF_REQUEST_BYTES, + )?; + validate_nonce_receipt(&existing, &handoff.request)?; + Ok(HandoffDisposition::Recovery) + } + Err(error) => Err(error), + } + } + + pub fn load_authorized_plan( + &self, + handoff: &ValidatedHandoff, + ) -> LegacyMigrationResult> { + let Some(receipt) = read_optional_bounded_json::( + &handoff.layout.consumed_nonce_path(), + MAX_HANDOFF_REQUEST_BYTES, + )? + else { + return Ok(None); + }; + validate_nonce_receipt(&receipt, &handoff.request)?; + let plan = handoff + .layout + .read_json::(&handoff.layout.plan_path())?; + if let Some(plan) = &plan { + validate_plan_binding(plan, &handoff.request)?; + } + Ok(plan) + } + + fn validate_request( + &self, + request: &MigratorHandoffRequest, + expected_run_id: &str, + now_ms: i64, + ) -> LegacyMigrationResult<()> { + validate_uuid("run id", &request.run_id)?; + validate_uuid("nonce", &request.nonce)?; + if request.run_id != expected_run_id { + return Err(LegacyMigrationError::InvalidRequest( + "handoff request does not match the derived run path".to_string(), + )); + } + if !MigratorProtocolCapabilities::current().accepts_request(request) { + return Err(LegacyMigrationError::InvalidRequest( + "handoff protocol or required capabilities are unsupported".to_string(), + )); + } + if request.product_id != self.expected_product_id { + return Err(LegacyMigrationError::InvalidRequest( + "handoff request belongs to a different product identity".to_string(), + )); + } + if request.release_channel != self.expected_release_channel { + return Err(LegacyMigrationError::InvalidRequest( + "handoff request belongs to a different release channel".to_string(), + )); + } + if request.caller_process_id == 0 { + return Err(LegacyMigrationError::InvalidRequest( + "handoff request has no caller process identity".to_string(), + )); + } + if request.created_at_ms > now_ms.saturating_add(MAX_CLOCK_SKEW_MS) { + return Err(LegacyMigrationError::InvalidRequest( + "handoff request creation time is in the future".to_string(), + )); + } + if request.expires_at_ms <= request.created_at_ms + || request.expires_at_ms.saturating_sub(request.created_at_ms) > MAX_HANDOFF_LIFETIME_MS + { + return Err(LegacyMigrationError::InvalidRequest( + "handoff request lifetime is invalid".to_string(), + )); + } + if request.is_expired_at(now_ms) { + return Err(LegacyMigrationError::InvalidRequest( + "handoff request has expired".to_string(), + )); + } + Ok(()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +struct ConsumedNonceReceipt { + format_version: u32, + run_id: String, + nonce_sha256: String, + consumed_at_ms: i64, +} + +impl Default for ConsumedNonceReceipt { + fn default() -> Self { + Self { + format_version: NONCE_RECEIPT_FORMAT_VERSION, + run_id: String::new(), + nonce_sha256: String::new(), + consumed_at_ms: 0, + } + } +} + +impl ConsumedNonceReceipt { + fn new(request: &MigratorHandoffRequest, consumed_at_ms: i64) -> Self { + Self { + format_version: NONCE_RECEIPT_FORMAT_VERSION, + run_id: request.run_id.clone(), + nonce_sha256: nonce_digest(&request.nonce), + consumed_at_ms, + } + } +} + +fn validate_nonce_receipt( + receipt: &ConsumedNonceReceipt, + request: &MigratorHandoffRequest, +) -> LegacyMigrationResult<()> { + if receipt.format_version != NONCE_RECEIPT_FORMAT_VERSION + || receipt.run_id != request.run_id + || receipt.nonce_sha256 != nonce_digest(&request.nonce) + { + return Err(LegacyMigrationError::InvalidRequest( + "handoff nonce has already been consumed by another request".to_string(), + )); + } + Ok(()) +} + +fn validate_plan_binding( + plan: &MigrationPlan, + request: &MigratorHandoffRequest, +) -> LegacyMigrationResult<()> { + if plan.run_id != request.run_id { + return Err(LegacyMigrationError::InvalidPlan( + "migration plan does not belong to the handoff run".to_string(), + )); + } + if let Some(source_fingerprint) = request.source_fingerprint.as_deref() { + if plan.source_fingerprint != source_fingerprint { + return Err(LegacyMigrationError::InvalidPlan( + "migration plan does not match the requested legacy source".to_string(), + )); + } + } + if request.mode == MigratorRequestMode::Execute && plan.selection != request.selection { + return Err(LegacyMigrationError::InvalidPlan( + "migration plan selection differs from the confirmed handoff selection".to_string(), + )); + } + Ok(()) +} + +fn nonce_digest(nonce: &str) -> String { + format!("sha256:{}", hex::encode(Sha256::digest(nonce.as_bytes()))) +} + +fn validate_uuid(label: &str, value: &str) -> LegacyMigrationResult<()> { + uuid::Uuid::parse_str(value).map_err(|_| { + LegacyMigrationError::InvalidRequest(format!("handoff {label} must be a UUID")) + })?; + Ok(()) +} + +fn initialize_private_layout(layout: &MigrationLayout) -> LegacyMigrationResult<()> { + layout.initialize()?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + for path in [ + layout.root(), + &layout.run_root(), + &layout.stage_root(), + &layout.backup_root(), + ] { + fs::set_permissions(path, fs::Permissions::from_mode(0o700)) + .map_err(|error| LegacyMigrationError::io(path, error))?; + } + } + Ok(()) +} + +fn write_new_private_json(path: &Path, value: &T) -> LegacyMigrationResult<()> { + let mut bytes = serde_json::to_vec_pretty(value) + .map_err(|error| LegacyMigrationError::json(path, error))?; + bytes.push(b'\n'); + let mut options = OpenOptions::new(); + options.create_new(true).write(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let mut file = options + .open(path) + .map_err(|error| LegacyMigrationError::io(path, error))?; + file.write_all(&bytes) + .map_err(|error| LegacyMigrationError::io(path, error))?; + file.sync_all() + .map_err(|error| LegacyMigrationError::io(path, error))?; + if let Some(parent) = path.parent() { + if let Ok(directory) = File::open(parent) { + let _ = directory.sync_all(); + } + } + Ok(()) +} + +fn read_bounded_json( + path: &Path, + max_bytes: u64, +) -> LegacyMigrationResult { + let metadata = + fs::symlink_metadata(path).map_err(|error| LegacyMigrationError::io(path, error))?; + if is_link_or_reparse(&metadata) { + return Err(LegacyMigrationError::LinkedPath(path.to_path_buf())); + } + if metadata.len() > max_bytes { + return Err(LegacyMigrationError::ResourceLimit(format!( + "handoff file exceeds {max_bytes} bytes" + ))); + } + let bytes = fs::read(path).map_err(|error| LegacyMigrationError::io(path, error))?; + serde_json::from_slice(&bytes).map_err(|error| LegacyMigrationError::json(path, error)) +} + +fn read_optional_bounded_json( + path: &Path, + max_bytes: u64, +) -> LegacyMigrationResult> { + match read_bounded_json(path, max_bytes) { + Ok(value) => Ok(Some(value)), + Err(LegacyMigrationError::Io { source, .. }) + if source.kind() == std::io::ErrorKind::NotFound => + { + Ok(None) + } + Err(error) => Err(error), + } +} + +fn ensure_path_chain_is_plain(root: &Path, target: &Path) -> LegacyMigrationResult<()> { + let relative = target + .strip_prefix(root) + .map_err(|_| LegacyMigrationError::PathEscape(target.to_path_buf()))?; + let mut current = root.to_path_buf(); + if let Ok(metadata) = fs::symlink_metadata(¤t) { + if is_link_or_reparse(&metadata) { + return Err(LegacyMigrationError::LinkedPath(current)); + } + } + for component in relative.components() { + let Component::Normal(component) = component else { + return Err(LegacyMigrationError::PathEscape(target.to_path_buf())); + }; + current.push(component); + match fs::symlink_metadata(¤t) { + Ok(metadata) if is_link_or_reparse(&metadata) => { + return Err(LegacyMigrationError::LinkedPath(current)); + } + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(LegacyMigrationError::io(¤t, error)), + } + } + Ok(()) +} + +#[cfg(windows)] +fn verify_current_user_owned(path: &Path) -> LegacyMigrationResult<()> { + use std::ffi::c_void; + use std::os::windows::ffi::OsStrExt; + use windows::core::PCWSTR; + use windows::Win32::Foundation::{CloseHandle, LocalFree, HANDLE, HLOCAL}; + use windows::Win32::Security::Authorization::{GetNamedSecurityInfoW, SE_FILE_OBJECT}; + use windows::Win32::Security::{ + EqualSid, GetTokenInformation, TokenUser, OWNER_SECURITY_INFORMATION, PSECURITY_DESCRIPTOR, + PSID, TOKEN_QUERY, TOKEN_USER, + }; + use windows::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken}; + + struct OwnedHandle(HANDLE); + impl Drop for OwnedHandle { + fn drop(&mut self) { + // SAFETY: this wrapper owns the process token handle exactly once. + unsafe { + let _ = CloseHandle(self.0); + } + } + } + + struct LocalSecurityDescriptor(PSECURITY_DESCRIPTOR); + impl Drop for LocalSecurityDescriptor { + fn drop(&mut self) { + // SAFETY: GetNamedSecurityInfoW allocated this descriptor with + // LocalAlloc-compatible ownership and this wrapper frees it once. + unsafe { + let _ = LocalFree(Some(HLOCAL(self.0 .0))); + } + } + } + + let wide_path = path + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect::>(); + let mut owner = PSID::default(); + let mut descriptor = PSECURITY_DESCRIPTOR::default(); + // SAFETY: every output pointer refers to initialized storage that lives + // through the call; the UTF-16 path is NUL terminated. + let status = unsafe { + GetNamedSecurityInfoW( + PCWSTR(wide_path.as_ptr()), + SE_FILE_OBJECT, + OWNER_SECURITY_INFORMATION, + Some(&mut owner), + None, + None, + None, + &mut descriptor, + ) + }; + if status.0 != 0 || descriptor.0.is_null() || owner.0.is_null() { + return Err(LegacyMigrationError::InvalidRequest( + "handoff file owner could not be verified".to_string(), + )); + } + let _descriptor = LocalSecurityDescriptor(descriptor); + + let mut raw_token = HANDLE::default(); + // SAFETY: `raw_token` is writable and becomes owned by `OwnedHandle` only + // after OpenProcessToken succeeds. + unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut raw_token) } + .map_err(|error| LegacyMigrationError::ProcessInspection(error.to_string()))?; + let token = OwnedHandle(raw_token); + let mut required = 0u32; + // SAFETY: the zero-length probe passes no output buffer and a valid size + // pointer, as required by GetTokenInformation. + let _ = unsafe { GetTokenInformation(token.0, TokenUser, None, 0, &mut required) }; + if required < std::mem::size_of::() as u32 { + return Err(LegacyMigrationError::ProcessInspection( + "current user token identity is unavailable".to_string(), + )); + } + let word_count = (required as usize).div_ceil(std::mem::size_of::()); + let mut buffer = vec![0usize; word_count]; + // SAFETY: `buffer` has exactly the size reported by the preceding probe and + // remains live through both this call and the TOKEN_USER view below. + unsafe { + GetTokenInformation( + token.0, + TokenUser, + Some(buffer.as_mut_ptr() as *mut c_void), + required, + &mut required, + ) + } + .map_err(|error| LegacyMigrationError::ProcessInspection(error.to_string()))?; + // SAFETY: GetTokenInformation successfully initialized a TOKEN_USER at the + // start of the aligned allocator buffer for the duration of this scope. + let token_user = unsafe { &*(buffer.as_ptr() as *const TOKEN_USER) }; + // SAFETY: both SIDs are owned by live security descriptor/token buffers. + if unsafe { EqualSid(owner, token_user.User.Sid) }.is_err() { + return Err(LegacyMigrationError::InvalidRequest( + "handoff file is owned by another OS user".to_string(), + )); + } + Ok(()) +} + +#[cfg(unix)] +fn verify_current_user_owned(path: &Path) -> LegacyMigrationResult<()> { + use std::os::unix::fs::MetadataExt; + let metadata = + fs::symlink_metadata(path).map_err(|error| LegacyMigrationError::io(path, error))?; + // SAFETY: geteuid takes no pointers and has no caller-side preconditions. + if metadata.uid() != unsafe { libc::geteuid() } { + return Err(LegacyMigrationError::InvalidRequest( + "handoff file is owned by another OS user".to_string(), + )); + } + Ok(()) +} + +#[cfg(windows)] +fn is_link_or_reparse(metadata: &fs::Metadata) -> bool { + use std::os::windows::fs::MetadataExt; + const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x00000400; + metadata.file_type().is_symlink() + || metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 +} + +#[cfg(not(windows))] +fn is_link_or_reparse(metadata: &fs::Metadata) -> bool { + metadata.file_type().is_symlink() +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct WriterProcess { + pub process_id: u32, + pub executable_name: String, + pub is_handoff_caller: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ProcessEntry { + process_id: u32, + executable_name: String, +} + +const WRITER_EXECUTABLE_NAMES: &[&str] = &[ + "bitfun.exe", + "bitfun-desktop.exe", + "bitfun", + "bitfun-desktop", + "openbitfun.exe", + "openbitfun-desktop.exe", + "openbitfun-agent-runtime.exe", + "openbitfun", + "openbitfun-desktop", + "openbitfun-agent-runtime", +]; + +pub fn blocking_writer_processes( + caller_process_id: u32, +) -> LegacyMigrationResult> { + blocking_writer_processes_for_product(caller_process_id, &[]) +} + +/// Find legacy and current writers, including product-defined sibling names. +/// +/// The additional names come from the build-time product projection rather +/// than the handoff request, so a request cannot broaden process matching. +pub fn blocking_writer_processes_for_product( + caller_process_id: u32, + product_writer_binary_names: &[&str], +) -> LegacyMigrationResult> { + let entries = platform_process_entries()?; + Ok(classify_writer_processes( + &entries, + caller_process_id, + std::process::id(), + product_writer_binary_names, + )) +} + +fn classify_writer_processes( + entries: &[ProcessEntry], + caller_process_id: u32, + current_process_id: u32, + product_writer_binary_names: &[&str], +) -> Vec { + let mut blockers = entries + .iter() + .filter(|entry| entry.process_id != current_process_id) + .filter_map(|entry| { + let is_handoff_caller = entry.process_id == caller_process_id; + let known_writer = WRITER_EXECUTABLE_NAMES + .iter() + .chain(product_writer_binary_names.iter()) + .any(|name| process_name_matches_binary(&entry.executable_name, name)); + (is_handoff_caller || known_writer).then(|| WriterProcess { + process_id: entry.process_id, + executable_name: entry.executable_name.clone(), + is_handoff_caller, + }) + }) + .collect::>(); + blockers.sort_by_key(|process| process.process_id); + blockers.dedup_by_key(|process| process.process_id); + blockers +} + +fn process_name_matches_binary(process_name: &str, binary_name: &str) -> bool { + process_name.eq_ignore_ascii_case(binary_name) + || process_name.eq_ignore_ascii_case(&platform_binary_filename(binary_name)) +} + +#[cfg(windows)] +fn platform_process_entries() -> LegacyMigrationResult> { + use std::mem::size_of; + use windows::Win32::Foundation::{CloseHandle, HANDLE}; + use windows::Win32::System::Diagnostics::ToolHelp::{ + CreateToolhelp32Snapshot, Process32FirstW, Process32NextW, PROCESSENTRY32W, + TH32CS_SNAPPROCESS, + }; + + struct Snapshot(HANDLE); + impl Drop for Snapshot { + fn drop(&mut self) { + // SAFETY: this wrapper owns the ToolHelp snapshot handle exactly + // once and never exposes it beyond this scope. + unsafe { + let _ = CloseHandle(self.0); + } + } + } + + let snapshot = Snapshot( + // SAFETY: the system call receives a documented snapshot flag and no + // caller-owned pointers. + unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) } + .map_err(|error| LegacyMigrationError::ProcessInspection(error.to_string()))?, + ); + let mut entry = PROCESSENTRY32W { + dwSize: size_of::() as u32, + ..Default::default() + }; + let mut entries = Vec::new(); + // SAFETY: the snapshot is live and `entry.dwSize` identifies the complete + // writable PROCESSENTRY32W buffer. + if unsafe { Process32FirstW(snapshot.0, &mut entry) }.is_err() { + return Ok(entries); + } + loop { + let length = entry + .szExeFile + .iter() + .position(|character| *character == 0) + .unwrap_or(entry.szExeFile.len()); + entries.push(ProcessEntry { + process_id: entry.th32ProcessID, + executable_name: String::from_utf16_lossy(&entry.szExeFile[..length]), + }); + // SAFETY: the same live snapshot and correctly sized entry buffer are + // reused serially until enumeration completes. + if unsafe { Process32NextW(snapshot.0, &mut entry) }.is_err() { + break; + } + } + Ok(entries) +} + +#[cfg(all(unix, not(target_os = "macos")))] +fn platform_process_entries() -> LegacyMigrationResult> { + let mut entries = Vec::new(); + let directory = fs::read_dir("/proc") + .map_err(|error| LegacyMigrationError::ProcessInspection(error.to_string()))?; + for entry in directory.flatten() { + let Some(process_id) = entry.file_name().to_string_lossy().parse::().ok() else { + continue; + }; + let executable_name = fs::read_to_string(entry.path().join("comm")) + .unwrap_or_default() + .trim() + .to_string(); + entries.push(ProcessEntry { + process_id, + executable_name, + }); + } + Ok(entries) +} + +#[cfg(target_os = "macos")] +fn platform_process_entries() -> LegacyMigrationResult> { + Err(LegacyMigrationError::ProcessInspection( + "process inventory is not implemented for macOS".to_string(), + )) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TrustedExecutable { + current_executable: PathBuf, + target_executable: PathBuf, +} + +impl TrustedExecutable { + pub fn current_executable(&self) -> &Path { + &self.current_executable + } + + pub fn target_executable(&self) -> &Path { + &self.target_executable + } +} + +#[derive(Debug, Clone, Copy, Default)] +pub struct TrustedInstallationResolver; + +impl TrustedInstallationResolver { + pub fn resolve_sibling( + current_executable: &Path, + expected_current_binary_name: &str, + expected_target_binary_name: &str, + ) -> LegacyMigrationResult { + validate_binary_name(expected_current_binary_name)?; + validate_binary_name(expected_target_binary_name)?; + let current = fs::canonicalize(current_executable) + .map_err(|error| LegacyMigrationError::io(current_executable, error))?; + let current_name = current.file_name().and_then(OsStr::to_str).ok_or_else(|| { + LegacyMigrationError::TrustedInstallationUnavailable( + "current executable has no Unicode file name".to_string(), + ) + })?; + if !names_equal( + current_name, + &platform_binary_filename(expected_current_binary_name), + ) { + return Err(LegacyMigrationError::UntrustedExecutable(current)); + } + reject_linked_executable(¤t)?; + let install_root = current.parent().ok_or_else(|| { + LegacyMigrationError::TrustedInstallationUnavailable( + "current executable has no installation directory".to_string(), + ) + })?; + let target_path = install_root.join(platform_binary_filename(expected_target_binary_name)); + reject_linked_executable(&target_path)?; + let target = fs::canonicalize(&target_path) + .map_err(|error| LegacyMigrationError::io(&target_path, error))?; + if target.parent() != Some(install_root) { + return Err(LegacyMigrationError::UntrustedExecutable(target)); + } + Ok(TrustedExecutable { + current_executable: current, + target_executable: target, + }) + } +} + +pub fn launch_trusted_executable( + executable: &TrustedExecutable, + arguments: &[&OsStr], +) -> LegacyMigrationResult { + let initial_spawn = openbitfun_services_core::process_manager::create_detached_command( + executable.target_executable(), + ) + .args(arguments) + .spawn(); + + #[cfg(windows)] + let child = match initial_spawn { + Ok(child) => child, + Err(error) if allow_inherited_job_dev_retry(&error) => { + openbitfun_services_core::process_manager::create_inherited_job_process_group_command( + executable.target_executable(), + ) + .args(arguments) + .spawn() + .map_err(|retry_error| { + LegacyMigrationError::io(executable.target_executable(), retry_error) + })? + } + Err(error) => { + return Err(LegacyMigrationError::io( + executable.target_executable(), + error, + )); + } + }; + + #[cfg(not(windows))] + let child = initial_spawn + .map_err(|error| LegacyMigrationError::io(executable.target_executable(), error))?; + + Ok(child.id()) +} + +#[cfg(windows)] +fn allow_inherited_job_dev_retry(error: &std::io::Error) -> bool { + should_retry_without_job_breakaway(cfg!(debug_assertions), error.kind()) +} + +#[cfg(any(windows, test))] +fn should_retry_without_job_breakaway( + debug_assertions: bool, + error_kind: std::io::ErrorKind, +) -> bool { + debug_assertions && error_kind == std::io::ErrorKind::PermissionDenied +} + +fn validate_binary_name(name: &str) -> LegacyMigrationResult<()> { + if name.is_empty() + || Path::new(name).components().count() != 1 + || matches!(name, "." | "..") + || name.contains(['/', '\\', '\0']) + { + return Err(LegacyMigrationError::TrustedInstallationUnavailable( + "trusted executable name is invalid".to_string(), + )); + } + Ok(()) +} + +#[cfg(windows)] +fn platform_binary_filename(name: &str) -> String { + if name.to_ascii_lowercase().ends_with(".exe") { + name.to_string() + } else { + format!("{name}.exe") + } +} + +#[cfg(not(windows))] +fn platform_binary_filename(name: &str) -> String { + name.to_string() +} + +#[cfg(windows)] +fn names_equal(actual: &str, expected: &str) -> bool { + actual.eq_ignore_ascii_case(expected) +} + +#[cfg(not(windows))] +fn names_equal(actual: &str, expected: &str) -> bool { + actual == expected +} + +fn reject_linked_executable(path: &Path) -> LegacyMigrationResult<()> { + let metadata = + fs::symlink_metadata(path).map_err(|error| LegacyMigrationError::io(path, error))?; + if !metadata.is_file() || is_link_or_reparse(&metadata) { + return Err(LegacyMigrationError::UntrustedExecutable( + path.to_path_buf(), + )); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use openbitfun_product_domains::legacy_migration::{ + MigrationSelection, MigratorProtocolCapability, MigratorRequestOrigin, + CURRENT_MIGRATION_FORMAT_VERSION, CURRENT_MIGRATOR_PROTOCOL_VERSION, + }; + use std::collections::BTreeSet; + + fn roots(root: &Path) -> MigrationRoots { + 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/skills"), + target_ssh_root: root.join("target/ssh"), + } + } + + fn request(now_ms: i64) -> MigratorHandoffRequest { + MigratorHandoffRequest { + protocol_version: CURRENT_MIGRATOR_PROTOCOL_VERSION, + mode: MigratorRequestMode::Execute, + origin: MigratorRequestOrigin::Settings, + run_id: uuid::Uuid::new_v4().to_string(), + nonce: uuid::Uuid::new_v4().to_string(), + selection: MigrationSelection::all(), + caller_process_id: 42, + product_id: "openbitfun".to_string(), + release_channel: "stable".to_string(), + created_at_ms: now_ms, + expires_at_ms: now_ms + 60_000, + required_capabilities: BTreeSet::from([ + MigratorProtocolCapability::OfflineExecute, + MigratorProtocolCapability::JournalRecovery, + ]), + ..MigratorHandoffRequest::default() + } + } + + fn plan(request: &MigratorHandoffRequest) -> MigrationPlan { + MigrationPlan { + format_version: CURRENT_MIGRATION_FORMAT_VERSION, + run_id: request.run_id.clone(), + source_fingerprint: "sha256:fixture".to_string(), + selection: request.selection.clone(), + plan_hash: "sha256:plan".to_string(), + ..MigrationPlan::default() + } + } + + #[test] + fn handoff_nonce_can_only_resume_the_same_persisted_plan() { + let temporary = tempfile::tempdir().expect("temporary directory"); + let roots = roots(temporary.path()); + let store = HandoffStore::new(roots, "openbitfun", "stable"); + let request = request(1_000); + store.write_request(&request, 1_000).expect("write request"); + let handoff = store + .load_request(&request.run_id, 1_001) + .expect("load request"); + assert_eq!(handoff.disposition(), HandoffDisposition::Fresh); + let plan = plan(&request); + assert_eq!( + store + .authorize_plan(&handoff, &plan, 1_002) + .expect("consume nonce"), + HandoffDisposition::Fresh + ); + + let recovered = store + .load_request(&request.run_id, 1_003) + .expect("load recovery request"); + assert_eq!(recovered.disposition(), HandoffDisposition::Recovery); + assert_eq!( + store + .authorize_plan(&recovered, &plan, 1_004) + .expect("resume exact plan"), + HandoffDisposition::Recovery + ); + + let mut different = plan.clone(); + different.plan_hash = "sha256:different".to_string(); + assert!(store.authorize_plan(&recovered, &different, 1_005).is_err()); + } + + #[test] + fn handoff_rejects_wrong_product_channel_and_expiry() { + let temporary = tempfile::tempdir().expect("temporary directory"); + let store = HandoffStore::new(roots(temporary.path()), "openbitfun", "stable"); + let mut wrong_product = request(1_000); + wrong_product.product_id = "other".to_string(); + assert!(store.write_request(&wrong_product, 1_000).is_err()); + + let mut wrong_channel = request(1_000); + wrong_channel.release_channel = "nightly".to_string(); + assert!(store.write_request(&wrong_channel, 1_000).is_err()); + + let expired = request(1_000); + assert!(store.write_request(&expired, 100_000).is_err()); + } + + #[test] + fn inherited_job_retry_requires_debug_and_permission_denied() { + use std::io::ErrorKind; + + assert!(should_retry_without_job_breakaway( + true, + ErrorKind::PermissionDenied + )); + assert!(!should_retry_without_job_breakaway( + false, + ErrorKind::PermissionDenied + )); + assert!(!should_retry_without_job_breakaway( + true, + ErrorKind::NotFound + )); + } + + #[test] + fn process_classifier_keeps_caller_and_known_writers_only() { + let processes = vec![ + ProcessEntry { + process_id: 10, + executable_name: "renamed-caller.exe".to_string(), + }, + ProcessEntry { + process_id: 11, + executable_name: "bitfun-desktop.exe".to_string(), + }, + ProcessEntry { + process_id: 12, + executable_name: "unrelated.exe".to_string(), + }, + ProcessEntry { + process_id: 13, + executable_name: "openbitfun-data-migrator.exe".to_string(), + }, + ]; + let blockers = classify_writer_processes(&processes, 10, 13, &[]); + assert_eq!( + blockers + .iter() + .map(|process| process.process_id) + .collect::>(), + vec![10, 11] + ); + assert!(blockers[0].is_handoff_caller); + } + + #[test] + fn process_classifier_includes_product_projected_writer_names() { + let processes = vec![ProcessEntry { + process_id: 21, + executable_name: platform_binary_filename("acme-desktop"), + }]; + + let blockers = classify_writer_processes(&processes, 99, 100, &["acme-desktop"]); + + assert_eq!(blockers.len(), 1); + assert_eq!(blockers[0].process_id, 21); + assert!(!blockers[0].is_handoff_caller); + } + + #[test] + fn trusted_resolver_never_accepts_a_request_supplied_target_path() { + let temporary = tempfile::tempdir().expect("temporary directory"); + let current = temporary + .path() + .join(platform_binary_filename("openbitfun-data-migrator")); + let target = temporary + .path() + .join(platform_binary_filename("openbitfun-desktop")); + fs::write(¤t, b"migrator").expect("write current executable"); + fs::write(&target, b"desktop").expect("write target executable"); + + let resolved = TrustedInstallationResolver::resolve_sibling( + ¤t, + "openbitfun-data-migrator", + "openbitfun-desktop", + ) + .expect("resolve trusted sibling"); + assert_eq!( + resolved.target_executable(), + fs::canonicalize(target).unwrap() + ); + assert!(TrustedInstallationResolver::resolve_sibling( + ¤t, + "openbitfun-data-migrator", + "../attacker", + ) + .is_err()); + } +} diff --git a/src/crates/services/legacy-migration/src/lib.rs b/src/crates/services/legacy-migration/src/lib.rs new file mode 100644 index 0000000000..0a319fc003 --- /dev/null +++ b/src/crates/services/legacy-migration/src/lib.rs @@ -0,0 +1,28 @@ +//! Offline, source-read-only import primitives for legacy BitFun data. + +mod diagnostics; +mod engine; +mod error; +mod handoff; +mod onboarding; +mod paths; +mod probe; +mod sqlite; +mod storage; + +pub use diagnostics::{export_failure_diagnostics, release_observation}; +pub use engine::{ + compute_plan_hash, CancellationToken, CrashInjector, CrashPoint, DomainContext, DomainScan, + LegacyDomainAdapter, MigrationEngine, NoCrashInjection, +}; +pub use error::{LegacyMigrationError, LegacyMigrationResult}; +pub use handoff::{ + blocking_writer_processes, blocking_writer_processes_for_product, launch_trusted_executable, + HandoffDisposition, HandoffStore, TrustedExecutable, TrustedInstallationResolver, + ValidatedHandoff, WriterProcess, +}; +pub use onboarding::MigrationOnboardingStore; +pub use paths::{MigrationRoots, LEGACY_PRODUCT_ID}; +pub use probe::{probe_legacy_source, ProbeLimits}; +pub use sqlite::{snapshot_sqlite_read_only, validate_sqlite}; +pub use storage::{atomic_write_bytes, atomic_write_json, MigrationLayout, MigrationLock}; diff --git a/src/crates/services/legacy-migration/src/onboarding.rs b/src/crates/services/legacy-migration/src/onboarding.rs new file mode 100644 index 0000000000..11c4fd26c7 --- /dev/null +++ b/src/crates/services/legacy-migration/src/onboarding.rs @@ -0,0 +1,283 @@ +use crate::{ + atomic_write_json, LegacyMigrationError, LegacyMigrationResult, MigrationLayout, MigrationRoots, +}; +use openbitfun_product_domains::legacy_migration::{MigrationOnboardingState, MigrationRunReport}; +use serde_json::{Map, Value}; +use std::fs; +use std::io::Read; +use std::path::{Component, Path, PathBuf}; + +const MAX_ONBOARDING_STATE_BYTES: u64 = 64 * 1024; +const MAX_MIGRATION_REPORT_BYTES: u64 = 8 * 1024 * 1024; + +/// Small persisted state that can be read before normal product storage opens. +/// +/// The store intentionally retains unknown JSON fields when current code updates +/// known fields. This allows an older Desktop or Data Migrator to coexist with a +/// newer additive state shape during upgrades. +#[derive(Debug, Clone)] +pub struct MigrationOnboardingStore { + roots: MigrationRoots, +} + +impl MigrationOnboardingStore { + pub fn new(roots: MigrationRoots) -> Self { + Self { roots } + } + + pub fn path(&self) -> PathBuf { + self.roots.migration_root().join("onboarding.json") + } + + pub fn load(&self) -> LegacyMigrationResult { + let (_, state) = self.load_document()?; + Ok(state) + } + + pub fn update( + &self, + update: impl FnOnce(&mut MigrationOnboardingState), + ) -> LegacyMigrationResult { + let (mut document, mut state) = self.load_document()?; + update(&mut state); + let known = serde_json::to_value(&state) + .map_err(|error| LegacyMigrationError::json(self.path(), error))?; + let known = known.as_object().ok_or_else(|| { + LegacyMigrationError::InvalidRequest( + "migration onboarding state did not serialize as an object".to_string(), + ) + })?; + document.extend(known.clone()); + atomic_write_json(&self.path(), &Value::Object(document))?; + Ok(state) + } + + /// Consume a restart acknowledgement exactly once. + /// + /// A stale, malformed, or unrelated command-line value never suppresses the + /// legacy probe. The matching id is cleared durably before startup proceeds. + pub fn consume_handled_run_id(&self, run_id: &str) -> LegacyMigrationResult { + if uuid::Uuid::parse_str(run_id).is_err() { + return Ok(false); + } + let current = self.load()?; + if current.handled_run_id.as_deref() != Some(run_id) { + return Ok(false); + } + self.update(|state| state.handled_run_id = None)?; + Ok(true) + } + + pub fn load_report(&self, run_id: &str) -> LegacyMigrationResult> { + if uuid::Uuid::parse_str(run_id).is_err() { + return Err(LegacyMigrationError::InvalidRequest( + "migration report run id must be a UUID".to_string(), + )); + } + let layout = MigrationLayout::new(&self.roots, run_id); + let path = layout.report_path(); + ensure_path_chain_is_plain(layout.root(), &path)?; + let file = match fs::File::open(&path) { + Ok(file) => file, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(LegacyMigrationError::io(&path, error)), + }; + let mut bytes = Vec::new(); + file.take(MAX_MIGRATION_REPORT_BYTES.saturating_add(1)) + .read_to_end(&mut bytes) + .map_err(|error| LegacyMigrationError::io(&path, error))?; + if bytes.len() as u64 > MAX_MIGRATION_REPORT_BYTES { + return Err(LegacyMigrationError::ResourceLimit( + "migration report exceeds the size limit".to_string(), + )); + } + serde_json::from_slice(&bytes) + .map(Some) + .map_err(|error| LegacyMigrationError::json(&path, error)) + } + + pub fn load_last_report(&self) -> LegacyMigrationResult> { + let state = self.load()?; + let Some(run_id) = state.last_report_run_id.as_deref() else { + return Ok(None); + }; + self.load_report(run_id) + } + + fn load_document( + &self, + ) -> LegacyMigrationResult<(Map, MigrationOnboardingState)> { + let path = self.path(); + ensure_path_chain_is_plain(self.roots.migration_root().as_path(), &path)?; + let metadata = match fs::symlink_metadata(&path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Ok((Map::new(), MigrationOnboardingState::default())); + } + Err(error) => return Err(LegacyMigrationError::io(&path, error)), + }; + if is_link_or_reparse(&metadata) { + return Err(LegacyMigrationError::LinkedPath(path)); + } + if metadata.len() > MAX_ONBOARDING_STATE_BYTES { + return Err(LegacyMigrationError::ResourceLimit( + "migration onboarding state exceeds the size limit".to_string(), + )); + } + let bytes = fs::read(&path).map_err(|error| LegacyMigrationError::io(&path, error))?; + let value: Value = serde_json::from_slice(&bytes) + .map_err(|error| LegacyMigrationError::json(&path, error))?; + let document = value.as_object().cloned().ok_or_else(|| { + LegacyMigrationError::InvalidRequest( + "migration onboarding state must be a JSON object".to_string(), + ) + })?; + let state = serde_json::from_value(Value::Object(document.clone())) + .map_err(|error| LegacyMigrationError::json(&path, error))?; + Ok((document, state)) + } +} + +fn ensure_path_chain_is_plain(root: &Path, target: &Path) -> LegacyMigrationResult<()> { + let relative = target + .strip_prefix(root) + .map_err(|_| LegacyMigrationError::PathEscape(target.to_path_buf()))?; + let mut current = root.to_path_buf(); + if let Ok(metadata) = fs::symlink_metadata(¤t) { + if is_link_or_reparse(&metadata) { + return Err(LegacyMigrationError::LinkedPath(current)); + } + } + for component in relative.components() { + let Component::Normal(component) = component else { + return Err(LegacyMigrationError::PathEscape(target.to_path_buf())); + }; + current.push(component); + match fs::symlink_metadata(¤t) { + Ok(metadata) if is_link_or_reparse(&metadata) => { + return Err(LegacyMigrationError::LinkedPath(current)); + } + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(LegacyMigrationError::io(¤t, error)), + } + } + Ok(()) +} + +#[cfg(windows)] +fn is_link_or_reparse(metadata: &fs::Metadata) -> bool { + use std::os::windows::fs::MetadataExt; + const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400; + metadata.file_type().is_symlink() + || metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 +} + +#[cfg(not(windows))] +fn is_link_or_reparse(metadata: &fs::Metadata) -> bool { + metadata.file_type().is_symlink() +} + +#[cfg(test)] +mod tests { + use super::*; + use openbitfun_product_domains::legacy_migration::{MigrationPromptChoice, MigrationRunStatus}; + + fn roots(root: &Path) -> MigrationRoots { + 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/skills"), + target_ssh_root: root.join("target/ssh"), + } + } + + #[test] + fn updates_preserve_unknown_future_fields() { + let temporary = tempfile::tempdir().unwrap(); + let store = MigrationOnboardingStore::new(roots(temporary.path())); + fs::create_dir_all(store.path().parent().unwrap()).unwrap(); + fs::write(&store.path(), br#"{"futureField":{"keep":true}}"#).unwrap(); + + store + .update(|state| { + state.choice = MigrationPromptChoice::RemindLater; + state.run_id = Some("run-id".to_string()); + }) + .unwrap(); + + let value: Value = serde_json::from_slice(&fs::read(store.path()).unwrap()).unwrap(); + assert_eq!(value["futureField"]["keep"], true); + assert_eq!(value["choice"], "remind_later"); + assert_eq!(value["runId"], "run-id"); + } + + #[test] + fn restart_acknowledgement_is_consumed_only_once() { + let temporary = tempfile::tempdir().unwrap(); + let store = MigrationOnboardingStore::new(roots(temporary.path())); + let run_id = uuid::Uuid::new_v4().to_string(); + store + .update(|state| state.handled_run_id = Some(run_id.clone())) + .unwrap(); + + assert!(store.consume_handled_run_id(&run_id).unwrap()); + assert!(!store.consume_handled_run_id(&run_id).unwrap()); + assert!(!store.consume_handled_run_id("not-a-uuid").unwrap()); + } + + #[test] + fn last_report_uses_a_distinct_persisted_run_reference() { + let temporary = tempfile::tempdir().unwrap(); + let roots = roots(temporary.path()); + let store = MigrationOnboardingStore::new(roots.clone()); + let report_run_id = uuid::Uuid::new_v4().to_string(); + let current_run_id = uuid::Uuid::new_v4().to_string(); + let layout = MigrationLayout::new(&roots, &report_run_id); + layout.initialize().unwrap(); + atomic_write_json( + &layout.report_path(), + &MigrationRunReport { + run_id: report_run_id.clone(), + status: MigrationRunStatus::Completed, + ..MigrationRunReport::default() + }, + ) + .unwrap(); + store + .update(|state| { + state.run_id = Some(current_run_id); + state.last_report_run_id = Some(report_run_id.clone()); + }) + .unwrap(); + + assert_eq!( + store.load_last_report().unwrap().unwrap().run_id, + report_run_id + ); + } + + #[test] + fn oversized_report_is_rejected_before_deserialization() { + let temporary = tempfile::tempdir().unwrap(); + let roots = roots(temporary.path()); + let store = MigrationOnboardingStore::new(roots.clone()); + let run_id = uuid::Uuid::new_v4().to_string(); + let layout = MigrationLayout::new(&roots, &run_id); + layout.initialize().unwrap(); + fs::write( + layout.report_path(), + vec![b' '; MAX_MIGRATION_REPORT_BYTES as usize + 1], + ) + .unwrap(); + + assert!(matches!( + store.load_report(&run_id), + Err(LegacyMigrationError::ResourceLimit(_)) + )); + } +} diff --git a/src/crates/services/legacy-migration/src/paths.rs b/src/crates/services/legacy-migration/src/paths.rs new file mode 100644 index 0000000000..a180a875ac --- /dev/null +++ b/src/crates/services/legacy-migration/src/paths.rs @@ -0,0 +1,121 @@ +use crate::{LegacyMigrationError, LegacyMigrationResult}; +use openbitfun_services_core::product_identity::{data_namespace, hidden_data_directory}; +use std::env; +use std::path::{Path, PathBuf}; + +pub const LEGACY_PRODUCT_ID: &str = "bitfun"; +const LEGACY_HIDDEN_DATA_DIRECTORY: &str = ".bitfun"; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MigrationRoots { + pub legacy_user_root: PathBuf, + pub legacy_home_root: PathBuf, + pub legacy_skills_root: PathBuf, + pub legacy_ssh_root: PathBuf, + pub target_user_root: PathBuf, + pub target_home_root: PathBuf, + pub target_skills_root: PathBuf, + pub target_ssh_root: PathBuf, +} + +impl MigrationRoots { + pub fn resolve_current_user() -> LegacyMigrationResult { + let config_root = dirs::config_dir().ok_or_else(|| { + LegacyMigrationError::PathUnavailable("platform config directory".to_string()) + })?; + let home = dirs::home_dir().ok_or_else(|| { + LegacyMigrationError::PathUnavailable("current user home directory".to_string()) + })?; + let data_root = dirs::data_dir().ok_or_else(|| { + LegacyMigrationError::PathUnavailable("platform data directory".to_string()) + })?; + let local_data_root = dirs::data_local_dir().ok_or_else(|| { + LegacyMigrationError::PathUnavailable("platform local data directory".to_string()) + })?; + + let legacy_user_root = env_path("BITFUN_USER_ROOT") + .or_else(|| env_path("BITFUN_E2E_USER_ROOT")) + .unwrap_or_else(|| config_root.join(LEGACY_PRODUCT_ID)); + let legacy_home_root = env_path("BITFUN_HOME") + .or_else(|| env_path("BITFUN_E2E_HOME")) + .unwrap_or_else(|| home.join(LEGACY_HIDDEN_DATA_DIRECTORY)); + let target_user_root = env_path("OPENBITFUN_USER_ROOT") + .or_else(|| env_path("OPENBITFUN_E2E_USER_ROOT")) + .unwrap_or_else(|| config_root.join(data_namespace())); + let target_home_root = env_path("OPENBITFUN_HOME") + .or_else(|| env_path("OPENBITFUN_E2E_HOME")) + .unwrap_or_else(|| home.join(hidden_data_directory())); + + let legacy_skills_root = platform_skills_root(&data_root, &local_data_root, "BitFun"); + let target_skills_root = + platform_skills_root(&data_root, &local_data_root, data_namespace()); + let legacy_ssh_root = local_data_root.join("BitFun").join("ssh"); + let target_ssh_root = local_data_root.join("OpenBitFun").join("ssh"); + + let roots = Self { + legacy_user_root, + legacy_home_root, + legacy_skills_root, + legacy_ssh_root, + target_user_root, + target_home_root, + target_skills_root, + target_ssh_root, + }; + roots.validate_distinct()?; + Ok(roots) + } + + pub fn migration_root(&self) -> PathBuf { + self.target_user_root + .join("data") + .join("migrations") + .join("bitfun-to-openbitfun") + } + + pub fn validate_distinct(&self) -> LegacyMigrationResult<()> { + for (source, target) in [ + (&self.legacy_user_root, &self.target_user_root), + (&self.legacy_home_root, &self.target_home_root), + (&self.legacy_skills_root, &self.target_skills_root), + (&self.legacy_ssh_root, &self.target_ssh_root), + ] { + if paths_equivalent(source, target) { + return Err(LegacyMigrationError::SourceEqualsTarget(source.clone())); + } + } + Ok(()) + } +} + +fn env_path(name: &str) -> Option { + env::var_os(name) + .map(PathBuf::from) + .filter(|path| !path.as_os_str().is_empty()) +} + +fn platform_skills_root(data_root: &Path, local_data_root: &Path, namespace: &str) -> PathBuf { + if cfg!(target_os = "windows") { + data_root.join(namespace).join("skills") + } else if cfg!(target_os = "macos") { + dirs::home_dir() + .unwrap_or_else(|| data_root.to_path_buf()) + .join("Library") + .join("Application Support") + .join(namespace) + .join("skills") + } else { + local_data_root.join(namespace).join("skills") + } +} + +fn paths_equivalent(left: &Path, right: &Path) -> bool { + let left = std::fs::canonicalize(left).unwrap_or_else(|_| left.to_path_buf()); + let right = std::fs::canonicalize(right).unwrap_or_else(|_| right.to_path_buf()); + if cfg!(windows) { + left.to_string_lossy() + .eq_ignore_ascii_case(&right.to_string_lossy()) + } else { + left == right + } +} diff --git a/src/crates/services/legacy-migration/src/probe.rs b/src/crates/services/legacy-migration/src/probe.rs new file mode 100644 index 0000000000..f0d76b3c43 --- /dev/null +++ b/src/crates/services/legacy-migration/src/probe.rs @@ -0,0 +1,310 @@ +use crate::{ + LegacyMigrationError, LegacyMigrationResult, MigrationOnboardingStore, MigrationRoots, + LEGACY_PRODUCT_ID, +}; +use openbitfun_product_domains::legacy_migration::{ + FindingSeverity, LegacyRootDescriptor, LegacyRootKind, LegacySourceDescriptor, + MigrationDiagnostic, MigrationPromptChoice, MigrationRunStatus, +}; +use semver::{Version, VersionReq}; +use serde_json::Value; +use sha2::{Digest, Sha256}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::UNIX_EPOCH; + +const SUPPORTED_VERSION_REQ: &str = ">=0.2.0,<1.0.0"; + +#[derive(Debug, Clone, Copy)] +pub struct ProbeLimits { + pub max_entries: usize, + pub max_config_bytes: u64, +} + +impl Default for ProbeLimits { + fn default() -> Self { + Self { + max_entries: 4096, + max_config_bytes: 1024 * 1024, + } + } +} + +pub fn probe_legacy_source( + roots: &MigrationRoots, + limits: ProbeLimits, +) -> LegacyMigrationResult> { + roots.validate_distinct()?; + let version = read_source_version(&roots.legacy_user_root, limits.max_config_bytes)?; + let markers = supported_markers(roots)?; + if markers.iter().all(|marker| !marker.present) { + return Ok(None); + } + + let supported = version + .as_deref() + .and_then(|value| Version::parse(value).ok()) + .is_some_and(|value| { + VersionReq::parse(SUPPORTED_VERSION_REQ) + .expect("static version requirement is valid") + .matches(&value) + }); + + let mut hasher = Sha256::new(); + hasher.update(LEGACY_PRODUCT_ID.as_bytes()); + hasher.update(version.as_deref().unwrap_or("unknown").as_bytes()); + let mut approximate_bytes = 0u64; + let mut entries = 0usize; + for root in [ + &roots.legacy_user_root, + &roots.legacy_home_root, + &roots.legacy_skills_root, + &roots.legacy_ssh_root, + ] { + hash_path_fact(&mut hasher, root, root)?; + let summary = bounded_tree_summary(root, limits.max_entries.saturating_sub(entries))?; + approximate_bytes = approximate_bytes.saturating_add(summary.bytes); + entries = entries.saturating_add(summary.entries); + } + for marker in &markers { + hasher.update(marker.name.as_bytes()); + hasher.update([u8::from(marker.present)]); + } + let fingerprint = format!("sha256:{}", hex::encode(hasher.finalize())); + let source_id = format!("bitfun-{}", &fingerprint[7..23]); + let already_migrated = source_was_migrated(roots, &fingerprint)?; + let mut diagnostics = Vec::new(); + if version.is_none() { + diagnostics.push(MigrationDiagnostic { + code: "source_version_missing".to_string(), + severity: FindingSeverity::Blocking, + message: "Legacy BitFun version could not be identified".to_string(), + action: Some( + "Keep the source unchanged and use a supported BitFun profile".to_string(), + ), + ..MigrationDiagnostic::default() + }); + } else if !supported { + diagnostics.push(MigrationDiagnostic { + code: "source_version_unsupported".to_string(), + severity: FindingSeverity::Blocking, + message: format!("Legacy BitFun version is outside {SUPPORTED_VERSION_REQ}"), + action: Some("Use a migrator that supports this source version".to_string()), + ..MigrationDiagnostic::default() + }); + } + if entries >= limits.max_entries { + diagnostics.push(MigrationDiagnostic { + code: "probe_entry_limit_reached".to_string(), + severity: FindingSeverity::Warning, + message: "Legacy source is larger than the lightweight probe budget".to_string(), + action: Some("Run the full read-only scan in Data Migrator".to_string()), + ..MigrationDiagnostic::default() + }); + } + + Ok(Some(LegacySourceDescriptor { + source_id, + source_fingerprint: fingerprint, + product_id: LEGACY_PRODUCT_ID.to_string(), + product_version: version.unwrap_or_else(|| "unknown".to_string()), + platform: std::env::consts::OS.to_string(), + roots: vec![ + root_descriptor(LegacyRootKind::ProductData, &roots.legacy_user_root), + root_descriptor(LegacyRootKind::ProductHome, &roots.legacy_home_root), + root_descriptor(LegacyRootKind::RemoteSsh, &roots.legacy_ssh_root), + ], + readable: true, + supported, + approximate_bytes, + already_migrated, + diagnostics, + })) +} + +#[derive(Debug)] +struct MarkerFact { + name: &'static str, + present: bool, +} + +fn supported_markers(roots: &MigrationRoots) -> LegacyMigrationResult> { + Ok(vec![ + marker("settings", roots.legacy_user_root.join("config/app.json")), + marker("agents", roots.legacy_user_root.join("agents")), + MarkerFact { + name: "skills", + present: directory_has_user_content(&roots.legacy_skills_root, &[".system"])?, + }, + marker("miniapps", roots.legacy_user_root.join("data/miniapps")), + marker( + "workspaces", + roots.legacy_user_root.join("data/workspace_data.json"), + ), + marker("sessions", roots.legacy_home_root.join("projects")), + marker( + "coordination", + roots + .legacy_user_root + .join("data/agent-runtime/coordination.sqlite"), + ), + marker( + "structured_memory", + roots.legacy_user_root.join("data/memories/memories.sqlite"), + ), + marker("file_memory", roots.legacy_home_root.join("memories")), + marker( + "remote_connect", + roots + .legacy_home_root + .join("remote_connect_persistence.json"), + ), + marker( + "remote_ssh", + roots.legacy_ssh_root.join("ssh_connections.json"), + ), + ]) +} + +fn marker(name: &'static str, path: PathBuf) -> MarkerFact { + MarkerFact { + name, + present: path.exists(), + } +} + +fn directory_has_user_content(path: &Path, excluded_names: &[&str]) -> LegacyMigrationResult { + let entries = match fs::read_dir(path) { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false), + Err(error) => return Err(LegacyMigrationError::io(path, error)), + }; + for entry in entries { + let entry = entry.map_err(|error| LegacyMigrationError::io(path, error))?; + if !excluded_names.iter().any(|name| { + entry + .file_name() + .to_string_lossy() + .eq_ignore_ascii_case(name) + }) { + return Ok(true); + } + } + Ok(false) +} + +fn read_source_version(root: &Path, max_bytes: u64) -> LegacyMigrationResult> { + let path = root.join("config/app.json"); + let metadata = match fs::metadata(&path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(LegacyMigrationError::io(&path, error)), + }; + if metadata.len() > max_bytes { + return Err(LegacyMigrationError::ResourceLimit( + "legacy app configuration exceeds the probe size limit".to_string(), + )); + } + let bytes = fs::read(&path).map_err(|error| LegacyMigrationError::io(&path, error))?; + let value: Value = + serde_json::from_slice(&bytes).map_err(|error| LegacyMigrationError::json(&path, error))?; + Ok(value + .get("version") + .and_then(Value::as_str) + .map(ToOwned::to_owned)) +} + +fn root_descriptor(kind: LegacyRootKind, path: &Path) -> LegacyRootDescriptor { + LegacyRootDescriptor { + kind, + display_path: path.to_string_lossy().to_string(), + } +} + +#[derive(Debug, Default)] +struct TreeSummary { + entries: usize, + bytes: u64, +} + +fn bounded_tree_summary(root: &Path, max_entries: usize) -> LegacyMigrationResult { + if max_entries == 0 || !root.exists() { + return Ok(TreeSummary::default()); + } + let mut summary = TreeSummary::default(); + let mut pending = vec![root.to_path_buf()]; + while let Some(path) = pending.pop() { + let metadata = + fs::symlink_metadata(&path).map_err(|error| LegacyMigrationError::io(&path, error))?; + if metadata.file_type().is_symlink() { + continue; + } + if metadata.is_file() { + summary.bytes = summary.bytes.saturating_add(metadata.len()); + summary.entries += 1; + } else if metadata.is_dir() { + for entry in + fs::read_dir(&path).map_err(|error| LegacyMigrationError::io(&path, error))? + { + let entry = entry.map_err(|error| LegacyMigrationError::io(&path, error))?; + let name = entry.file_name(); + let name = name.to_string_lossy(); + if should_skip_probe_name(&name) { + continue; + } + pending.push(entry.path()); + if summary.entries + pending.len() >= max_entries { + return Ok(summary); + } + } + } + } + Ok(summary) +} + +fn should_skip_probe_name(name: &str) -> bool { + matches!(name, "cache" | "logs" | "cli-logs" | "temp" | "runtimes") + || name.eq_ignore_ascii_case(".system") + || name.starts_with("ipc-v") + || matches!(name, "ownership" | "request-traces") +} + +fn hash_path_fact(hasher: &mut Sha256, root: &Path, path: &Path) -> LegacyMigrationResult<()> { + hasher.update(path.to_string_lossy().as_bytes()); + match fs::symlink_metadata(path) { + Ok(metadata) => { + hasher.update(metadata.len().to_le_bytes()); + let modified_ms = metadata + .modified() + .ok() + .and_then(|time| time.duration_since(UNIX_EPOCH).ok()) + .map(|duration| duration.as_millis() as u64) + .unwrap_or_default(); + hasher.update(modified_ms.to_le_bytes()); + hasher.update( + path.strip_prefix(root) + .unwrap_or(path) + .as_os_str() + .as_encoded_bytes(), + ); + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => hasher.update(b"missing"), + Err(error) => return Err(LegacyMigrationError::io(path, error)), + } + Ok(()) +} + +fn source_was_migrated(roots: &MigrationRoots, fingerprint: &str) -> LegacyMigrationResult { + let store = MigrationOnboardingStore::new(roots.clone()); + let state = store.load()?; + if state.source_fingerprint != fingerprint || state.choice != MigrationPromptChoice::MigrateNow + { + return Ok(false); + } + Ok(store.load_last_report()?.is_some_and(|report| { + matches!( + report.status, + MigrationRunStatus::Completed | MigrationRunStatus::CompletedWithWarnings + ) + })) +} diff --git a/src/crates/services/legacy-migration/src/sqlite.rs b/src/crates/services/legacy-migration/src/sqlite.rs new file mode 100644 index 0000000000..cf8ddee0da --- /dev/null +++ b/src/crates/services/legacy-migration/src/sqlite.rs @@ -0,0 +1,61 @@ +use crate::{LegacyMigrationError, LegacyMigrationResult}; +use rusqlite::backup::Backup; +use rusqlite::{Connection, OpenFlags}; +use std::path::Path; +use std::time::Duration; + +pub fn snapshot_sqlite_read_only(source: &Path, destination: &Path) -> LegacyMigrationResult<()> { + if destination.exists() { + return Err(LegacyMigrationError::InvalidRequest(format!( + "SQLite snapshot destination already exists: {}", + destination.display() + ))); + } + if let Some(parent) = destination.parent() { + std::fs::create_dir_all(parent).map_err(|error| LegacyMigrationError::io(parent, error))?; + } + let source_connection = Connection::open_with_flags( + source, + OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX, + ) + .map_err(|error| LegacyMigrationError::sqlite(source, error))?; + source_connection + .pragma_update(None, "query_only", true) + .map_err(|error| LegacyMigrationError::sqlite(source, error))?; + validate_connection(&source_connection, source)?; + + let mut destination_connection = Connection::open(destination) + .map_err(|error| LegacyMigrationError::sqlite(destination, error))?; + let backup = Backup::new(&source_connection, &mut destination_connection) + .map_err(|error| LegacyMigrationError::sqlite(source, error))?; + backup + .run_to_completion(128, Duration::from_millis(5), None) + .map_err(|error| LegacyMigrationError::sqlite(destination, error))?; + drop(backup); + destination_connection + .execute_batch("PRAGMA wal_checkpoint(TRUNCATE); PRAGMA journal_mode=DELETE;") + .map_err(|error| LegacyMigrationError::sqlite(destination, error))?; + validate_connection(&destination_connection, destination) +} + +pub fn validate_sqlite(path: &Path) -> LegacyMigrationResult<()> { + let connection = Connection::open_with_flags( + path, + OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX, + ) + .map_err(|error| LegacyMigrationError::sqlite(path, error))?; + validate_connection(&connection, path) +} + +fn validate_connection(connection: &Connection, path: &Path) -> LegacyMigrationResult<()> { + let result: String = connection + .query_row("PRAGMA integrity_check(1)", [], |row| row.get(0)) + .map_err(|error| LegacyMigrationError::sqlite(path, error))?; + if result != "ok" { + return Err(LegacyMigrationError::UnsupportedSource(format!( + "SQLite integrity check failed for {}", + path.display() + ))); + } + Ok(()) +} diff --git a/src/crates/services/legacy-migration/src/storage.rs b/src/crates/services/legacy-migration/src/storage.rs new file mode 100644 index 0000000000..c7a0db1964 --- /dev/null +++ b/src/crates/services/legacy-migration/src/storage.rs @@ -0,0 +1,255 @@ +use crate::{LegacyMigrationError, LegacyMigrationResult, MigrationRoots}; +use fs2::FileExt; +use serde::Serialize; +use std::fs::{self, File, OpenOptions}; +use std::io::Write; +use std::path::{Path, PathBuf}; + +#[derive(Debug, Clone)] +pub struct MigrationLayout { + root: PathBuf, + run_id: String, +} + +impl MigrationLayout { + pub fn new(roots: &MigrationRoots, run_id: impl Into) -> Self { + Self { + root: roots.migration_root(), + run_id: run_id.into(), + } + } + + pub fn root(&self) -> &Path { + &self.root + } + + pub fn run_root(&self) -> PathBuf { + self.root.join("runs").join(&self.run_id) + } + + pub fn request_path(&self) -> PathBuf { + self.run_root().join("request.json") + } + + pub fn consumed_nonce_path(&self) -> PathBuf { + self.run_root().join("nonce-consumed.json") + } + + pub fn plan_path(&self) -> PathBuf { + self.run_root().join("plan.json") + } + + pub fn journal_path(&self) -> PathBuf { + self.run_root().join("journal.jsonl") + } + + pub fn report_path(&self) -> PathBuf { + self.run_root().join("report.json") + } + + pub fn release_observation_path(&self) -> PathBuf { + self.run_root().join("release-observation.json") + } + + pub fn failure_diagnostics_path(&self) -> PathBuf { + self.run_root().join("failure-diagnostics.json") + } + + pub fn stage_root(&self) -> PathBuf { + self.run_root().join("stage") + } + + pub fn backup_root(&self) -> PathBuf { + self.run_root().join("backup") + } + + pub fn lock_path(&self) -> PathBuf { + self.root.join("lock") + } + + pub fn initialize(&self) -> LegacyMigrationResult<()> { + for path in [self.run_root(), self.stage_root(), self.backup_root()] { + fs::create_dir_all(&path).map_err(|error| LegacyMigrationError::io(&path, error))?; + } + Ok(()) + } + + pub fn append_journal(&self, event: &T) -> LegacyMigrationResult<()> { + let path = self.journal_path(); + let mut file = OpenOptions::new() + .create(true) + .append(true) + .open(&path) + .map_err(|error| LegacyMigrationError::io(&path, error))?; + serde_json::to_writer(&mut file, event) + .map_err(|error| LegacyMigrationError::json(&path, error))?; + file.write_all(b"\n") + .map_err(|error| LegacyMigrationError::io(&path, error))?; + file.sync_data() + .map_err(|error| LegacyMigrationError::io(&path, error)) + } + + pub fn read_json( + &self, + path: &Path, + ) -> LegacyMigrationResult> { + let bytes = match fs::read(path) { + Ok(bytes) => bytes, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(LegacyMigrationError::io(path, error)), + }; + serde_json::from_slice(&bytes) + .map(Some) + .map_err(|error| LegacyMigrationError::json(path, error)) + } +} + +pub struct MigrationLock { + file: File, +} + +impl MigrationLock { + pub fn acquire(layout: &MigrationLayout) -> LegacyMigrationResult { + fs::create_dir_all(layout.root()) + .map_err(|error| LegacyMigrationError::io(layout.root(), error))?; + let path = layout.lock_path(); + let file = OpenOptions::new() + .create(true) + .read(true) + .write(true) + .open(&path) + .map_err(|error| LegacyMigrationError::io(&path, error))?; + file.try_lock_exclusive() + .map_err(|_| LegacyMigrationError::LockUnavailable)?; + Ok(Self { file }) + } +} + +impl Drop for MigrationLock { + fn drop(&mut self) { + let _ = FileExt::unlock(&self.file); + } +} + +pub fn atomic_write_json(path: &Path, value: &T) -> LegacyMigrationResult<()> { + let mut bytes = serde_json::to_vec_pretty(value) + .map_err(|error| LegacyMigrationError::json(path, error))?; + bytes.push(b'\n'); + atomic_write(path, &bytes) +} + +/// Atomically replace a file with caller-provided bytes on the target volume. +pub fn atomic_write_bytes(path: &Path, bytes: &[u8]) -> LegacyMigrationResult<()> { + atomic_write(path, bytes) +} + +fn atomic_write(path: &Path, bytes: &[u8]) -> LegacyMigrationResult<()> { + let parent = path.parent().ok_or_else(|| { + LegacyMigrationError::InvalidRequest(format!("path has no parent: {}", path.display())) + })?; + fs::create_dir_all(parent).map_err(|error| LegacyMigrationError::io(parent, error))?; + let temp = parent.join(format!( + ".{}.{}.tmp", + path.file_name().unwrap_or_default().to_string_lossy(), + uuid::Uuid::new_v4() + )); + let mut file = OpenOptions::new() + .create_new(true) + .write(true) + .open(&temp) + .map_err(|error| LegacyMigrationError::io(&temp, error))?; + file.write_all(bytes) + .map_err(|error| LegacyMigrationError::io(&temp, error))?; + file.sync_all() + .map_err(|error| LegacyMigrationError::io(&temp, error))?; + drop(file); + if let Err(error) = replace_file(&temp, path) { + let _ = fs::remove_file(&temp); + return Err(error); + } + if let Ok(directory) = File::open(parent) { + let _ = directory.sync_all(); + } + Ok(()) +} + +#[cfg(not(windows))] +fn replace_file(source: &Path, target: &Path) -> LegacyMigrationResult<()> { + fs::rename(source, target).map_err(|error| LegacyMigrationError::io(target, error)) +} + +#[cfg(windows)] +fn replace_file(source: &Path, target: &Path) -> LegacyMigrationResult<()> { + use windows::core::PCWSTR; + use windows::Win32::Storage::FileSystem::{ + MoveFileExW, MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH, + }; + + let source_wide = + windows_extended_path(source).map_err(|error| LegacyMigrationError::io(source, error))?; + let target_wide = + windows_extended_path(target).map_err(|error| LegacyMigrationError::io(target, error))?; + unsafe { + MoveFileExW( + PCWSTR(source_wide.as_ptr()), + PCWSTR(target_wide.as_ptr()), + MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH, + ) + .map_err(|error| LegacyMigrationError::io(target, std::io::Error::other(error.to_string()))) + } +} + +#[cfg(windows)] +fn windows_extended_path(path: &Path) -> std::io::Result> { + use std::os::windows::ffi::OsStrExt; + + let absolute = std::path::absolute(path)?; + let path = absolute.as_os_str().encode_wide().collect::>(); + let slash = b'\\' as u16; + let mut extended = if path.starts_with(&[slash, slash, b'?' as u16, slash]) + || path.starts_with(&[slash, slash, b'.' as u16, slash]) + { + path + } else if path.starts_with(&[slash, slash]) { + r"\\?\UNC\" + .encode_utf16() + .chain(path.into_iter().skip(2)) + .collect() + } else if path.len() >= 3 && path[1] == b':' as u16 && path[2] == slash { + r"\\?\".encode_utf16().chain(path).collect() + } else { + path + }; + extended.push(0); + Ok(extended) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[cfg(windows)] + #[test] + fn atomic_write_supports_windows_paths_beyond_max_path() { + let temp = tempfile::Builder::new() + .prefix("openbitfun-legacy-migration-long-path-") + .tempdir() + .unwrap(); + let mut parent = temp.path().to_path_buf(); + while parent + .join("migration-owned-session-state.json") + .as_os_str() + .len() + < 270 + { + parent.push("workspace-session-runtime-segment"); + } + let target = parent.join("migration-owned-session-state.json"); + + atomic_write_bytes(&target, b"first").unwrap(); + assert_eq!(fs::read(&target).unwrap(), b"first"); + + atomic_write_bytes(&target, b"second").unwrap(); + assert_eq!(fs::read(&target).unwrap(), b"second"); + } +} diff --git a/src/crates/services/legacy-migration/tests/fixtures/support-matrix.json b/src/crates/services/legacy-migration/tests/fixtures/support-matrix.json new file mode 100644 index 0000000000..fab4ab3f31 --- /dev/null +++ b/src/crates/services/legacy-migration/tests/fixtures/support-matrix.json @@ -0,0 +1,79 @@ +{ + "matrixVersion": 1, + "sourceProduct": "bitfun", + "supportedVersions": ">=0.2.0,<1.0.0", + "canonicalFixtureVersion": "0.2.19", + "sourceRetentionPolicy": "never_delete_automatically", + "domains": [ + { + "id": "settings_and_credentials", + "owner": "configuration and credential store owners", + "atomicSubdomains": ["settings", "credentials"], + "requiredInputs": ["user-root/config/app.json"], + "conflictPolicy": "target_explicit_value_wins" + }, + { + "id": "agents_skills_and_miniapps", + "owner": "Agent, Skill, and MiniApp product-domain owners", + "atomicSubdomains": ["skills", "miniapps", "agents"], + "requiredInputs": [ + "user-root/agents", + "user-root/skills", + "user-root/data/miniapps" + ], + "conflictPolicy": "preserve_both_or_report" + }, + { + "id": "workspaces_sessions_and_tasks", + "owner": "workspace, Session persistence, and Agent coordination owners", + "atomicSubdomains": ["workspace_sessions", "agent_coordination"], + "requiredInputs": [ + "user-root/data/workspace_data.json", + "home/projects", + "user-root/data/agent-runtime/coordination.sqlite" + ], + "requiredReferences": [ + "session.id -> coordination_sessions.parent_session_id", + "agents.child_session_id -> child session.id", + "background_tasks.parent_dialog_turn_id -> parent turn.id", + "background_tasks.child_dialog_turn_id -> child turn.id" + ], + "conflictPolicy": "group_blocked_when_reference_closure_fails" + }, + { + "id": "memory", + "owner": "Memory store owner", + "atomicSubdomains": ["structured_memory", "file_memory"], + "requiredInputs": [ + "user-root/data/memories/memories.sqlite", + "home/memories" + ], + "conflictPolicy": "stable_id_then_content_hash" + }, + { + "id": "remote_connections_and_devices", + "owner": "Remote Connect and Remote SSH owners", + "atomicSubdomains": ["remote_connect_devices", "remote_ssh"], + "requiredInputs": [ + "home/device_identity.json", + "home/remote_connect_persistence.json", + "ssh/ssh_connections.json", + "ssh/remote_workspace.json", + "ssh/known_hosts" + ], + "conflictPolicy": "target_identity_wins_and_reauthenticate_when_unportable" + } + ], + "globalExcludes": [ + "cache", + "logs", + "temp", + "skills/.system", + "data/agent-runtime/ownership", + "data/agent-runtime/ipc-v*", + "**/*.lock", + "**/*.pid", + "**/*-shm", + "**/request-traces" + ] +} diff --git a/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/README.md b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/README.md new file mode 100644 index 0000000000..eddb42ae05 --- /dev/null +++ b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/README.md @@ -0,0 +1,28 @@ +# BitFun 0.2.19 migration fixture + +This fixture is a synthetic, de-identified representation of the final BitFun +storage generation before commit `8784bbc4258131b4e74e6f9192a23b43c454fa94`. +It contains one relationship-complete record for each V1 migration domain and +explicitly includes files that must be excluded. + +The fixture is source data, not a ready-to-copy OpenBitFun profile. Tests build +SQLite databases from the checked-in SQL, enable WAL where needed, and validate +the fixture through the migration readers before executing a plan. + +The Memory SQL mirrors the historical `stage1_outputs` and `jobs` owner schema. +Only `stage1_outputs` contains migratable facts; `jobs` is included to prove +that resumable runtime work state is rebuilt instead of imported. + +Supported source range for V1 is `>=0.2.0,<1.0.0`. The fixture's canonical +source revision is `845b4b4d2925f7c41e7e03a4a618606fbd0da8b6`. +Additional canonical versions require evidence captured from a real retired +BitFun build; synthetic version labels must not expand the support matrix. + +The source-retention contract is `never_delete_automatically`. Engine and +owner-adapter tests compare source-root file hashes across successful, failed, +and cancelled runs; uninstalling BitFun or deleting its data remains a separate +explicit user action. + +All identities, paths, message text, tokens, host keys, and credentials are +synthetic. Secret-bearing owners are represented only by metadata that forces +the migrator to report reauthentication; no usable secret is stored here. diff --git a/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/fixture-manifest.json b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/fixture-manifest.json new file mode 100644 index 0000000000..4ddecc9e59 --- /dev/null +++ b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/fixture-manifest.json @@ -0,0 +1,39 @@ +{ + "fixtureVersion": 1, + "sourceProduct": "bitfun", + "sourceVersion": "0.2.19", + "sourceRevision": "845b4b4d2925f7c41e7e03a4a618606fbd0da8b6", + "domains": [ + "settings_and_credentials", + "agents_skills_and_miniapps", + "workspaces_sessions_and_tasks", + "memory", + "remote_connections_and_devices" + ], + "expectedIncluded": [ + "user-root/config/app.json", + "user-root/agents/researcher.md", + "user-root/skills/user-skill/SKILL.md", + "user-root/data/miniapps/custom-notes/meta.json", + "user-root/data/workspace_data.json", + "user-root/data/agent-runtime/coordination.sql", + "user-root/data/memories/memories.sql", + "home/projects/c--fixture-workspace/sessions/session-1/metadata.json", + "home/projects/c--fixture-workspace/sessions/session-child-1/metadata.json", + "home/runtime-events/session-1.jsonl", + "home/memories/MEMORY.md", + "home/device_identity.json", + "home/remote_connect_persistence.json", + "ssh/ssh_connections.json", + "ssh/remote_workspace.json", + "ssh/known_hosts" + ], + "expectedExcluded": [ + "user-root/skills/.system/builtin/SKILL.md", + "user-root/data/miniapps/builtin-gomoku/index.html", + "user-root/data/agent-runtime/ownership/writer.lock", + "user-root/data/agent-runtime/ipc-v17/discovery.json", + "user-root/logs/legacy.log", + "home/projects/c--fixture-workspace/sessions/session-1/request-traces/trace.json" + ] +} diff --git a/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/home/device_identity.json b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/home/device_identity.json new file mode 100644 index 0000000000..a6a157a559 --- /dev/null +++ b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/home/device_identity.json @@ -0,0 +1,5 @@ +{ + "device_id": "0123456789abcdef0123456789abcdef", + "device_name": "fixture-device", + "mac_address": "02:00:00:00:00:19" +} diff --git a/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/home/memories/MEMORY.md b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/home/memories/MEMORY.md new file mode 100644 index 0000000000..11384c5aef --- /dev/null +++ b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/home/memories/MEMORY.md @@ -0,0 +1,4 @@ +# Synthetic memory fixture + +This file verifies that file-backed memory is migrated independently of the +SQLite memory store. diff --git a/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/home/projects/c--fixture-workspace/sessions/session-1/metadata.json b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/home/projects/c--fixture-workspace/sessions/session-1/metadata.json new file mode 100644 index 0000000000..92ce422c0a --- /dev/null +++ b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/home/projects/c--fixture-workspace/sessions/session-1/metadata.json @@ -0,0 +1,19 @@ +{ + "schema_version": 2, + "sessionId": "session-1", + "sessionName": "Synthetic migration fixture", + "agentType": "Agentic", + "sessionKind": "standard", + "memoryMode": "enabled", + "modelName": "fixture-model", + "createdAt": 1, + "lastActiveAt": 2, + "lastFinishedAt": 2, + "turnCount": 1, + "messageCount": 1, + "toolCallCount": 0, + "status": "active", + "tags": [], + "workspacePath": "C:\\fixture\\workspace", + "workspaceHostname": "localhost" +} diff --git a/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/home/projects/c--fixture-workspace/sessions/session-1/request-traces/trace.json b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/home/projects/c--fixture-workspace/sessions/session-1/request-traces/trace.json new file mode 100644 index 0000000000..0beb43bf25 --- /dev/null +++ b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/home/projects/c--fixture-workspace/sessions/session-1/request-traces/trace.json @@ -0,0 +1 @@ +{"request":"synthetic diagnostic payload that must not migrate"} diff --git a/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/home/projects/c--fixture-workspace/sessions/session-1/turns/turn-0000.json b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/home/projects/c--fixture-workspace/sessions/session-1/turns/turn-0000.json new file mode 100644 index 0000000000..3f356e73ff --- /dev/null +++ b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/home/projects/c--fixture-workspace/sessions/session-1/turns/turn-0000.json @@ -0,0 +1,19 @@ +{ + "schema_version": 2, + "turnId": "turn-1", + "turnIndex": 0, + "sessionId": "session-1", + "timestamp": 1, + "agentType": "Agentic", + "userMessage": { + "id": "message-1", + "content": "Synthetic parent request", + "timestamp": 1 + }, + "modelRounds": [], + "startTime": 1, + "endTime": 2, + "durationMs": 1, + "hasFinalResponse": true, + "status": "completed" +} diff --git a/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/home/projects/c--fixture-workspace/sessions/session-child-1/metadata.json b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/home/projects/c--fixture-workspace/sessions/session-child-1/metadata.json new file mode 100644 index 0000000000..56eb764026 --- /dev/null +++ b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/home/projects/c--fixture-workspace/sessions/session-child-1/metadata.json @@ -0,0 +1,27 @@ +{ + "schema_version": 2, + "sessionId": "session-child-1", + "sessionName": "Subagent: Synthetic helper", + "agentType": "Explore", + "createdBy": "session-1", + "sessionKind": "subagent", + "memoryMode": "enabled", + "modelName": "fixture-model", + "createdAt": 1, + "lastActiveAt": 2, + "lastFinishedAt": 2, + "turnCount": 1, + "messageCount": 1, + "toolCallCount": 0, + "status": "completed", + "tags": [], + "relationship": { + "kind": "subagent", + "parentSessionId": "session-1", + "parentDialogTurnId": "turn-1", + "parentToolCallId": "call-1", + "subagentType": "Explore" + }, + "workspacePath": "C:\\fixture\\workspace", + "workspaceHostname": "localhost" +} diff --git a/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/home/projects/c--fixture-workspace/sessions/session-child-1/turns/turn-0000.json b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/home/projects/c--fixture-workspace/sessions/session-child-1/turns/turn-0000.json new file mode 100644 index 0000000000..40844f4782 --- /dev/null +++ b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/home/projects/c--fixture-workspace/sessions/session-child-1/turns/turn-0000.json @@ -0,0 +1,19 @@ +{ + "schema_version": 2, + "turnId": "turn-child-1", + "turnIndex": 0, + "sessionId": "session-child-1", + "timestamp": 1, + "agentType": "Explore", + "userMessage": { + "id": "message-child-1", + "content": "Synthetic delegated request", + "timestamp": 1 + }, + "modelRounds": [], + "startTime": 1, + "endTime": 2, + "durationMs": 1, + "hasFinalResponse": true, + "status": "completed" +} diff --git a/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/home/remote_connect_persistence.json b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/home/remote_connect_persistence.json new file mode 100644 index 0000000000..fec7c4bf3a --- /dev/null +++ b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/home/remote_connect_persistence.json @@ -0,0 +1,30 @@ +{ + "connections": [ + { + "bot_type": "telegram", + "chat_id": "fixture-chat", + "config": { + "bot_type": "telegram", + "bot_token": "" + }, + "chat_state": { + "chat_id": "fixture-chat", + "paired": false, + "current_workspace": "/srv/fixture-workspace", + "current_assistant": null, + "current_assistant_name": null, + "current_session_id": null, + "display_mode": "assistant", + "account_remote_context": true + }, + "connected_at": 0 + } + ], + "form_state": { + "custom_server_url": "https://relay.example.invalid", + "telegram_bot_token": "", + "feishu_app_id": "", + "feishu_app_secret": "" + }, + "verbose_mode": false +} diff --git a/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/home/runtime-events/session-1.jsonl b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/home/runtime-events/session-1.jsonl new file mode 100644 index 0000000000..b32a5b8ff8 --- /dev/null +++ b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/home/runtime-events/session-1.jsonl @@ -0,0 +1 @@ +{"streamId":"fixture-stream","cursor":1,"event":{"type":"TextChunk","session_id":"session-1","turn_id":"turn-runtime-1","round_id":"round-runtime-1","text":"Synthetic in-flight output"}} diff --git a/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/ssh/known_hosts b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/ssh/known_hosts new file mode 100644 index 0000000000..df11902273 --- /dev/null +++ b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/ssh/known_hosts @@ -0,0 +1,9 @@ +[ + { + "host": "example.invalid", + "port": 22, + "key_type": "ssh-ed25519", + "fingerprint": "SHA256:fixture-only-host-fingerprint", + "public_key": "00010203040506070809" + } +] diff --git a/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/ssh/remote_workspace.json b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/ssh/remote_workspace.json new file mode 100644 index 0000000000..2a8a9ec504 --- /dev/null +++ b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/ssh/remote_workspace.json @@ -0,0 +1,6 @@ +{ + "connectionId": "ssh-fixture@example.invalid:22", + "remotePath": "/srv/fixture-workspace", + "connectionName": "fixture@example.invalid", + "sshHost": "example.invalid" +} diff --git a/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/ssh/ssh_connections.json b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/ssh/ssh_connections.json new file mode 100644 index 0000000000..fd3cac716e --- /dev/null +++ b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/ssh/ssh_connections.json @@ -0,0 +1,14 @@ +[ + { + "id": "ssh-fixture@example.invalid:22", + "name": "fixture@example.invalid", + "host": "example.invalid", + "port": 22, + "username": "fixture", + "authType": { + "type": "Password" + }, + "defaultWorkspace": "/srv/fixture-workspace", + "lastConnected": 1 + } +] diff --git a/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/user-root/agents/researcher.md b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/user-root/agents/researcher.md new file mode 100644 index 0000000000..66db5bbc14 --- /dev/null +++ b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/user-root/agents/researcher.md @@ -0,0 +1,13 @@ +--- +id: researcher +name: Researcher +description: Synthetic fixture agent +kind: subagent +tools: + - Read +readonly: true +model: fast +schema_version: 1 +--- + +Inspect the supplied fixture files and summarize their relationships. diff --git a/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/user-root/config/app.json b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/user-root/config/app.json new file mode 100644 index 0000000000..92f7e1e1c0 --- /dev/null +++ b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/user-root/config/app.json @@ -0,0 +1,20 @@ +{ + "app": { + "language": "zh-CN", + "auto_update": false, + "telemetry": false + }, + "appearance": { + "theme_id": "bitfun-dark" + }, + "ai": { + "agent_profiles": { + "coding_shared": { + "enabled_skills": ["user::bitfun::user-skill"] + } + } + }, + "schema_version": 1, + "version": "0.2.19", + "last_modified": 0 +} diff --git a/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/user-root/data/agent-runtime/coordination.sql b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/user-root/data/agent-runtime/coordination.sql new file mode 100644 index 0000000000..1dfa315de4 --- /dev/null +++ b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/user-root/data/agent-runtime/coordination.sql @@ -0,0 +1,46 @@ +PRAGMA user_version = 2; +CREATE TABLE coordination_sessions ( + parent_session_id TEXT PRIMARY KEY, + next_auto_agent_seq INTEGER NOT NULL DEFAULT 1, + updated_at_ms INTEGER NOT NULL +); +CREATE TABLE agents ( + agent_pk INTEGER PRIMARY KEY AUTOINCREMENT, + parent_session_id TEXT NOT NULL, + agent_id TEXT NOT NULL, + child_session_id TEXT, + next_bg_seq INTEGER NOT NULL DEFAULT 1, + state TEXT NOT NULL, + created_at_ms INTEGER NOT NULL +); +CREATE TABLE background_tasks ( + task_pk INTEGER PRIMARY KEY AUTOINCREMENT, + parent_session_id TEXT NOT NULL, + agent_pk INTEGER NOT NULL, + bg_task_id TEXT NOT NULL, + bg_ordinal INTEGER NOT NULL, + parent_dialog_turn_id TEXT NOT NULL, + parent_tool_call_id TEXT NOT NULL, + child_dialog_turn_id TEXT NOT NULL, + status TEXT NOT NULL, + error_code TEXT, + error_message TEXT, + execution_owner_token TEXT NOT NULL, + created_at_ms INTEGER NOT NULL, + terminal_at_ms INTEGER +); +CREATE TABLE swarm_trees (root_session_id TEXT PRIMARY KEY, created_at_ms INTEGER NOT NULL); +CREATE TABLE swarm_nodes ( + session_id TEXT PRIMARY KEY, + root_session_id TEXT NOT NULL, + parent_session_id TEXT, + agent_type TEXT NOT NULL, + depth INTEGER NOT NULL, + created_at_ms INTEGER NOT NULL +); +INSERT INTO coordination_sessions VALUES ('session-1', 2, 1); +INSERT INTO agents VALUES (1, 'session-1', 'helper-1', 'session-child-1', 2, 'historical', 1); +INSERT INTO background_tasks VALUES (1, 'session-1', 1, 'bg-1', 1, 'turn-1', 'call-1', 'turn-child-1', 'completed', NULL, NULL, 'fixture-owner', 1, 2); +INSERT INTO swarm_trees VALUES ('session-1', 1); +INSERT INTO swarm_nodes VALUES ('session-1', 'session-1', NULL, 'Ultra', 0, 1); +INSERT INTO swarm_nodes VALUES ('session-child-1', 'session-1', 'session-1', 'researcher', 1, 1); diff --git a/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/user-root/data/agent-runtime/ipc-v17/discovery.json b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/user-root/data/agent-runtime/ipc-v17/discovery.json new file mode 100644 index 0000000000..00b8780624 --- /dev/null +++ b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/user-root/data/agent-runtime/ipc-v17/discovery.json @@ -0,0 +1 @@ +{"pid":999999,"endpoint":"fixture","token":"not-a-secret-fixture"} diff --git a/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/user-root/data/agent-runtime/ownership/writer.lock b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/user-root/data/agent-runtime/ownership/writer.lock new file mode 100644 index 0000000000..5b3e049c6c --- /dev/null +++ b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/user-root/data/agent-runtime/ownership/writer.lock @@ -0,0 +1 @@ +synthetic stale ownership marker diff --git a/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/user-root/data/memories/memories.sql b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/user-root/data/memories/memories.sql new file mode 100644 index 0000000000..3e87d0db5a --- /dev/null +++ b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/user-root/data/memories/memories.sql @@ -0,0 +1,57 @@ +CREATE TABLE stage1_outputs ( + thread_id TEXT PRIMARY KEY NOT NULL, + workspace_path TEXT NOT NULL, + rollout_path TEXT NOT NULL, + source_updated_at INTEGER NOT NULL, + raw_memory TEXT NOT NULL, + rollout_summary TEXT NOT NULL, + rollout_slug TEXT, + generated_at INTEGER NOT NULL, + usage_count INTEGER, + last_usage INTEGER, + selected_for_phase2 INTEGER NOT NULL DEFAULT 0, + selected_for_phase2_source_updated_at INTEGER +); + +CREATE INDEX idx_stage1_outputs_source_updated_at + ON stage1_outputs(source_updated_at DESC, thread_id DESC); + +CREATE TABLE jobs ( + kind TEXT NOT NULL, + job_key TEXT NOT NULL, + status TEXT NOT NULL, + worker_id TEXT, + ownership_token TEXT, + started_at INTEGER, + finished_at INTEGER, + lease_until INTEGER, + retry_at INTEGER, + retry_remaining INTEGER NOT NULL, + last_error TEXT, + input_watermark INTEGER, + last_success_watermark INTEGER, + PRIMARY KEY (kind, job_key) +); + +CREATE INDEX idx_jobs_kind_status_retry_lease + ON jobs(kind, status, retry_at, lease_until); + +INSERT INTO stage1_outputs VALUES ( + 'session-1', + 'C:\fixture-workspace', + 'C:\fixture-workspace\sessions\session-1', + 1, + 'Synthetic migration fixture memory.', + 'Synthetic migration fixture summary.', + 'fixture-memory', + 2, + 0, + NULL, + 0, + NULL +); + +INSERT INTO jobs ( + kind, job_key, status, retry_remaining, input_watermark, + last_success_watermark +) VALUES ('memory_stage1', 'session-1', 'done', 3, 1, 1); diff --git a/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/user-root/data/miniapps/builtin-gomoku/.builtin-manifest.json b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/user-root/data/miniapps/builtin-gomoku/.builtin-manifest.json new file mode 100644 index 0000000000..81385c1b7f --- /dev/null +++ b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/user-root/data/miniapps/builtin-gomoku/.builtin-manifest.json @@ -0,0 +1 @@ +{"version":1,"hash":"sha256:fixture"} diff --git a/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/user-root/data/miniapps/builtin-gomoku/index.html b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/user-root/data/miniapps/builtin-gomoku/index.html new file mode 100644 index 0000000000..eb2b200e8b --- /dev/null +++ b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/user-root/data/miniapps/builtin-gomoku/index.html @@ -0,0 +1 @@ +Legacy built-in code must be regenerated. diff --git a/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/user-root/data/miniapps/builtin-gomoku/meta.json b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/user-root/data/miniapps/builtin-gomoku/meta.json new file mode 100644 index 0000000000..e81ed06d2d --- /dev/null +++ b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/user-root/data/miniapps/builtin-gomoku/meta.json @@ -0,0 +1 @@ +{"id":"builtin-gomoku","name":"Legacy built-in","version":1,"created_at":0,"updated_at":0} diff --git a/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/user-root/data/miniapps/builtin-gomoku/storage.json b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/user-root/data/miniapps/builtin-gomoku/storage.json new file mode 100644 index 0000000000..3de66b3860 --- /dev/null +++ b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/user-root/data/miniapps/builtin-gomoku/storage.json @@ -0,0 +1 @@ +{"wins":3,"losses":1} diff --git a/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/user-root/data/miniapps/custom-notes/index.html b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/user-root/data/miniapps/custom-notes/index.html new file mode 100644 index 0000000000..0975b01cea --- /dev/null +++ b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/user-root/data/miniapps/custom-notes/index.html @@ -0,0 +1 @@ +
Synthetic fixture
diff --git a/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/user-root/data/miniapps/custom-notes/meta.json b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/user-root/data/miniapps/custom-notes/meta.json new file mode 100644 index 0000000000..9c9b503d1a --- /dev/null +++ b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/user-root/data/miniapps/custom-notes/meta.json @@ -0,0 +1,18 @@ +{ + "id": "custom-notes", + "name": "Custom Notes", + "description": "Synthetic user MiniApp", + "icon": "FileText", + "category": "productivity", + "tags": ["fixture"], + "version": 1, + "created_at": 1, + "updated_at": 2, + "permissions": { + "fs": { "read": ["{appdata}"], "write": ["{appdata}"] }, + "shell": { "allow": [] }, + "net": { "allow": [] }, + "node": { "enabled": false, "max_memory_mb": 128, "timeout_ms": 5000 } + }, + "ai_context": null +} diff --git a/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/user-root/data/miniapps/custom-notes/storage.json b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/user-root/data/miniapps/custom-notes/storage.json new file mode 100644 index 0000000000..6910bb1c15 --- /dev/null +++ b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/user-root/data/miniapps/custom-notes/storage.json @@ -0,0 +1 @@ +{"notes":[{"id":"note-1","title":"Fixture note"}]} diff --git a/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/user-root/data/workspace_data.json b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/user-root/data/workspace_data.json new file mode 100644 index 0000000000..c33628ff81 --- /dev/null +++ b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/user-root/data/workspace_data.json @@ -0,0 +1,26 @@ +{ + "workspaces": { + "workspace-1": { + "id": "workspace-1", + "name": "Fixture workspace", + "rootPath": "C:\\fixture\\workspace", + "workspaceType": "Other", + "workspaceKind": "normal", + "status": "Inactive", + "languages": [], + "openedAt": "2026-01-01T00:00:00Z", + "lastAccessed": "2026-01-01T00:00:00Z", + "description": null, + "tags": [], + "statistics": null, + "relatedPaths": [], + "metadata": { + "sshHost": "localhost" + } + } + }, + "opened_workspace_ids": ["workspace-1"], + "current_workspace_id": "workspace-1", + "recent_workspaces": ["workspace-1"], + "saved_at": "2026-01-01T00:00:00Z" +} diff --git a/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/user-root/skills/.system/builtin/SKILL.md b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/user-root/skills/.system/builtin/SKILL.md new file mode 100644 index 0000000000..5c01116d5e --- /dev/null +++ b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/user-root/skills/.system/builtin/SKILL.md @@ -0,0 +1,6 @@ +--- +name: builtin +description: Synthetic built-in skill that must never be migrated. +--- + +This directory is regenerated by the installed OpenBitFun version. diff --git a/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/user-root/skills/user-skill/SKILL.md b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/user-root/skills/user-skill/SKILL.md new file mode 100644 index 0000000000..726bd125d3 --- /dev/null +++ b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/user-root/skills/user-skill/SKILL.md @@ -0,0 +1,6 @@ +--- +name: user-skill +description: Synthetic user skill used by migration tests. +--- + +Read the fixture without executing scripts. diff --git a/src/crates/services/legacy-migration/tests/migration_engine_contracts.rs b/src/crates/services/legacy-migration/tests/migration_engine_contracts.rs new file mode 100644 index 0000000000..ef12a7a0ac --- /dev/null +++ b/src/crates/services/legacy-migration/tests/migration_engine_contracts.rs @@ -0,0 +1,916 @@ +use openbitfun_legacy_migration::{ + atomic_write_json, export_failure_diagnostics, probe_legacy_source, snapshot_sqlite_read_only, + CancellationToken, CrashInjector, CrashPoint, DomainContext, DomainScan, LegacyDomainAdapter, + LegacyMigrationError, LegacyMigrationResult, MigrationEngine, MigrationLayout, MigrationLock, + MigrationRoots, NoCrashInjection, ProbeLimits, +}; +use openbitfun_product_domains::legacy_migration::{ + FindingSeverity, MigrationDiagnostic, MigrationDomainId, MigrationDomainResult, + MigrationDomainState, MigrationGroupId, MigrationJournalEvent, MigrationPhase, + MigrationReleaseObservation, MigrationRunReport, MigrationRunStatus, MigrationSelection, + ScanFinding, +}; +use rusqlite::Connection; +use sha2::{Digest, Sha256}; +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +struct CallCounts { + scan: usize, + stage: usize, + validate_stage: usize, + commit: usize, + validate_commit: usize, + finalize_result: usize, + rollback: usize, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +enum FinalizeBehavior { + #[default] + PassThrough, + AddRedactedMetadata, + Fail, +} + +struct FakeAdapter { + domain: MigrationDomainId, + calls: Arc>>, + finalize_behavior: FinalizeBehavior, + stage_error: Option, +} + +impl LegacyDomainAdapter for FakeAdapter { + fn domain(&self) -> MigrationDomainId { + self.domain + } + + fn scan(&self, _roots: &MigrationRoots) -> LegacyMigrationResult { + self.update(|counts| counts.scan += 1); + Ok(DomainScan { + finding: ScanFinding { + domain: self.domain, + code: "fake_source_supported".to_string(), + entity_count: 1, + logical_bytes: 16, + source_schema: Some("fake.v1".to_string()), + migratable: true, + ..ScanFinding::default() + }, + conflicts: Vec::new(), + target_schema: Some("fake.current".to_string()), + dependencies: Vec::new(), + }) + } + + fn stage(&self, context: &DomainContext<'_>) -> LegacyMigrationResult { + self.update(|counts| counts.stage += 1); + if let Some(kind) = self.stage_error { + return Err(LegacyMigrationError::Io { + path: PathBuf::from("C:/Users/private/session-state.json"), + source: std::io::Error::new(kind, "private storage detail"), + }); + } + atomic_write_json( + &stage_path(context, self.domain), + &serde_json::json!({"domain": format!("{:?}", self.domain), "entities": ["stable-id"]}), + )?; + Ok(MigrationDomainResult { + domain: self.domain, + state: MigrationDomainState::Staged, + imported: 1, + ..MigrationDomainResult::default() + }) + } + + fn validate_stage(&self, context: &DomainContext<'_>) -> LegacyMigrationResult<()> { + self.update(|counts| counts.validate_stage += 1); + require_file(&stage_path(context, self.domain)) + } + + fn commit(&self, context: &DomainContext<'_>) -> LegacyMigrationResult<()> { + self.update(|counts| counts.commit += 1); + let staged = fs::read(stage_path(context, self.domain)).map_err(|error| { + LegacyMigrationError::InvalidRequest(format!("missing fake stage: {error}")) + })?; + let value: serde_json::Value = serde_json::from_slice(&staged).map_err(|error| { + LegacyMigrationError::InvalidRequest(format!("invalid fake stage: {error}")) + })?; + // Rewriting the same owner record is intentional: recovery can repeat a + // commit whose target write succeeded before its journal marker did. + atomic_write_json(&target_path(context, self.domain), &value) + } + + fn validate_commit(&self, context: &DomainContext<'_>) -> LegacyMigrationResult<()> { + self.update(|counts| counts.validate_commit += 1); + require_file(&target_path(context, self.domain)) + } + + fn finalize_result( + &self, + _context: &DomainContext<'_>, + staged: &MigrationDomainResult, + ) -> LegacyMigrationResult { + self.update(|counts| counts.finalize_result += 1); + match self.finalize_behavior { + FinalizeBehavior::PassThrough => Ok(staged.clone()), + FinalizeBehavior::AddRedactedMetadata => { + let mut finalized = staged.clone(); + finalized.imported = 2; + finalized.skipped = 1; + finalized.warnings.push(MigrationDiagnostic { + code: "credential_requires_reauthentication".to_string(), + severity: FindingSeverity::Warning, + domain: Some(self.domain), + message: "A credential must be entered again.".to_string(), + ..MigrationDiagnostic::default() + }); + finalized.requires_reauthentication = vec!["account-1".to_string()]; + Ok(finalized) + } + FinalizeBehavior::Fail => Err(LegacyMigrationError::InvalidRequest( + "final result is unavailable".to_string(), + )), + } + } + + fn rollback_unverified(&self, context: &DomainContext<'_>) -> LegacyMigrationResult<()> { + self.update(|counts| counts.rollback += 1); + let path = target_path(context, self.domain); + match fs::remove_file(&path) { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(LegacyMigrationError::InvalidRequest(format!( + "failed to roll back fake owner file {}: {error}", + path.display() + ))), + } + } +} + +impl FakeAdapter { + fn update(&self, update: impl FnOnce(&mut CallCounts)) { + let mut calls = self + .calls + .lock() + .expect("fake call state should not poison"); + update(calls.entry(self.domain).or_default()); + } +} + +struct CrashOnce { + point: CrashPoint, + fired: AtomicBool, +} + +impl CrashInjector for CrashOnce { + fn should_crash(&self, point: CrashPoint) -> bool { + point == self.point && !self.fired.swap(true, Ordering::AcqRel) + } +} + +#[test] +fn engine_recovers_after_commit_before_journal_and_deduplicates_repeated_runs() { + let temp = test_tempdir(); + let roots = fixture_roots(temp.path()); + seed_supported_source(&roots); + let source_before = legacy_source_snapshot(&roots); + let source = probe_legacy_source(&roots, ProbeLimits::default()) + .expect("probe should succeed") + .expect("source should be present"); + let calls = Arc::new(Mutex::new(BTreeMap::new())); + let engine = fake_engine(roots.clone(), Arc::clone(&calls)); + let selection = MigrationSelection { + groups: BTreeSet::from([MigrationGroupId::SettingsAndCredentials]), + }; + let plan = engine + .plan(&source, selection, &CancellationToken::default()) + .expect("plan should succeed"); + let crash = CrashOnce { + point: CrashPoint::AfterCommit(MigrationDomainId::Settings), + fired: AtomicBool::new(false), + }; + + assert!(matches!( + engine.execute(&plan, &CancellationToken::default(), &crash), + Err(LegacyMigrationError::InjectedCrash( + CrashPoint::AfterCommit(MigrationDomainId::Settings) + )) + )); + let recovered = engine + .execute(&plan, &CancellationToken::default(), &NoCrashInjection) + .expect("retry should recover"); + assert_eq!(recovered.status, MigrationRunStatus::Completed); + assert!(recovered + .domain_results + .iter() + .all(|result| result.state == MigrationDomainState::Verified)); + + let before_repeat = calls.lock().expect("calls should not poison").clone(); + let repeated = engine + .execute(&plan, &CancellationToken::default(), &NoCrashInjection) + .expect("completed run should be readable"); + assert_eq!(repeated, recovered); + assert_eq!( + *calls.lock().expect("calls should not poison"), + before_repeat, + "a completed plan must not execute owner writes again" + ); + assert_eq!( + before_repeat + .get(&MigrationDomainId::Settings) + .expect("settings calls") + .commit, + 2, + "the owner idempotently retries an ambiguous commit" + ); + let observation = read_observation(&roots, &plan.run_id); + assert_eq!(observation.result_code, "migration_completed"); + assert_eq!(observation.failure_phase, None); + assert_eq!(legacy_source_snapshot(&roots), source_before); +} + +#[test] +fn engine_persists_owner_metadata_finalized_after_commit_validation() { + let temp = test_tempdir(); + let roots = fixture_roots(temp.path()); + seed_supported_source(&roots); + let source = probe_legacy_source(&roots, ProbeLimits::default()) + .expect("probe should succeed") + .expect("source should be present"); + let calls = Arc::new(Mutex::new(BTreeMap::new())); + let engine = fake_engine_with_finalize( + roots.clone(), + Arc::clone(&calls), + FinalizeBehavior::AddRedactedMetadata, + ); + let plan = engine + .plan( + &source, + MigrationSelection { + groups: BTreeSet::from([MigrationGroupId::SettingsAndCredentials]), + }, + &CancellationToken::default(), + ) + .expect("plan should succeed"); + + let report = engine + .execute(&plan, &CancellationToken::default(), &NoCrashInjection) + .expect("execution should succeed"); + + assert_eq!(report.status, MigrationRunStatus::CompletedWithWarnings); + assert_eq!(report.requires_reauthentication, ["account-1"]); + let settings = report + .domain_results + .iter() + .find(|result| result.domain == MigrationDomainId::Settings) + .expect("settings result should exist"); + assert_eq!(settings.state, MigrationDomainState::Verified); + assert_eq!(settings.imported, 2); + assert_eq!(settings.skipped, 1); + assert_eq!(settings.warnings.len(), 1); + assert_eq!( + calls + .lock() + .expect("calls should not poison") + .get(&MigrationDomainId::Settings) + .expect("settings calls") + .finalize_result, + 1 + ); + assert_eq!( + read_observation(&roots, &plan.run_id).result_code, + "migration_completed_with_warnings" + ); + + let persisted: MigrationRunReport = serde_json::from_slice( + &fs::read(MigrationLayout::new(&roots, &plan.run_id).report_path()) + .expect("report should be persisted"), + ) + .expect("persisted report should be valid"); + assert_eq!(persisted, report); +} + +#[test] +fn progress_reports_real_domain_phases_counts_and_cancel_boundaries() { + let temp = test_tempdir(); + let roots = fixture_roots(temp.path()); + seed_supported_source(&roots); + let source = probe_legacy_source(&roots, ProbeLimits::default()) + .expect("probe should succeed") + .expect("source should be present"); + let engine = fake_engine(roots, Arc::new(Mutex::new(BTreeMap::new()))); + let plan = engine + .plan( + &source, + MigrationSelection { + groups: BTreeSet::from([MigrationGroupId::SettingsAndCredentials]), + }, + &CancellationToken::default(), + ) + .expect("plan should succeed"); + let mut progress = Vec::new(); + + engine + .execute_with_progress( + &plan, + &CancellationToken::default(), + &NoCrashInjection, + |event| progress.push(event), + ) + .expect("execution should succeed"); + + for phase in [ + MigrationPhase::Stage, + MigrationPhase::ValidateStage, + MigrationPhase::Commit, + MigrationPhase::ValidateCommit, + MigrationPhase::Finalize, + ] { + assert!( + progress.iter().any(|event| event.phase == phase), + "progress should include {phase:?}" + ); + } + assert!(progress + .iter() + .filter(|event| event.phase == MigrationPhase::Commit) + .all(|event| !event.safe_to_cancel)); + assert!(progress + .iter() + .any(|event| event.code == "domain_verified" && event.safe_to_cancel)); + assert!(progress + .iter() + .all(|event| event.processed <= event.total && event.total == plan.steps.len() as u64)); +} + +#[test] +fn engine_rolls_back_unverified_commit_when_owner_finalization_fails() { + let temp = test_tempdir(); + let roots = fixture_roots(temp.path()); + seed_supported_source(&roots); + let source_before = legacy_source_snapshot(&roots); + let source = probe_legacy_source(&roots, ProbeLimits::default()) + .expect("probe should succeed") + .expect("source should be present"); + let calls = Arc::new(Mutex::new(BTreeMap::new())); + let engine = + fake_engine_with_finalize(roots.clone(), Arc::clone(&calls), FinalizeBehavior::Fail); + let plan = engine + .plan( + &source, + MigrationSelection { + groups: BTreeSet::from([MigrationGroupId::SettingsAndCredentials]), + }, + &CancellationToken::default(), + ) + .expect("plan should succeed"); + + assert!(matches!( + engine.execute(&plan, &CancellationToken::default(), &NoCrashInjection), + Err(LegacyMigrationError::Domain { + domain: MigrationDomainId::Settings, + .. + }) + )); + assert!(!target_path_for_roots(&roots, MigrationDomainId::Settings).exists()); + let settings_calls = calls + .lock() + .expect("calls should not poison") + .get(&MigrationDomainId::Settings) + .copied() + .expect("settings calls"); + assert_eq!(settings_calls.finalize_result, 1); + assert_eq!(settings_calls.rollback, 1); + + let persisted: MigrationRunReport = serde_json::from_slice( + &fs::read(MigrationLayout::new(&roots, &plan.run_id).report_path()) + .expect("failed report should be persisted"), + ) + .expect("persisted report should be valid"); + assert_eq!(persisted.status, MigrationRunStatus::FailedRecoverable); + assert_eq!( + persisted + .domain_results + .iter() + .find(|result| result.domain == MigrationDomainId::Settings) + .expect("settings result should exist") + .state, + MigrationDomainState::Failed + ); + let observation = read_observation(&roots, &plan.run_id); + assert_eq!(observation.result_code, "domain_failed_recoverable"); + assert_eq!( + observation.failure_phase, + Some(MigrationPhase::ValidateCommit) + ); + assert_eq!(legacy_source_snapshot(&roots), source_before); +} + +#[test] +fn domain_failure_report_keeps_only_sanitized_storage_classification() { + let temp = test_tempdir(); + let roots = fixture_roots(temp.path()); + seed_supported_source(&roots); + let source = probe_legacy_source(&roots, ProbeLimits::default()) + .expect("probe should succeed") + .expect("source should be present"); + let calls = Arc::new(Mutex::new(BTreeMap::new())); + let engine = + fake_engine_with_stage_error(roots.clone(), calls, std::io::ErrorKind::PermissionDenied); + let plan = engine + .plan( + &source, + MigrationSelection { + groups: BTreeSet::from([MigrationGroupId::SettingsAndCredentials]), + }, + &CancellationToken::default(), + ) + .expect("plan should succeed"); + + assert!(matches!( + engine.execute(&plan, &CancellationToken::default(), &NoCrashInjection), + Err(LegacyMigrationError::Domain { + domain: MigrationDomainId::Settings, + .. + }) + )); + + let report_bytes = fs::read(MigrationLayout::new(&roots, &plan.run_id).report_path()) + .expect("failed report should be persisted"); + let report: MigrationRunReport = + serde_json::from_slice(&report_bytes).expect("failed report should be valid"); + let diagnostic = report + .diagnostics + .last() + .expect("failed report should include a diagnostic"); + assert_eq!(diagnostic.code, "domain_io_permission_denied"); + assert_eq!(diagnostic.domain, Some(MigrationDomainId::Settings)); + assert_eq!(diagnostic.severity, FindingSeverity::Blocking); + assert!(diagnostic.relative_path.is_none()); + let serialized = String::from_utf8(report_bytes).unwrap(); + assert!(!serialized.contains("private")); + assert!(!serialized.contains("session-state")); +} + +#[test] +fn cancellation_keeps_every_legacy_source_root_unchanged() { + let temp = test_tempdir(); + let roots = fixture_roots(temp.path()); + seed_supported_source(&roots); + fs::create_dir_all(&roots.legacy_home_root).expect("legacy home should be created"); + fs::write( + roots.legacy_home_root.join("user-content.txt"), + "content that must remain untouched", + ) + .expect("legacy content should be written"); + let source_before = legacy_source_snapshot(&roots); + let source = probe_legacy_source(&roots, ProbeLimits::default()) + .expect("probe should succeed") + .expect("source should be present"); + let engine = fake_engine(roots.clone(), Arc::new(Mutex::new(BTreeMap::new()))); + let plan = engine + .plan( + &source, + MigrationSelection { + groups: BTreeSet::from([MigrationGroupId::SettingsAndCredentials]), + }, + &CancellationToken::default(), + ) + .expect("plan should succeed"); + let cancellation = CancellationToken::default(); + let cancellation_from_progress = cancellation.clone(); + + let result = + engine.execute_with_progress(&plan, &cancellation, &NoCrashInjection, move |event| { + if event.phase == MigrationPhase::Stage { + cancellation_from_progress.cancel(); + } + }); + + assert!(matches!(result, Err(LegacyMigrationError::Cancelled))); + assert_eq!(legacy_source_snapshot(&roots), source_before); + let layout = MigrationLayout::new(&roots, &plan.run_id); + let report: MigrationRunReport = serde_json::from_slice( + &fs::read(layout.report_path()).expect("cancelled report should be persisted"), + ) + .expect("cancelled report should be valid"); + assert_eq!(report.status, MigrationRunStatus::Cancelled); + let observation: MigrationReleaseObservation = serde_json::from_slice( + &fs::read(layout.release_observation_path()) + .expect("cancelled observation should be persisted"), + ) + .expect("cancelled observation should be valid"); + assert_eq!(observation.result_code, "migration_cancelled"); + assert_eq!(observation.failure_phase, None); +} + +#[test] +fn release_observation_and_failure_export_exclude_sensitive_content() { + let temp = test_tempdir(); + let roots = fixture_roots(temp.path()); + let layout = MigrationLayout::new(&roots, "diagnostics-run"); + layout.initialize().expect("layout should initialize"); + for event in [ + MigrationJournalEvent { + format_version: 1, + sequence: 1, + recorded_at_ms: 1_100, + run_id: "private-run-id".to_string(), + status: MigrationRunStatus::Staging, + phase: MigrationPhase::Stage, + domain: Some(MigrationDomainId::Settings), + domain_state: Some(MigrationDomainState::Staged), + code: "journal-secret C:/Users/Alice".to_string(), + }, + MigrationJournalEvent { + format_version: 1, + sequence: 2, + recorded_at_ms: 1_250, + run_id: "private-run-id".to_string(), + status: MigrationRunStatus::FailedRecoverable, + phase: MigrationPhase::ValidateStage, + domain: Some(MigrationDomainId::Settings), + domain_state: Some(MigrationDomainState::Failed), + code: "domain_failed_recoverable".to_string(), + }, + ] { + layout + .append_journal(&event) + .expect("journal event should append"); + } + fs::write( + layout.journal_path(), + [ + fs::read(layout.journal_path()).expect("journal should be readable"), + b"{\"invalid\":\"message-body-secret\"\n".to_vec(), + ] + .concat(), + ) + .expect("invalid diagnostic fixture should append"); + let report = MigrationRunReport { + format_version: 1, + run_id: "private-run-id".to_string(), + source_fingerprint: "private-source-fingerprint".to_string(), + plan_hash: "private-plan-hash".to_string(), + status: MigrationRunStatus::FailedRecoverable, + started_at_ms: 1_000, + domain_results: vec![MigrationDomainResult { + domain: MigrationDomainId::Settings, + state: MigrationDomainState::Failed, + imported: 42, + warnings: vec![MigrationDiagnostic { + code: "settings_validation_failed".to_string(), + severity: FindingSeverity::Blocking, + domain: Some(MigrationDomainId::Settings), + relative_path: Some("C:/Users/Alice/secret.txt".to_string()), + message: "message-body-secret".to_string(), + action: Some("repair-account-secret".to_string()), + }], + requires_reauthentication: vec!["repair-account-secret".to_string()], + ..MigrationDomainResult::default() + }], + diagnostics: vec![MigrationDiagnostic { + code: "diagnostic secret token".to_string(), + severity: FindingSeverity::Warning, + domain: None, + relative_path: Some("../private-path".to_string()), + message: "another-private-message".to_string(), + action: Some("private-action".to_string()), + }], + ..MigrationRunReport::default() + }; + + let path = + export_failure_diagnostics(&layout, &report).expect("failure diagnostics should export"); + assert_eq!(path, layout.failure_diagnostics_path()); + let bytes = fs::read(&path).expect("failure diagnostics should be readable"); + let serialized = String::from_utf8(bytes.clone()).expect("diagnostics should be UTF-8"); + for forbidden in [ + "private-run-id", + "private-source-fingerprint", + "private-plan-hash", + "Users/Alice", + "message-body-secret", + "repair-account-secret", + "another-private-message", + "private-action", + "private-path", + "journal-secret", + "42", + ] { + assert!( + !serialized.contains(forbidden), + "diagnostics exposed forbidden value: {forbidden}" + ); + } + let value: serde_json::Value = + serde_json::from_slice(&bytes).expect("diagnostics should be valid JSON"); + assert_object_keys( + &value, + &["diagnosticCodes", "formatVersion", "journal", "observation"], + ); + assert_object_keys( + &value["observation"], + &["domainStates", "durationMs", "failurePhase", "resultCode"], + ); + for domain_state in value["observation"]["domainStates"] + .as_array() + .expect("domain states should be an array") + { + assert_object_keys(domain_state, &["domain", "state"]); + } + for diagnostic in value["diagnosticCodes"] + .as_array() + .expect("diagnostic codes should be an array") + { + assert_object_keys(diagnostic, &["code", "domain", "severity"]); + } + for journal_entry in value["journal"] + .as_array() + .expect("journal should be an array") + { + assert_object_keys( + journal_entry, + &[ + "code", + "domain", + "domainState", + "phase", + "sequence", + "status", + ], + ); + } + assert_eq!(value["observation"]["durationMs"], 250); + assert_eq!(value["observation"]["failurePhase"], "validate_stage"); + assert_eq!( + value["observation"]["resultCode"], + "domain_failed_recoverable" + ); + assert!(serialized.contains("settings_validation_failed")); + assert!(serialized.contains("journal_entry_invalid")); + assert!(serialized.contains("redacted_code")); +} + +#[test] +fn sqlite_wal_snapshot_contains_committed_rows_without_changing_source_files() { + let temp = test_tempdir(); + let source = temp.path().join("source.sqlite"); + let destination = temp.path().join("stage/snapshot.sqlite"); + let connection = Connection::open(&source).expect("source SQLite should open"); + connection + .execute_batch( + "PRAGMA journal_mode=WAL;\ + PRAGMA wal_autocheckpoint=0;\ + CREATE TABLE items(id INTEGER PRIMARY KEY, value TEXT NOT NULL);\ + INSERT INTO items(value) VALUES ('first'), ('second');", + ) + .expect("WAL fixture should be created"); + let source_files = sqlite_family(&source); + assert!(source_files + .iter() + .any(|path| path.to_string_lossy().ends_with("-wal"))); + let before = source_files + .iter() + .filter(|path| !path.to_string_lossy().ends_with("-shm")) + .map(|path| (path.clone(), sha256_file(path))) + .collect::>(); + + snapshot_sqlite_read_only(&source, &destination).expect("snapshot should succeed"); + + let snapshot = Connection::open(&destination).expect("snapshot should open"); + let count: i64 = snapshot + .query_row("SELECT COUNT(*) FROM items", [], |row| row.get(0)) + .expect("snapshot rows should be readable"); + assert_eq!(count, 2); + drop(snapshot); + for (path, expected_hash) in before { + assert_eq!( + sha256_file(&path), + expected_hash, + "source changed: {}", + path.display() + ); + } + assert!(!destination.with_extension("sqlite-shm").exists()); + assert!(!destination.with_extension("sqlite-wal").exists()); +} + +#[test] +fn lock_contention_and_source_target_aliases_fail_closed() { + let temp = test_tempdir(); + let roots = fixture_roots(temp.path()); + let layout = MigrationLayout::new(&roots, "lock-test"); + let _first = MigrationLock::acquire(&layout).expect("first lock should succeed"); + assert!(matches!( + MigrationLock::acquire(&layout), + Err(LegacyMigrationError::LockUnavailable) + )); + + let mut aliased = roots; + aliased.target_user_root = aliased.legacy_user_root.clone(); + assert!(matches!( + aliased.validate_distinct(), + Err(LegacyMigrationError::SourceEqualsTarget(_)) + )); +} + +fn fake_engine( + roots: MigrationRoots, + calls: Arc>>, +) -> MigrationEngine { + fake_engine_with_finalize(roots, calls, FinalizeBehavior::PassThrough) +} + +fn fake_engine_with_finalize( + roots: MigrationRoots, + calls: Arc>>, + settings_finalize_behavior: FinalizeBehavior, +) -> MigrationEngine { + fake_engine_with_behaviors(roots, calls, settings_finalize_behavior, None) +} + +fn fake_engine_with_stage_error( + roots: MigrationRoots, + calls: Arc>>, + settings_stage_error: std::io::ErrorKind, +) -> MigrationEngine { + fake_engine_with_behaviors( + roots, + calls, + FinalizeBehavior::PassThrough, + Some(settings_stage_error), + ) +} + +fn fake_engine_with_behaviors( + roots: MigrationRoots, + calls: Arc>>, + settings_finalize_behavior: FinalizeBehavior, + settings_stage_error: Option, +) -> MigrationEngine { + let adapters = [ + MigrationDomainId::Settings, + MigrationDomainId::Credentials, + MigrationDomainId::CrossReferenceRepair, + ] + .into_iter() + .map(|domain| { + Box::new(FakeAdapter { + domain, + calls: Arc::clone(&calls), + finalize_behavior: if domain == MigrationDomainId::Settings { + settings_finalize_behavior + } else { + FinalizeBehavior::PassThrough + }, + stage_error: (domain == MigrationDomainId::Settings) + .then_some(settings_stage_error) + .flatten(), + }) as Box + }); + MigrationEngine::new(roots, adapters).expect("fake engine should be valid") +} + +fn fixture_roots(root: &Path) -> MigrationRoots { + 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/skills"), + target_ssh_root: root.join("target/ssh"), + } +} + +fn seed_supported_source(roots: &MigrationRoots) { + let path = roots.legacy_user_root.join("config/app.json"); + fs::create_dir_all(path.parent().expect("configuration should have a parent")) + .expect("legacy config directory should be created"); + fs::write(path, r#"{"version":"0.2.19"}"#).expect("legacy configuration should be written"); +} + +fn stage_path(context: &DomainContext<'_>, domain: MigrationDomainId) -> PathBuf { + context.layout.stage_root().join(format!("{domain:?}.json")) +} + +fn target_path(context: &DomainContext<'_>, domain: MigrationDomainId) -> PathBuf { + target_path_for_roots(context.roots, domain) +} + +fn target_path_for_roots(roots: &MigrationRoots, domain: MigrationDomainId) -> PathBuf { + roots + .target_user_root + .join("data/fake-owner") + .join(format!("{domain:?}.json")) +} + +fn require_file(path: &Path) -> LegacyMigrationResult<()> { + if path.is_file() { + Ok(()) + } else { + Err(LegacyMigrationError::InvalidRequest(format!( + "expected fake owner file: {}", + path.display() + ))) + } +} + +fn sqlite_family(database: &Path) -> Vec { + let mut paths = vec![database.to_path_buf()]; + for suffix in ["-wal", "-shm"] { + let path = PathBuf::from(format!("{}{suffix}", database.display())); + if path.exists() { + paths.push(path); + } + } + paths +} + +fn sha256_file(path: &Path) -> String { + let bytes = fs::read(path).expect("fixture file should remain readable"); + hex::encode(Sha256::digest(bytes)) +} + +fn legacy_source_snapshot(roots: &MigrationRoots) -> BTreeMap { + let mut snapshot = BTreeMap::new(); + for (name, root) in [ + ("user", &roots.legacy_user_root), + ("home", &roots.legacy_home_root), + ("skills", &roots.legacy_skills_root), + ("ssh", &roots.legacy_ssh_root), + ] { + collect_source_entries(name, root, root, &mut snapshot); + } + snapshot +} + +fn collect_source_entries( + name: &str, + root: &Path, + current: &Path, + snapshot: &mut BTreeMap, +) { + if !current.exists() { + return; + } + let relative = current + .strip_prefix(root) + .expect("source entry should remain below its root") + .to_string_lossy() + .replace('\\', "/"); + let key = if relative.is_empty() { + name.to_string() + } else { + format!("{name}/{relative}") + }; + if current.is_dir() { + snapshot.insert(format!("{key}/"), "directory".to_string()); + let mut entries = fs::read_dir(current) + .expect("source directory should remain readable") + .collect::, _>>() + .expect("source entries should remain readable"); + entries.sort_by_key(std::fs::DirEntry::file_name); + for entry in entries { + collect_source_entries(name, root, &entry.path(), snapshot); + } + } else { + snapshot.insert(key, sha256_file(current)); + } +} + +fn assert_object_keys(value: &serde_json::Value, expected: &[&str]) { + let mut actual = value + .as_object() + .expect("value should be a JSON object") + .keys() + .map(String::as_str) + .collect::>(); + actual.sort_unstable(); + assert_eq!(actual, expected); +} + +fn read_observation(roots: &MigrationRoots, run_id: &str) -> MigrationReleaseObservation { + serde_json::from_slice( + &fs::read(MigrationLayout::new(roots, run_id).release_observation_path()) + .expect("release observation should be persisted"), + ) + .expect("release observation should be valid") +} + +fn test_tempdir() -> tempfile::TempDir { + match std::env::var_os("OPENBITFUN_TEST_TMPDIR") { + Some(root) => tempfile::Builder::new() + .prefix("legacy-migration-") + .tempdir_in(root) + .expect("temporary directory should be created in the requested root"), + None => tempfile::tempdir().expect("temporary directory should be created"), + } +} diff --git a/src/crates/services/services-core/AGENTS.md b/src/crates/services/services-core/AGENTS.md index b4055886a8..919d369a94 100644 --- a/src/crates/services/services-core/AGENTS.md +++ b/src/crates/services/services-core/AGENTS.md @@ -4,8 +4,8 @@ Scope: this guide applies to `src/crates/services/services-core`. `openbitfun-services-core` owns cross-platform service DTOs and helpers that compile without the full product runtime. This includes generic filesystem/search/JSON -IO helpers, bounded local Instruction file reads, session metadata storage -helpers, and local OS action primitives such as command lookup, +IO helpers, bounded local Instruction file reads, Session metadata storage +helpers, the durable Memory SQLite format, and local OS action primitives such as command lookup, clipboard, file/url opening, script execution, workspace runtime FS/shell providers, process-wide TLS provider selection, managed process-tree lifecycle, process-level Agent Runtime ownership locks, and system facts. Product crates may layer routing, policy, @@ -30,7 +30,7 @@ crate. `permission`, `dispatch-workspace`, `markdown`, `session-git`, and `workspace-text-runtime` extensions only for behavior they use. Products needing IANA time-zone ranges and dashboard aggregation additionally select - `token-usage-statistics`. In particular, session metadata consumers must + `token-usage-statistics` and `memory-store`. In particular, session metadata consumers must not compile libgit2 unless they use the memory-workspace baseline/diff API. Keep Tokio and platform API capabilities owner-scoped too: the empty profile carries no Tokio dependency, `workspace-runtime` explicitly composes @@ -91,6 +91,7 @@ cargo test -p openbitfun-services-core --no-default-features --features workspac cargo test -p openbitfun-services-core --no-default-features --features workspace-runtime --lib workspace::tests:: cargo test -p openbitfun-services-core --no-default-features --features local-storage --test session_contracts session_metadata_contracts:: cargo test -p openbitfun-services-core --no-default-features --features local-storage --test session_write_lock_contracts +cargo test -p openbitfun-services-core --no-default-features --features memory-store --lib memory_store::tests:: cargo test -p openbitfun-services-core --no-default-features --features token-usage-statistics --lib token_usage:: cargo test -p openbitfun-services-core --no-default-features --features process-runtime --test process_runtime_contracts cargo test -p openbitfun-services-core --no-default-features --features process-runtime --lib process_tree::tests:: diff --git a/src/crates/services/services-core/Cargo.toml b/src/crates/services/services-core/Cargo.toml index fad014f208..89ea3c489e 100644 --- a/src/crates/services/services-core/Cargo.toml +++ b/src/crates/services/services-core/Cargo.toml @@ -105,6 +105,7 @@ local-storage = [ "windows/Win32_Foundation", "windows/Win32_Storage_FileSystem", ] +memory-store = ["dep:rusqlite"] product-identity = ["dep:openbitfun-core-types"] token-usage-statistics = ["local-storage", "dep:chrono-tz"] process-runtime = [ @@ -119,6 +120,7 @@ process-runtime = [ "tokio/time", "windows/Win32_Foundation", "windows/Win32_System_Diagnostics_ToolHelp", + "windows/Win32_System_JobObjects", "windows/Win32_System_Threading", ] workspace-instructions = [ diff --git a/src/crates/services/services-core/src/lib.rs b/src/crates/services/services-core/src/lib.rs index a7798b327c..070c54f100 100644 --- a/src/crates/services/services-core/src/lib.rs +++ b/src/crates/services/services-core/src/lib.rs @@ -29,6 +29,8 @@ pub mod local_runtime_ports; pub mod managed_runtime; #[cfg(feature = "markdown")] pub mod markdown; +#[cfg(feature = "memory-store")] +pub mod memory_store; #[cfg(feature = "permission")] pub mod permission_store; #[cfg(feature = "local-storage")] diff --git a/src/crates/services/services-core/src/memory_store.rs b/src/crates/services/services-core/src/memory_store.rs new file mode 100644 index 0000000000..85744e4fcf --- /dev/null +++ b/src/crates/services/services-core/src/memory_store.rs @@ -0,0 +1,456 @@ +//! Durable Memory store schema and synchronous owner access. +//! +//! The live Memory workflow remains in Product Assembly. This module owns the +//! SQLite persistence shape and the small synchronous API needed by both the +//! live runtime and the offline legacy-data migrator. + +use rusqlite::{params, Connection}; +use serde::{Deserialize, Serialize}; +use std::path::{Component, Path}; +use thiserror::Error; + +pub const MEMORY_STORE_SCHEMA: &str = "openbitfun.memory.stage1.v1"; +pub const MEMORY_FILE_NAME: &str = "MEMORY.md"; +pub const MEMORY_SUMMARY_FILE_NAME: &str = "memory_summary.md"; +pub const MEMORY_EXTENSIONS_DIR_NAME: &str = "extensions"; +pub const AD_HOC_EXTENSION_NAME: &str = "ad_hoc"; +pub const AD_HOC_NOTES_DIR_NAME: &str = "notes"; + +pub const EXPECTED_STAGE1_COLUMNS: &[&str] = &[ + "thread_id", + "workspace_path", + "rollout_path", + "source_updated_at", + "raw_memory", + "rollout_summary", + "rollout_slug", + "generated_at", + "usage_count", + "last_usage", + "selected_for_phase2", + "selected_for_phase2_source_updated_at", +]; + +pub const EXPECTED_JOBS_COLUMNS: &[&str] = &[ + "kind", + "job_key", + "status", + "worker_id", + "ownership_token", + "started_at", + "finished_at", + "lease_until", + "retry_at", + "retry_remaining", + "last_error", + "input_watermark", + "last_success_watermark", +]; + +const JOB_KIND_MEMORY_CONSOLIDATE_GLOBAL: &str = "memory_consolidate_global"; +const JOB_STATUS_DONE: &str = "done"; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct MemoryRecord { + pub session_id: String, + pub workspace_path: String, + pub rollout_path: String, + pub source_updated_at_unix_secs: i64, + pub raw_memory: String, + pub rollout_summary: String, + pub rollout_slug: Option, + pub generated_at_unix_secs: i64, + pub usage_count: i64, + pub last_usage_unix_secs: Option, + pub selected_for_phase2: i64, + pub selected_for_phase2_source_updated_at: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct MemoryJobRecord { + pub kind: String, + pub job_key: String, + pub status: String, + pub worker_id: Option, + pub ownership_token: Option, + pub started_at_unix_secs: Option, + pub finished_at_unix_secs: Option, + pub lease_until_unix_secs: Option, + pub retry_at_unix_secs: Option, + pub retry_remaining: i64, + pub last_error: Option, + pub input_watermark: Option, + pub last_success_watermark: Option, +} + +impl MemoryJobRecord { + pub fn success_cooldown_until_unix_secs(&self, cooldown_seconds: i64) -> Option { + if self.kind != JOB_KIND_MEMORY_CONSOLIDATE_GLOBAL + || self.status != JOB_STATUS_DONE + || self.last_error.is_some() + || self.input_watermark.is_none() + || self.last_success_watermark != self.input_watermark + { + return None; + } + + self.finished_at_unix_secs + .map(|finished_at| finished_at.saturating_add(cooldown_seconds.max(0))) + } +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct MemoryStoreSnapshot { + pub records: Vec, + pub jobs: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MemoryWorkspaceFileKind { + Index, + Summary, + AdHocNote, +} + +#[derive(Debug, Error)] +pub enum MemoryStoreError { + #[error("Memory store SQLite operation failed: {0}")] + Sqlite(#[from] rusqlite::Error), + #[error("Memory store schema is unsupported: {0}")] + UnsupportedSchema(String), + #[error("Memory record is invalid: {0}")] + InvalidRecord(String), +} + +pub fn initialize_memory_schema(conn: &Connection) -> Result<(), MemoryStoreError> { + recreate_table_if_shape_differs(conn, "stage1_outputs", EXPECTED_STAGE1_COLUMNS)?; + recreate_table_if_shape_differs(conn, "jobs", EXPECTED_JOBS_COLUMNS)?; + conn.execute_batch( + r#" + CREATE TABLE IF NOT EXISTS stage1_outputs ( + thread_id TEXT PRIMARY KEY NOT NULL, + workspace_path TEXT NOT NULL, + rollout_path TEXT NOT NULL, + source_updated_at INTEGER NOT NULL, + raw_memory TEXT NOT NULL, + rollout_summary TEXT NOT NULL, + rollout_slug TEXT, + generated_at INTEGER NOT NULL, + usage_count INTEGER, + last_usage INTEGER, + selected_for_phase2 INTEGER NOT NULL DEFAULT 0, + selected_for_phase2_source_updated_at INTEGER + ); + + CREATE INDEX IF NOT EXISTS idx_stage1_outputs_source_updated_at + ON stage1_outputs(source_updated_at DESC, thread_id DESC); + + CREATE TABLE IF NOT EXISTS jobs ( + kind TEXT NOT NULL, + job_key TEXT NOT NULL, + status TEXT NOT NULL, + worker_id TEXT, + ownership_token TEXT, + started_at INTEGER, + finished_at INTEGER, + lease_until INTEGER, + retry_at INTEGER, + retry_remaining INTEGER NOT NULL, + last_error TEXT, + input_watermark INTEGER, + last_success_watermark INTEGER, + PRIMARY KEY (kind, job_key) + ); + + CREATE INDEX IF NOT EXISTS idx_jobs_kind_status_retry_lease + ON jobs(kind, status, retry_at, lease_until); + "#, + )?; + Ok(()) +} + +pub fn validate_memory_schema(conn: &Connection) -> Result<(), MemoryStoreError> { + validate_table_shape(conn, "stage1_outputs", EXPECTED_STAGE1_COLUMNS)?; + validate_table_shape(conn, "jobs", EXPECTED_JOBS_COLUMNS) +} + +pub fn read_memory_store_snapshot( + conn: &Connection, +) -> Result { + validate_memory_schema(conn)?; + let mut record_statement = conn.prepare( + r#" + SELECT thread_id, workspace_path, rollout_path, source_updated_at, raw_memory, + rollout_summary, rollout_slug, generated_at, COALESCE(usage_count, 0), + last_usage, selected_for_phase2, selected_for_phase2_source_updated_at + FROM stage1_outputs + ORDER BY thread_id + "#, + )?; + let records = record_statement + .query_map([], decode_memory_record)? + .collect::, _>>()?; + for record in &records { + validate_memory_record(record)?; + } + + let mut job_statement = conn.prepare( + r#" + SELECT kind, job_key, status, worker_id, ownership_token, started_at, + finished_at, lease_until, retry_at, retry_remaining, last_error, + input_watermark, last_success_watermark + FROM jobs + ORDER BY kind, job_key + "#, + )?; + let jobs = job_statement + .query_map([], decode_memory_job)? + .collect::, _>>()?; + Ok(MemoryStoreSnapshot { records, jobs }) +} + +pub fn upsert_memory_record( + conn: &Connection, + record: &MemoryRecord, + overwrite_usage_and_selection: bool, +) -> Result<(), MemoryStoreError> { + let overwrite = i64::from(overwrite_usage_and_selection); + conn.execute( + UPSERT_STAGE1_OUTPUT_SQL, + params![ + &record.session_id, + &record.workspace_path, + &record.rollout_path, + record.source_updated_at_unix_secs, + &record.raw_memory, + &record.rollout_summary, + &record.rollout_slug, + record.generated_at_unix_secs, + record.usage_count, + record.last_usage_unix_secs, + record.selected_for_phase2, + record.selected_for_phase2_source_updated_at, + overwrite, + overwrite, + overwrite, + overwrite, + ], + )?; + Ok(()) +} + +pub fn decode_memory_record(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(MemoryRecord { + session_id: row.get(0)?, + workspace_path: row.get(1)?, + rollout_path: row.get(2)?, + source_updated_at_unix_secs: row.get(3)?, + raw_memory: row.get(4)?, + rollout_summary: row.get(5)?, + rollout_slug: row.get(6)?, + generated_at_unix_secs: row.get(7)?, + usage_count: row.get(8)?, + last_usage_unix_secs: row.get(9)?, + selected_for_phase2: row.get(10)?, + selected_for_phase2_source_updated_at: row.get(11)?, + }) +} + +pub fn decode_memory_job(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(MemoryJobRecord { + kind: row.get(0)?, + job_key: row.get(1)?, + status: row.get(2)?, + worker_id: row.get(3)?, + ownership_token: row.get(4)?, + started_at_unix_secs: row.get(5)?, + finished_at_unix_secs: row.get(6)?, + lease_until_unix_secs: row.get(7)?, + retry_at_unix_secs: row.get(8)?, + retry_remaining: row.get(9)?, + last_error: row.get(10)?, + input_watermark: row.get(11)?, + last_success_watermark: row.get(12)?, + }) +} + +pub fn classify_memory_workspace_file(path: &Path) -> Option { + let components = path.components().collect::>(); + if components + .iter() + .any(|component| !matches!(component, Component::Normal(_))) + { + return None; + } + if components.len() == 1 { + let file_name = components[0].as_os_str().to_str()?; + return match file_name { + MEMORY_FILE_NAME => Some(MemoryWorkspaceFileKind::Index), + MEMORY_SUMMARY_FILE_NAME => Some(MemoryWorkspaceFileKind::Summary), + _ => None, + }; + } + if components.len() == 4 + && components[0].as_os_str() == MEMORY_EXTENSIONS_DIR_NAME + && components[1].as_os_str() == AD_HOC_EXTENSION_NAME + && components[2].as_os_str() == AD_HOC_NOTES_DIR_NAME + { + let file_name = components[3].as_os_str().to_str()?; + if !file_name.is_empty() && file_name.ends_with(".md") { + return Some(MemoryWorkspaceFileKind::AdHocNote); + } + } + None +} + +fn validate_memory_record(record: &MemoryRecord) -> Result<(), MemoryStoreError> { + if record.session_id.trim().is_empty() { + return Err(MemoryStoreError::InvalidRecord( + "thread_id must not be empty".to_string(), + )); + } + if !matches!(record.selected_for_phase2, 0 | 1) { + return Err(MemoryStoreError::InvalidRecord(format!( + "selected_for_phase2 must be 0 or 1 for thread_id {}", + record.session_id + ))); + } + Ok(()) +} + +fn validate_table_shape( + conn: &Connection, + table_name: &str, + expected_columns: &[&str], +) -> Result<(), MemoryStoreError> { + let actual = table_columns(conn, table_name)?; + let expected = expected_columns + .iter() + .map(|column| column.to_string()) + .collect::>(); + if actual != expected { + return Err(MemoryStoreError::UnsupportedSchema(format!( + "table {table_name} has columns {actual:?}, expected {expected:?}" + ))); + } + Ok(()) +} + +fn recreate_table_if_shape_differs( + conn: &Connection, + table_name: &str, + expected_columns: &[&str], +) -> Result<(), MemoryStoreError> { + let columns = table_columns(conn, table_name)?; + let expected = expected_columns + .iter() + .map(|column| column.to_string()) + .collect::>(); + if !columns.is_empty() && columns != expected { + conn.execute(&format!("DROP TABLE IF EXISTS {table_name}"), [])?; + } + Ok(()) +} + +fn table_columns(conn: &Connection, table_name: &str) -> Result, MemoryStoreError> { + let mut statement = conn.prepare(&format!("PRAGMA table_info({table_name})"))?; + let rows = statement.query_map([], |row| row.get::<_, String>(1))?; + Ok(rows.collect::, _>>()?) +} + +const UPSERT_STAGE1_OUTPUT_SQL: &str = r#" + INSERT INTO stage1_outputs ( + thread_id, workspace_path, rollout_path, source_updated_at, raw_memory, + rollout_summary, rollout_slug, generated_at, usage_count, + last_usage, selected_for_phase2, selected_for_phase2_source_updated_at + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(thread_id) DO UPDATE SET + workspace_path = excluded.workspace_path, + rollout_path = excluded.rollout_path, + source_updated_at = excluded.source_updated_at, + raw_memory = excluded.raw_memory, + rollout_summary = excluded.rollout_summary, + rollout_slug = excluded.rollout_slug, + generated_at = excluded.generated_at, + usage_count = CASE + WHEN ? != 0 THEN excluded.usage_count + ELSE stage1_outputs.usage_count + END, + last_usage = CASE + WHEN ? != 0 THEN excluded.last_usage + ELSE stage1_outputs.last_usage + END, + selected_for_phase2 = CASE + WHEN ? != 0 THEN excluded.selected_for_phase2 + ELSE stage1_outputs.selected_for_phase2 + END, + selected_for_phase2_source_updated_at = CASE + WHEN ? != 0 THEN excluded.selected_for_phase2_source_updated_at + ELSE stage1_outputs.selected_for_phase2_source_updated_at + END + WHERE excluded.source_updated_at >= stage1_outputs.source_updated_at +"#; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn owner_schema_round_trips_records_and_keeps_jobs_separate() { + let connection = Connection::open_in_memory().unwrap(); + initialize_memory_schema(&connection).unwrap(); + let record = MemoryRecord { + session_id: "session-1".to_string(), + workspace_path: "/workspace".to_string(), + rollout_path: "/workspace/sessions/session-1".to_string(), + source_updated_at_unix_secs: 10, + raw_memory: "durable fact".to_string(), + rollout_summary: "summary".to_string(), + rollout_slug: Some("fixture".to_string()), + generated_at_unix_secs: 11, + usage_count: 2, + last_usage_unix_secs: Some(12), + selected_for_phase2: 1, + selected_for_phase2_source_updated_at: Some(10), + }; + upsert_memory_record(&connection, &record, true).unwrap(); + connection + .execute( + "INSERT INTO jobs (kind, job_key, status, retry_remaining) VALUES ('memory_stage1', 'session-1', 'done', 3)", + [], + ) + .unwrap(); + + let snapshot = read_memory_store_snapshot(&connection).unwrap(); + assert_eq!(snapshot.records, vec![record]); + assert_eq!(snapshot.jobs.len(), 1); + assert_eq!(snapshot.jobs[0].job_key, "session-1"); + } + + #[test] + fn workspace_file_contract_accepts_only_durable_owner_inputs() { + assert_eq!( + classify_memory_workspace_file(Path::new("MEMORY.md")), + Some(MemoryWorkspaceFileKind::Index) + ); + assert_eq!( + classify_memory_workspace_file(Path::new( + "extensions/ad_hoc/notes/2026-01-01T00-00-00-note.md" + )), + Some(MemoryWorkspaceFileKind::AdHocNote) + ); + assert_eq!( + classify_memory_workspace_file(Path::new("raw_memories.md")), + None + ); + assert_eq!( + classify_memory_workspace_file(Path::new("rollout_summaries/generated.md")), + None + ); + assert_eq!( + classify_memory_workspace_file(Path::new("../MEMORY.md")), + None + ); + } +} diff --git a/src/crates/services/services-core/src/process_manager.rs b/src/crates/services/services-core/src/process_manager.rs index 22e9ab4b5b..fca3ca82bd 100644 --- a/src/crates/services/services-core/src/process_manager.rs +++ b/src/crates/services/services-core/src/process_manager.rs @@ -4,7 +4,7 @@ //! only closes managed child trees; it must never close a Job containing the //! calling host before an updater, restart, or shutdown can finish. -use std::process::Command; +use std::process::{Command, Stdio}; #[cfg(windows)] use std::sync::LazyLock; #[cfg(target_os = "macos")] @@ -25,6 +25,10 @@ use win32job::Job; #[cfg(windows)] const CREATE_NO_WINDOW: u32 = 0x08000000; +#[cfg(windows)] +const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200; +#[cfg(windows)] +const CREATE_BREAKAWAY_FROM_JOB: u32 = 0x01000000; #[cfg(windows)] static GLOBAL_PROCESS_MANAGER: LazyLock = LazyLock::new(ProcessManager::new); @@ -62,6 +66,7 @@ impl ProcessManager { let mut info = ExtendedLimitInfo::new(); info.limit_kill_on_job_close(); job.set_extended_limit_info(&info)?; + allow_explicit_job_breakaway(&job)?; // Assign current process to Job so child processes inherit automatically job.assign_current_process()?; @@ -79,6 +84,43 @@ impl ProcessManager { } } +#[cfg(windows)] +fn allow_explicit_job_breakaway(job: &Job) -> Result<(), Box> { + use std::ffi::c_void; + use std::mem::size_of; + use windows::Win32::Foundation::HANDLE; + use windows::Win32::System::JobObjects::{ + JobObjectExtendedLimitInformation, QueryInformationJobObject, SetInformationJobObject, + JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JOB_OBJECT_LIMIT_BREAKAWAY_OK, + }; + + let handle = HANDLE(job.handle() as *mut c_void); + let mut info = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default(); + // SAFETY: `job` owns a live Job handle and `info` is a correctly sized, + // writable buffer for the requested information class. + unsafe { + QueryInformationJobObject( + Some(handle), + JobObjectExtendedLimitInformation, + &mut info as *mut _ as *mut c_void, + size_of::() as u32, + None, + )?; + } + info.BasicLimitInformation.LimitFlags |= JOB_OBJECT_LIMIT_BREAKAWAY_OK; + // SAFETY: the same live Job handle and initialized information buffer are + // passed with their exact byte size. + unsafe { + SetInformationJobObject( + handle, + JobObjectExtendedLimitInformation, + &info as *const _ as *const c_void, + size_of::() as u32, + )?; + } + Ok(()) +} + /// Create synchronous Command (Windows automatically adds CREATE_NO_WINDOW) pub fn create_command>(program: S) -> Command { let cmd = Command::new(program.as_ref()); @@ -116,6 +158,55 @@ pub fn create_tokio_command>(program: S) -> TokioComma cmd } +/// Create a command that must survive the current GUI process exiting. +/// +/// This is reserved for signed, product-owned process handoffs such as the +/// offline Data Migrator and the trusted main-app restart. Callers must resolve +/// the executable from an authenticated installation boundary; this helper +/// only supplies lifecycle and no-console-window behavior. +pub fn create_detached_command>(program: S) -> Command { + let mut command = create_handoff_command(program); + + #[cfg(windows)] + command.creation_flags(detached_creation_flags()); + + command +} + +/// Create a hidden Windows handoff process group without requesting Job +/// breakaway. +/// +/// The child keeps the caller's Job association when one exists. This is a +/// development-only fallback for hosts whose outer Job rejects explicit +/// breakaway; callers must not treat it as equivalent to a detached handoff. +#[cfg(windows)] +pub fn create_inherited_job_process_group_command>( + program: S, +) -> Command { + let mut command = create_handoff_command(program); + command.creation_flags(inherited_job_process_group_creation_flags()); + command +} + +fn create_handoff_command>(program: S) -> Command { + let mut command = Command::new(program.as_ref()); + command + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + command +} + +#[cfg(windows)] +const fn detached_creation_flags() -> u32 { + CREATE_NO_WINDOW | CREATE_NEW_PROCESS_GROUP | CREATE_BREAKAWAY_FROM_JOB +} + +#[cfg(windows)] +const fn inherited_job_process_group_creation_flags() -> u32 { + CREATE_NO_WINDOW | CREATE_NEW_PROCESS_GROUP +} + #[cfg(target_os = "macos")] fn apply_cached_macos_path(cmd: &mut TokioCommand) { if let Some(path) = cached_macos_path_env() { @@ -180,3 +271,24 @@ pub fn contain_current_process_tree() -> std::io::Result<()> { } Ok(()) } + +#[cfg(all(test, windows))] +mod tests { + use super::*; + + #[test] + fn detached_handoff_is_hidden_and_can_leave_the_process_job() { + let flags = detached_creation_flags(); + assert_ne!(flags & CREATE_NO_WINDOW, 0); + assert_ne!(flags & CREATE_NEW_PROCESS_GROUP, 0); + assert_ne!(flags & CREATE_BREAKAWAY_FROM_JOB, 0); + } + + #[test] + fn inherited_job_handoff_is_hidden_without_requesting_breakaway() { + let flags = inherited_job_process_group_creation_flags(); + assert_ne!(flags & CREATE_NO_WINDOW, 0); + assert_ne!(flags & CREATE_NEW_PROCESS_GROUP, 0); + assert_eq!(flags & CREATE_BREAKAWAY_FROM_JOB, 0); + } +} diff --git a/src/crates/services/services-core/src/process_tree.rs b/src/crates/services/services-core/src/process_tree.rs index 4637a9d83e..55a4d20844 100644 --- a/src/crates/services/services-core/src/process_tree.rs +++ b/src/crates/services/services-core/src/process_tree.rs @@ -473,30 +473,63 @@ mod tests { #[cfg(windows)] fn descendant_fixture(pid_file: &Path) -> Command { - let script = r#"$child = Start-Process -FilePath "$env:SystemRoot\System32\ping.exe" -ArgumentList '-t','127.0.0.1' -WindowStyle Hidden -PassThru; [IO.File]::WriteAllText($env:OPENBITFUN_DESCENDANT_PID_FILE, [string]$child.Id); while ($true) { Start-Sleep -Seconds 60 }"#; - let mut command = Command::new("powershell.exe"); - command - .arg("-NoProfile") - .arg("-NonInteractive") - .arg("-Command") - .arg(script) - .env("OPENBITFUN_DESCENDANT_PID_FILE", pid_file); - command + Command::from(windows_fixture_command(pid_file, "parent")) } #[cfg(windows)] fn orphaned_descendant_fixture(pid_file: &Path) -> Command { - let script = r#"$child = Start-Process -FilePath "$env:SystemRoot\System32\ping.exe" -ArgumentList '-t','127.0.0.1' -WindowStyle Hidden -PassThru; [IO.File]::WriteAllText($env:OPENBITFUN_DESCENDANT_PID_FILE, [string]$child.Id)"#; - let mut command = Command::new("powershell.exe"); + Command::from(windows_fixture_command(pid_file, "orphan-parent")) + } + + #[cfg(windows)] + fn windows_fixture_command(pid_file: &Path, role: &str) -> std::process::Command { + // Reuse the test binary so readiness does not depend on PowerShell + // startup, Start-Process behavior, or an external ping executable. + let mut command = crate::process_manager::create_command( + std::env::current_exe().expect("locate process-tree test executable"), + ); command - .arg("-NoProfile") - .arg("-NonInteractive") - .arg("-Command") - .arg(script) - .env("OPENBITFUN_DESCENDANT_PID_FILE", pid_file); + .args([ + "--exact", + "process_tree::tests::windows_fixture_process", + "--nocapture", + ]) + .env("OPENBITFUN_PROCESS_TREE_FIXTURE_ROLE", role) + .env("OPENBITFUN_DESCENDANT_PID_FILE", pid_file) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::inherit()); command } + #[cfg(windows)] + #[test] + fn windows_fixture_process() { + let Ok(role) = std::env::var("OPENBITFUN_PROCESS_TREE_FIXTURE_ROLE") else { + return; + }; + let pid_file = + std::env::var_os("OPENBITFUN_DESCENDANT_PID_FILE").expect("fixture PID file path"); + match role.as_str() { + "parent" | "orphan-parent" => { + let _child = windows_fixture_command(Path::new(&pid_file), "leaf") + .spawn() + .expect("spawn fixture descendant"); + if role == "orphan-parent" { + return; + } + } + "leaf" => { + std::fs::write(&pid_file, std::process::id().to_string()) + .expect("publish fixture descendant PID"); + } + _ => panic!("unknown process-tree fixture role: {role}"), + } + loop { + std::thread::sleep(Duration::from_secs(60)); + } + } + #[cfg(unix)] fn descendant_fixture(pid_file: &Path) -> Command { let mut command = Command::new("sh"); diff --git a/src/crates/services/services-core/src/session/mod.rs b/src/crates/services/services-core/src/session/mod.rs index 7136ef7a6b..7d4736fd75 100644 --- a/src/crates/services/services-core/src/session/mod.rs +++ b/src/crates/services/services-core/src/session/mod.rs @@ -4,6 +4,7 @@ mod lineage; mod memory_workspace; mod metadata; mod metadata_store; +mod offline_import; pub mod page; pub mod types; mod write_lock; @@ -30,6 +31,9 @@ pub use metadata::{ SessionMetadataBuildFacts, }; pub use metadata_store::{SessionMetadataStore, SessionMetadataStoreError}; +pub use offline_import::{ + OfflineSessionBundle, OfflineSessionImportError, OfflineSessionImportStore, +}; pub use openbitfun_core_types::{SessionKind, SESSION_PROVIDER_ACP, SESSION_PROVIDER_METADATA_KEY}; pub use page::{build_session_metadata_page, empty_session_metadata_page, SessionMetadataPage}; pub use types::*; diff --git a/src/crates/services/services-core/src/session/offline_import.rs b/src/crates/services/services-core/src/session/offline_import.rs new file mode 100644 index 0000000000..71f321fb16 --- /dev/null +++ b/src/crates/services/services-core/src/session/offline_import.rs @@ -0,0 +1,230 @@ +//! Offline Session bundle writer and validator. +//! +//! The standalone data migrator cannot run the live Session runtime. It still +//! writes metadata and Turn envelopes through the same storage owners so a +//! successful import is immediately readable by Desktop, CLI, and Server. + +use super::{ + DialogTurnData, SessionMetadata, SessionMetadataStore, SessionMetadataStoreError, + SessionStorageLayout, StoredDialogTurnFile, StoredSessionMetadataFile, + SESSION_STORAGE_SCHEMA_VERSION, +}; +use crate::json_store::{JsonFileStore, JsonFileStoreError}; +use openbitfun_core_types::validate_session_id; +use std::collections::BTreeSet; +use std::path::{Path, PathBuf}; +use thiserror::Error; + +#[derive(Debug, Clone)] +pub struct OfflineSessionBundle { + pub metadata: SessionMetadata, + pub turns: Vec, +} + +impl OfflineSessionBundle { + pub fn validate(&self) -> Result<(), OfflineSessionImportError> { + validate_bundle_shape(self) + } +} + +#[derive(Debug, Error)] +pub enum OfflineSessionImportError { + #[error(transparent)] + Metadata(#[from] SessionMetadataStoreError), + #[error(transparent)] + Json(#[from] JsonFileStoreError), + #[error("Invalid Session bundle: {0}")] + InvalidBundle(String), +} + +#[derive(Debug, Clone)] +pub struct OfflineSessionImportStore { + layout: SessionStorageLayout, + metadata_store: SessionMetadataStore, + json_store: JsonFileStore, +} + +impl OfflineSessionImportStore { + pub fn new(sessions_root: impl Into) -> Self { + let sessions_root = sessions_root.into(); + Self { + layout: SessionStorageLayout::new(sessions_root.clone()), + metadata_store: SessionMetadataStore::new(sessions_root), + json_store: JsonFileStore, + } + } + + pub async fn write_bundle( + &self, + bundle: &OfflineSessionBundle, + ) -> Result<(), OfflineSessionImportError> { + validate_bundle_shape(bundle)?; + self.metadata_store.save_metadata(&bundle.metadata).await?; + self.layout + .ensure_turns_dir(&bundle.metadata.session_id) + .await + .map_err(|error| { + OfflineSessionImportError::InvalidBundle(format!( + "failed to create Turns directory: {error}" + )) + })?; + for turn in &bundle.turns { + self.json_store + .write_atomic_strict( + &self.layout.turn_path(&turn.session_id, turn.turn_index), + &StoredDialogTurnFile::new(turn.clone()), + ) + .await?; + } + Ok(()) + } + + pub async fn load_bundle( + &self, + session_id: &str, + ) -> Result, OfflineSessionImportError> { + validate_session_id(session_id).map_err(OfflineSessionImportError::InvalidBundle)?; + let Some(stored_metadata) = self + .json_store + .read_optional::(&self.layout.metadata_path(session_id)) + .await? + else { + return Ok(None); + }; + if stored_metadata.schema_version > SESSION_STORAGE_SCHEMA_VERSION { + return Err(OfflineSessionImportError::InvalidBundle(format!( + "Session metadata schema {} is newer than supported schema {}", + stored_metadata.schema_version, SESSION_STORAGE_SCHEMA_VERSION + ))); + } + + let mut turns = Vec::new(); + for (file_index, path) in self + .layout + .list_indexed_turn_paths(session_id) + .await + .map_err(|error| { + OfflineSessionImportError::InvalidBundle(format!( + "failed to list persisted Turns: {error}" + )) + })? + { + let stored = self + .json_store + .read_optional::(&path) + .await? + .ok_or_else(|| { + OfflineSessionImportError::InvalidBundle(format!( + "persisted Turn disappeared while reading {}", + path.display() + )) + })?; + if stored.schema_version > SESSION_STORAGE_SCHEMA_VERSION { + return Err(OfflineSessionImportError::InvalidBundle(format!( + "Turn schema {} is newer than supported schema {}", + stored.schema_version, SESSION_STORAGE_SCHEMA_VERSION + ))); + } + if stored.turn.turn_index != file_index { + return Err(OfflineSessionImportError::InvalidBundle(format!( + "Turn file index {file_index} does not match payload index {}", + stored.turn.turn_index + ))); + } + turns.push(stored.turn); + } + let bundle = OfflineSessionBundle { + metadata: stored_metadata.metadata, + turns, + }; + validate_bundle_shape(&bundle)?; + Ok(Some(bundle)) + } + + pub async fn rebuild_index(&self) -> Result<(), OfflineSessionImportError> { + self.metadata_store.rebuild_index().await?; + Ok(()) + } + + pub fn sessions_root(&self) -> &Path { + self.layout.sessions_root() + } +} + +fn validate_bundle_shape(bundle: &OfflineSessionBundle) -> Result<(), OfflineSessionImportError> { + validate_session_id(&bundle.metadata.session_id) + .map_err(OfflineSessionImportError::InvalidBundle)?; + if bundle.metadata.turn_count != bundle.turns.len() { + return Err(OfflineSessionImportError::InvalidBundle(format!( + "Session {} declares {} Turns but contains {}", + bundle.metadata.session_id, + bundle.metadata.turn_count, + bundle.turns.len() + ))); + } + let mut indices = BTreeSet::new(); + let mut turn_ids = BTreeSet::new(); + for turn in &bundle.turns { + if turn.session_id != bundle.metadata.session_id { + return Err(OfflineSessionImportError::InvalidBundle(format!( + "Turn {} belongs to a different Session", + turn.turn_id + ))); + } + if !indices.insert(turn.turn_index) { + return Err(OfflineSessionImportError::InvalidBundle(format!( + "Session {} contains duplicate Turn index {}", + bundle.metadata.session_id, turn.turn_index + ))); + } + if !turn_ids.insert(turn.turn_id.as_str()) { + return Err(OfflineSessionImportError::InvalidBundle(format!( + "Session {} contains duplicate Turn id", + bundle.metadata.session_id + ))); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + #[tokio::test] + async fn owner_writer_round_trips_a_legacy_bundle_in_the_current_format() { + let fixture = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join( + "../legacy-migration/tests/fixtures/v0.2.19/home/projects/c--fixture-workspace/sessions/session-1", + ); + let stored_metadata: StoredSessionMetadataFile = + serde_json::from_slice(&fs::read(fixture.join("metadata.json")).unwrap()).unwrap(); + let stored_turn: StoredDialogTurnFile = + serde_json::from_slice(&fs::read(fixture.join("turns/turn-0000.json")).unwrap()) + .unwrap(); + let bundle = OfflineSessionBundle { + metadata: stored_metadata.metadata, + turns: vec![stored_turn.turn], + }; + let temp_root = std::env::var_os("OPENBITFUN_TEST_TMPDIR") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from("E:/tmp")); + fs::create_dir_all(&temp_root).unwrap(); + let temp = tempfile::Builder::new() + .prefix("openbitfun-offline-session-") + .tempdir_in(temp_root) + .unwrap(); + let store = OfflineSessionImportStore::new(temp.path()); + + store.write_bundle(&bundle).await.unwrap(); + let reloaded = store.load_bundle("session-1").await.unwrap().unwrap(); + reloaded.validate().unwrap(); + assert_eq!(reloaded.metadata.session_id, "session-1"); + assert_eq!(reloaded.turns.len(), 1); + assert_eq!(reloaded.turns[0].turn_id, "turn-1"); + let stored: StoredSessionMetadataFile = + serde_json::from_slice(&fs::read(temp.path().join("session-1/metadata.json")).unwrap()) + .unwrap(); + assert_eq!(stored.schema_version, SESSION_STORAGE_SCHEMA_VERSION); + } +} diff --git a/src/crates/services/services-core/src/session/types.rs b/src/crates/services/services-core/src/session/types.rs index 32e5794a7d..9e74daf813 100644 --- a/src/crates/services/services-core/src/session/types.rs +++ b/src/crates/services/services-core/src/session/types.rs @@ -339,6 +339,22 @@ impl StoredSessionMetadataFile { } } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StoredDialogTurnFile { + pub schema_version: u32, + #[serde(flatten)] + pub turn: DialogTurnData, +} + +impl StoredDialogTurnFile { + pub fn new(turn: DialogTurnData) -> Self { + Self { + schema_version: SESSION_STORAGE_SCHEMA_VERSION, + turn, + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct StoredSessionIndexFile { pub schema_version: u32, diff --git a/src/crates/services/services-integrations/AGENTS.md b/src/crates/services/services-integrations/AGENTS.md index b734c1dc11..9f462d43ac 100644 --- a/src/crates/services/services-integrations/AGENTS.md +++ b/src/crates/services/services-integrations/AGENTS.md @@ -28,6 +28,10 @@ slices that are outside pure product logic but still platform-neutral. orchestration ports, LAN/ngrok provider helpers, IM bot provider clients, provider-private cursor caches, mobile-web relay upload, image-context adapter contracts, remote workspace helpers, and command/response assembly. +- The `remote-persistence` feature is the lightweight persisted-shape owner shared + by Remote Connect, remote SSH, and offline migration. Keep it free of network, + SSH transport, and runtime orchestration dependencies so owner readers and + writers can validate staged data without enabling those heavier families. - Remote workspace facts, session metadata, file projection DTOs, and workspace/projection host traits belong in `openbitfun-runtime-ports`. - Workspace-root source selection, persistence/workspace service reads, @@ -108,6 +112,7 @@ streamable HTTP stay independent. Representative stable entry points are: ```bash cargo check -p openbitfun-services-integrations --no-default-features +cargo test -p openbitfun-services-integrations --no-default-features --features remote-persistence --lib remote_persistence::tests:: cargo test -p openbitfun-services-integrations --no-default-features --features mcp --test mcp_contracts cargo test -p openbitfun-services-integrations --no-default-features --features mcp --test mcp_streamable_http_contracts cargo test -p openbitfun-services-integrations --no-default-features --features remote-ssh --test remote_ssh_contracts remote_ssh_disabled_contracts:: diff --git a/src/crates/services/services-integrations/Cargo.toml b/src/crates/services/services-integrations/Cargo.toml index 372f5efef5..b38dbdaf51 100644 --- a/src/crates/services/services-integrations/Cargo.toml +++ b/src/crates/services/services-integrations/Cargo.toml @@ -171,7 +171,15 @@ mcp = [ "url", "process-tree", ] +miniapp-storage = [ + "dep:openbitfun-product-domains", + "openbitfun-product-domains/miniapp", + "tokio/fs", + "tokio/time", + "uuid", +] miniapp-runtime = [ + "miniapp-storage", "base64", "openbitfun-product-domains/miniapp", "openbitfun-services-core/process-runtime", @@ -248,6 +256,7 @@ hook-import = [ "uuid", ] remote-connect = [ + "remote-persistence", "anyhow", "aes", "aes-gcm", @@ -297,6 +306,7 @@ remote-connect = [ "tokio-tungstenite?/rustls-tls-native-roots", "urlencoding", "uuid", + "windows", "which", "x25519-dalek", ] @@ -325,6 +335,7 @@ remote-ssh = [ ] remote-ssh-concrete = [ "remote-ssh", + "remote-persistence", "aes-gcm", "anyhow", "async-trait", @@ -347,6 +358,17 @@ remote-ssh-concrete = [ "terminal-core", "thiserror", "uuid", + "windows", +] +remote-persistence = [ + "aes-gcm", + "anyhow", + "base64", + "hostname", + "openbitfun-services-core/product-identity", + "rand", + "sha2", + "windows", ] review-platform = [ "async-trait", diff --git a/src/crates/services/services-integrations/src/lib.rs b/src/crates/services/services-integrations/src/lib.rs index 7ca6e7e521..2e3e7a0686 100644 --- a/src/crates/services/services-integrations/src/lib.rs +++ b/src/crates/services/services-integrations/src/lib.rs @@ -63,7 +63,7 @@ pub mod mcp; #[cfg(feature = "models-dev")] pub mod models_dev; -#[cfg(feature = "miniapp-runtime")] +#[cfg(any(feature = "miniapp-runtime", feature = "miniapp-storage"))] pub mod miniapp; #[cfg(feature = "miniapp-market")] @@ -78,6 +78,9 @@ mod repository_trust; #[cfg(feature = "remote-connect")] pub mod remote_connect; +#[cfg(feature = "remote-persistence")] +pub mod remote_persistence; + #[cfg(all(test, feature = "remote-connect"))] mod feature_contract_tests { #[test] diff --git a/src/crates/services/services-integrations/src/miniapp/mod.rs b/src/crates/services/services-integrations/src/miniapp/mod.rs index 0b72d5f3ce..fe88f7cc1d 100644 --- a/src/crates/services/services-integrations/src/miniapp/mod.rs +++ b/src/crates/services/services-integrations/src/miniapp/mod.rs @@ -1,7 +1,11 @@ //! MiniApp concrete integration services. +#[cfg(feature = "miniapp-runtime")] pub mod builtin_io; +#[cfg(feature = "miniapp-runtime")] pub mod host_dispatch; pub mod storage; +#[cfg(feature = "miniapp-runtime")] pub mod worker; +#[cfg(feature = "miniapp-runtime")] pub mod worker_pool; diff --git a/src/crates/services/services-integrations/src/miniapp/storage.rs b/src/crates/services/services-integrations/src/miniapp/storage.rs index c625f3010a..3df61e62a5 100644 --- a/src/crates/services/services-integrations/src/miniapp/storage.rs +++ b/src/crates/services/services-integrations/src/miniapp/storage.rs @@ -283,6 +283,91 @@ impl MiniAppStorage { Ok(()) } + /// Write a validated import bundle from an offline owner such as the data migrator. + /// + /// Callers must provide an isolated destination root. Product runtime code should + /// continue using the async port; this synchronous entrypoint exists so an offline + /// process does not need to construct the normal MiniApp runtime. + pub fn write_import_bundle_offline( + &self, + request: MiniAppImportBundleWriteRequest, + ) -> MiniAppStorageResult<()> { + let import_layout = MiniAppImportLayout::new(&request.source_path); + Self::validate_import_layout(&request.source_path, &import_layout)?; + let destination = self.layout(&request.app_id); + std::fs::create_dir_all(destination.source_dir()).map_err(|error| { + MiniAppStorageError::io(format!( + "Failed to create offline import directory: {error}" + )) + })?; + std::fs::write(destination.meta_path(), request.meta_json).map_err(|error| { + MiniAppStorageError::io(format!("Failed to write meta.json: {error}")) + })?; + for name in REQUIRED_SOURCE_FILES { + std::fs::copy( + import_layout.source_file_path(name), + destination.source_file_path(name), + ) + .map_err(|error| { + MiniAppStorageError::io(format!("Failed to copy source/{name}: {error}")) + })?; + } + let esm_source = import_layout.esm_dependencies_path(); + if esm_source.exists() { + std::fs::copy(&esm_source, destination.source_file_path(ESM_DEPS_JSON)).map_err( + |error| { + MiniAppStorageError::io(format!( + "Failed to copy esm_dependencies.json: {error}" + )) + }, + )?; + } else { + std::fs::write( + destination.source_file_path(ESM_DEPS_JSON), + request.esm_dependencies_json, + ) + .map_err(|error| { + MiniAppStorageError::io(format!("Failed to write esm_dependencies.json: {error}")) + })?; + } + let package_source = import_layout.package_json_path(); + if package_source.exists() { + std::fs::copy(package_source, destination.package_json_path()).map_err(|error| { + MiniAppStorageError::io(format!("Failed to copy package.json: {error}")) + })?; + } else { + std::fs::write(destination.package_json_path(), request.package_json).map_err( + |error| MiniAppStorageError::io(format!("Failed to write package.json: {error}")), + )?; + } + let storage_source = import_layout.storage_json_path(); + if storage_source.exists() { + std::fs::copy(storage_source, destination.storage_path()).map_err(|error| { + MiniAppStorageError::io(format!("Failed to copy storage.json: {error}")) + })?; + } else { + std::fs::write(destination.storage_path(), request.storage_json).map_err(|error| { + MiniAppStorageError::io(format!("Failed to write storage.json: {error}")) + })?; + } + std::fs::write(destination.compiled_path(), request.compiled_html).map_err(|error| { + MiniAppStorageError::io(format!("Failed to write compiled.html: {error}")) + }) + } + + /// Validate and read an import bundle without starting the MiniApp runtime. + pub fn read_import_meta_json_offline( + &self, + source_path: impl AsRef, + ) -> MiniAppStorageResult { + let source_path = source_path.as_ref(); + let import_layout = MiniAppImportLayout::new(source_path); + Self::validate_import_layout(source_path, &import_layout)?; + std::fs::read_to_string(import_layout.meta_path()).map_err(|error| { + MiniAppStorageError::io(format!("Failed to read offline import meta.json: {error}")) + }) + } + /// Ensure app directory and source subdir exist. pub async fn ensure_app_dir(&self, app_id: &str) -> MiniAppStorageResult<()> { let dir = self.app_dir(app_id); diff --git a/src/crates/services/services-integrations/src/remote_connect.rs b/src/crates/services/services-integrations/src/remote_connect.rs index f36e0f652c..c8abb5ab9c 100644 --- a/src/crates/services/services-integrations/src/remote_connect.rs +++ b/src/crates/services/services-integrations/src/remote_connect.rs @@ -53,7 +53,7 @@ pub use openbitfun_runtime_ports::{ RemoteWorkspaceFileContent, RemoteWorkspaceFileInfo, RemoteWorkspaceFileRuntimeHost, RemoteWorkspaceKind, RemoteWorkspacePort, RemoteWorkspaceRuntimeHost, RemoteWorkspaceUpdate, }; -use openbitfun_services_core::product_identity::{hidden_data_directory, product_id}; +use openbitfun_services_core::product_identity::hidden_data_directory; pub use page_upload::{ create_page_open_link_on_relay, delete_page_from_relay, delete_page_version_on_relay, deploy_page_version_on_relay, join_relay_url, list_page_versions_from_relay, diff --git a/src/crates/services/services-integrations/src/remote_connect/bot/mod.rs b/src/crates/services/services-integrations/src/remote_connect/bot/mod.rs index b6c559d431..0f6438094b 100644 --- a/src/crates/services/services-integrations/src/remote_connect/bot/mod.rs +++ b/src/crates/services/services-integrations/src/remote_connect/bot/mod.rs @@ -511,9 +511,12 @@ fn load_bot_persistence_unlocked() -> BotPersistenceData { let Some(path) = bot_persistence_path() else { return BotPersistenceData::default(); }; - match std::fs::read_to_string(&path) { - Ok(data) => serde_json::from_str(&data).unwrap_or_default(), - Err(_) => { + match crate::remote_persistence::read_bot_persistence(&path) { + Ok(Some(data)) => serde_json::to_value(data) + .ok() + .and_then(|value| serde_json::from_value(value).ok()) + .unwrap_or_default(), + Ok(None) | Err(_) => { // A backup without the canonical file means the process stopped // during the Windows replace dance. Fail closed instead of // restoring a pre-clear account context. @@ -600,7 +603,12 @@ fn save_bot_persistence_unlocked(data: &BotPersistenceData) { return; }; if let Ok(json) = serde_json::to_string_pretty(data) { - if let Err(e) = write_bot_persistence_atomic(&path, json.as_bytes()) { + let owner_valid = + serde_json::from_str::(&json) + .is_ok_and(|value| value.validate().is_ok()); + if !owner_valid { + log::error!("Failed to save bot persistence: owner validation failed"); + } else if let Err(e) = write_bot_persistence_atomic(&path, json.as_bytes()) { log::error!("Failed to save bot persistence: {e}"); } } diff --git a/src/crates/services/services-integrations/src/remote_connect/device.rs b/src/crates/services/services-integrations/src/remote_connect/device.rs index 2798e2ca7a..0964062e6a 100644 --- a/src/crates/services/services-integrations/src/remote_connect/device.rs +++ b/src/crates/services/services-integrations/src/remote_connect/device.rs @@ -12,12 +12,7 @@ use anyhow::{anyhow, Context, Result}; use sha2::{Digest, Sha256}; /// Represents a device's identity used for pairing and account routing. -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct DeviceIdentity { - pub device_id: String, - pub device_name: String, - pub mac_address: String, -} +pub use crate::remote_persistence::DeviceIdentityRecord as DeviceIdentity; static CACHED_IDENTITY: Mutex> = Mutex::new(None); @@ -122,29 +117,15 @@ fn identity_file_path() -> Result { fn load_persisted() -> Result> { let path = identity_file_path()?; - let json = match std::fs::read_to_string(&path) { - Ok(data) => data, - Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), - Err(e) => { - return Err(anyhow!("read device identity: {e}").context(path.display().to_string())) - } - }; - let identity: DeviceIdentity = - serde_json::from_str(&json).context("parse device identity file")?; - if !is_valid_device_id(&identity.device_id) { - return Err(anyhow!("persisted device_id is invalid")); - } - Ok(Some(identity)) + crate::remote_persistence::read_device_identity(&path) + .context("read device identity") + .map_err(|error| error.context(path.display().to_string())) } fn save_persisted(identity: &DeviceIdentity) -> Result<()> { let path = identity_file_path()?; - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent).context("create device identity dir")?; - } - let json = serde_json::to_string_pretty(identity).context("serialize device identity")?; - std::fs::write(&path, json).context("write device identity file")?; - Ok(()) + crate::remote_persistence::write_device_identity(&path, identity) + .context("write device identity file") } fn cache_identity(identity: DeviceIdentity) { diff --git a/src/crates/services/services-integrations/src/remote_connect/session_store.rs b/src/crates/services/services-integrations/src/remote_connect/session_store.rs index d4d3b0fa02..c76ca587e7 100644 --- a/src/crates/services/services-integrations/src/remote_connect/session_store.rs +++ b/src/crates/services/services-integrations/src/remote_connect/session_store.rs @@ -12,19 +12,9 @@ //! Format: base64(nonce || ciphertext) where the plaintext is a JSON //! payload `{ token, user_id, master_key_b64, relay_url }`. +use anyhow::{anyhow, Result}; use std::path::PathBuf; use std::sync::{OnceLock, RwLock}; -use std::{fs::OpenOptions, io::Write}; - -use aes_gcm::aead::{Aead, KeyInit, OsRng}; -use aes_gcm::{Aes256Gcm, Nonce}; -use anyhow::{anyhow, Context, Result}; -use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; -use rand::RngCore; -use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; - -const NONCE_SIZE: usize = 12; fn session_store_directory_override() -> &'static RwLock> { static OVERRIDE: OnceLock>> = OnceLock::new(); @@ -52,216 +42,11 @@ fn session_store_directory() -> Result { super::product_home_dir().ok_or_else(|| anyhow!("cannot determine OpenBitFun home directory")) } -/// The on-disk JSON payload (plaintext before encryption). -#[derive(Serialize, Deserialize)] -struct SessionPayload { - token: String, - user_id: String, - /// Base64-encoded 32-byte master key. - master_key_b64: String, - relay_url: String, - /// Account-bound device id used when the session token was issued. - /// Optional for backward compatibility with sessions saved before this field. - #[serde(default, skip_serializing_if = "Option::is_none")] - device_id: Option, -} - /// Resolve the persistent session file path. fn session_file_path() -> Result { Ok(session_store_directory()?.join("account_session.enc")) } -fn session_key_file_path() -> Result { - Ok(session_store_directory()?.join("account_session.key")) -} - -/// Atomically replace a secret-bearing file and restrict it to the current -/// OS user where Unix permission bits are available. The parent is also made -/// private so a permissive process umask cannot expose account material. -fn write_private_file(path: &std::path::Path, contents: &[u8]) -> Result<()> { - let parent = path - .parent() - .ok_or_else(|| anyhow!("secret file has no parent directory"))?; - std::fs::create_dir_all(parent).context("create private data directory")?; - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700)) - .context("restrict private data directory permissions")?; - } - - let mut random = [0u8; 8]; - OsRng.fill_bytes(&mut random); - let suffix = random - .iter() - .map(|byte| format!("{byte:02x}")) - .collect::(); - let file_name = path - .file_name() - .and_then(|name| name.to_str()) - .ok_or_else(|| anyhow!("secret file has an invalid name"))?; - let temp_path = parent.join(format!(".{file_name}.{suffix}.tmp")); - - let mut options = OpenOptions::new(); - options.write(true).create_new(true); - #[cfg(unix)] - { - use std::os::unix::fs::OpenOptionsExt; - options.mode(0o600); - } - let write_result = (|| -> Result<()> { - let mut file = options - .open(&temp_path) - .context("create temporary secret file")?; - file.write_all(contents) - .context("write temporary secret file")?; - file.sync_all().context("flush temporary secret file")?; - drop(file); - - #[cfg(windows)] - if path.exists() { - std::fs::remove_file(path).context("replace existing secret file")?; - } - std::fs::rename(&temp_path, path).context("install private secret file")?; - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)) - .context("restrict secret file permissions")?; - } - Ok(()) - })(); - if write_result.is_err() { - let _ = std::fs::remove_file(&temp_path); - } - write_result -} - -/// Derive a machine-bound AES-256 key from stable machine identifiers. -/// -/// Combines hostname, OS username, OS type, and platform constant into -/// SHA-256 to produce a 32-byte key. The file is useless on a different -/// machine (different hostname / username combination). -fn derive_machine_key(local_secret: &[u8; 32]) -> [u8; 32] { - let mut hasher = Sha256::new(); - - // Hostname / machine name - if let Some(hostname) = hostname_string() { - hasher.update(hostname.as_bytes()); - } - hasher.update(b"|"); - - // OS username - if let Some(username) = username_string() { - hasher.update(username.as_bytes()); - } - hasher.update(b"|"); - - // OS type (linux / macos / windows) - hasher.update(std::env::consts::OS.as_bytes()); - hasher.update(b"|"); - - // Product-specific domain separation prevents credentials from crossing - // product data namespaces even when they share the same machine account. - hasher.update(super::product_id().as_bytes()); - hasher.update(b"::session_store::v1|"); - hasher.update(local_secret); - let result = hasher.finalize(); - let mut key = [0u8; 32]; - key.copy_from_slice(&result); - key -} - -fn load_or_create_local_session_secret() -> Result<[u8; 32]> { - let path = session_key_file_path()?; - match std::fs::read(&path) { - Ok(bytes) => { - return bytes - .try_into() - .map_err(|_| anyhow!("local account session key has an invalid length")); - } - Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} - Err(error) => return Err(anyhow!("read local account session key: {error}")), - } - - let mut secret = [0u8; 32]; - OsRng.fill_bytes(&mut secret); - let parent = path - .parent() - .ok_or_else(|| anyhow!("session key has no parent directory"))?; - std::fs::create_dir_all(parent).context("create session key directory")?; - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700)) - .context("restrict session key directory permissions")?; - } - - let mut options = OpenOptions::new(); - options.write(true).create_new(true); - #[cfg(unix)] - { - use std::os::unix::fs::OpenOptionsExt; - options.mode(0o600); - } - match options.open(&path) { - Ok(mut file) => { - file.write_all(&secret) - .context("write local account session key")?; - file.sync_all().context("flush local account session key")?; - Ok(secret) - } - Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { - let bytes = std::fs::read(&path).context("read concurrently created session key")?; - bytes - .try_into() - .map_err(|_| anyhow!("local account session key has an invalid length")) - } - Err(error) => Err(anyhow!("create local account session key: {error}")), - } -} - -/// Best-effort hostname retrieval (cross-platform). -fn hostname_string() -> Option { - // `hostname::get()` from the `hostname` crate would be cleaner, but - // to avoid adding a new dependency we use environment / std methods. - // - // On Unix: read /etc/hostname or call `gethostname` via libc. - // On Windows: `%COMPUTERNAME%`. - if cfg!(target_os = "windows") { - std::env::var("COMPUTERNAME").ok() - } else { - // Try `hostname` command as a portable fallback. - std::process::Command::new("hostname") - .output() - .ok() - .and_then(|o| String::from_utf8(o.stdout).ok()) - .map(|s| s.trim().to_string()) - .or_else(|| { - std::fs::read_to_string("/etc/hostname") - .ok() - .map(|s| s.trim().to_string()) - }) - } -} - -/// Best-effort current username retrieval (cross-platform). -fn username_string() -> Option { - if cfg!(target_os = "windows") { - std::env::var("USERNAME") - .or_else(|_| std::env::var("USER")) - .ok() - } else { - std::env::var("USER").ok().or_else(|| { - std::process::Command::new("whoami") - .output() - .ok() - .and_then(|o| String::from_utf8(o.stdout).ok()) - .map(|s| s.trim().to_string()) - }) - } -} - // ── Public API ────────────────────────────────────────────────────────── /// Persist the session (token, master_key, user_id, relay_url) to disk, @@ -283,41 +68,21 @@ pub fn save_session_with_device( relay_url: &str, device_id: Option<&str>, ) -> Result<()> { - let payload = SessionPayload { + let payload = crate::remote_persistence::AccountSessionRecord { token: token.to_string(), user_id: user_id.to_string(), - master_key_b64: BASE64.encode(master_key), + master_key: *master_key, relay_url: relay_url.to_string(), device_id: device_id .map(str::trim) .filter(|id| !id.is_empty()) .map(str::to_string), }; - let json = serde_json::to_string(&payload).context("serialize session payload")?; - - let local_secret = load_or_create_local_session_secret()?; - let key = derive_machine_key(&local_secret); - let cipher = Aes256Gcm::new_from_slice(&key).map_err(|e| anyhow!("cipher init: {e}"))?; - - let mut nonce_bytes = [0u8; NONCE_SIZE]; - OsRng.fill_bytes(&mut nonce_bytes); - let nonce = Nonce::from_slice(&nonce_bytes); - - let ciphertext = cipher - .encrypt(nonce, json.as_bytes()) - .map_err(|e| anyhow!("encrypt session: {e}"))?; - - // Pack: nonce (12 bytes) || ciphertext, then base64-encode the whole thing. - let mut packed = Vec::with_capacity(NONCE_SIZE + ciphertext.len()); - packed.extend_from_slice(&nonce_bytes); - packed.extend_from_slice(&ciphertext); - let encoded = BASE64.encode(&packed); - - let path = session_file_path()?; - write_private_file(&path, encoded.as_bytes()).context("write session file")?; - - log::debug!("Session persisted to {:?}", path); - Ok(()) + crate::remote_persistence::write_current_account_session( + &session_store_directory()?, + &crate::remote_persistence::MachineBinding::current(), + &payload, + ) } /// Loaded account session fields from disk. @@ -338,52 +103,14 @@ pub fn load_session() -> Result> { /// Load and decrypt the session, including optional account-bound `device_id`. pub fn load_session_detailed() -> Result> { - let path = session_file_path()?; - let encoded = match std::fs::read_to_string(&path) { - Ok(data) => data, - Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), - Err(e) => { - return Err( - anyhow!("read session file: {e}").context(path.to_string_lossy().to_string()) - ) - } - }; - - let packed = BASE64 - .decode(encoded.trim()) - .map_err(|e| anyhow!("base64 decode session: {e}"))?; - if packed.len() < NONCE_SIZE { - return Err(anyhow!("session file too short")); - } - - let (nonce_bytes, ciphertext) = packed.split_at(NONCE_SIZE); - let nonce_arr: [u8; NONCE_SIZE] = nonce_bytes - .try_into() - .map_err(|_| anyhow!("nonce conversion"))?; - - let local_secret = load_or_create_local_session_secret()?; - let key = derive_machine_key(&local_secret); - let cipher = Aes256Gcm::new_from_slice(&key).map_err(|e| anyhow!("cipher init: {e}"))?; - let plaintext = cipher - .decrypt(Nonce::from_slice(&nonce_arr), ciphertext) - .map_err(|e| anyhow!("decrypt session: {e}"))?; - - let payload: SessionPayload = - serde_json::from_slice(&plaintext).context("deserialize session payload")?; - - let master_key_bytes = BASE64 - .decode(&payload.master_key_b64) - .map_err(|e| anyhow!("decode master key: {e}"))?; - let mut master_key = [0u8; 32]; - if master_key_bytes.len() != 32 { - return Err(anyhow!("invalid master key length")); - } - master_key.copy_from_slice(&master_key_bytes); - - Ok(Some(LoadedSession { + Ok(crate::remote_persistence::read_current_account_session( + &session_store_directory()?, + &crate::remote_persistence::MachineBinding::current(), + )? + .map(|payload| LoadedSession { token: payload.token, user_id: payload.user_id, - master_key, + master_key: payload.master_key, relay_url: payload.relay_url, device_id: payload.device_id, })) @@ -404,11 +131,7 @@ fn credential_hint_path() -> Result { } /// Non-secret login pre-fill (never stores password or master key). -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct AccountHint { - pub username: String, - pub relay_url: String, -} +pub use crate::remote_persistence::AccountHintRecord as AccountHint; /// Persist username + relay URL for the next login form. pub fn save_credential_hint(username: &str, relay_url: &str) { @@ -416,22 +139,16 @@ pub fn save_credential_hint(username: &str, relay_url: &str) { username: username.to_string(), relay_url: relay_url.to_string(), }; - let Ok(path) = credential_hint_path() else { - return; - }; - if let Some(parent) = path.parent() { - let _ = std::fs::create_dir_all(parent); - } - if let Ok(json) = serde_json::to_string(&hint) { - let _ = write_private_file(&path, json.as_bytes()); + if let Ok(path) = credential_hint_path() { + let _ = crate::remote_persistence::write_account_hint(&path, &hint); } } /// Load the persisted credential hint, if any. pub fn load_credential_hint() -> Option { - let path = credential_hint_path().ok()?; - let json = std::fs::read_to_string(&path).ok()?; - serde_json::from_str(&json).ok() + crate::remote_persistence::read_account_hint(&credential_hint_path().ok()?) + .ok() + .flatten() } /// Clear the credential hint (called on logout). @@ -452,8 +169,8 @@ mod tests { let private_dir = root.path().join("private"); let path = private_dir.join("session.enc"); - write_private_file(&path, b"first").unwrap(); - write_private_file(&path, b"second").unwrap(); + crate::remote_persistence::write_private_bytes(&path, b"first").unwrap(); + crate::remote_persistence::write_private_bytes(&path, b"second").unwrap(); assert_eq!(std::fs::read(&path).unwrap(), b"second"); assert_eq!( diff --git a/src/crates/services/services-integrations/src/remote_connect/sync_state.rs b/src/crates/services/services-integrations/src/remote_connect/sync_state.rs index d2fdbc17d7..5e79cce555 100644 --- a/src/crates/services/services-integrations/src/remote_connect/sync_state.rs +++ b/src/crates/services/services-integrations/src/remote_connect/sync_state.rs @@ -10,35 +10,19 @@ //! backup loop and the settings sync engine are independent writers, so a //! shared read-modify-write file could drop one writer's update. -use std::collections::HashMap; use std::path::PathBuf; use anyhow::{anyhow, Result}; -use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; /// On-disk sync progress for one account. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct AccountSyncState { - /// Highest relay session `version` successfully processed by a pull. - #[serde(default)] - pub last_session_since: i64, - /// Last successfully uploaded content hash per session_id. - #[serde(default)] - pub uploaded_hashes: HashMap, -} +pub use crate::remote_persistence::AccountSyncStateRecord as AccountSyncState; /// Settings sync progress for one account: the cloud settings blob version /// this device last uploaded or applied, plus the content hash of that blob. /// Lets the periodic pull skip unchanged blobs across restarts and the push /// path skip unchanged content. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct SettingsCursor { - #[serde(default)] - pub version: i64, - #[serde(default)] - pub hash: String, -} +pub use crate::remote_persistence::SettingsCursorRecord as SettingsCursor; /// SHA-256 hex digest of session bundle plaintext (stable skip key). pub fn content_hash(plaintext: &str) -> String { @@ -79,22 +63,16 @@ pub fn load(user_id: &str) -> AccountSyncState { Ok(p) => p, Err(_) => return AccountSyncState::default(), }; - match std::fs::read_to_string(&path) { - Ok(raw) => serde_json::from_str(&raw).unwrap_or_default(), - Err(_) => AccountSyncState::default(), - } + crate::remote_persistence::read_account_sync_state(&path) + .ok() + .flatten() + .unwrap_or_default() } /// Persist sync state for `user_id`. pub fn save(user_id: &str, state: &AccountSyncState) -> Result<()> { - let dir = sync_dir()?; - std::fs::create_dir_all(&dir)?; let path = sync_state_path(user_id)?; - let raw = serde_json::to_string_pretty(state)?; - let tmp = path.with_extension("json.tmp"); - std::fs::write(&tmp, raw)?; - std::fs::rename(&tmp, &path)?; - Ok(()) + crate::remote_persistence::write_account_sync_state(&path, state) } /// Load the settings cursor for `user_id`, defaulting when missing/corrupt. @@ -103,22 +81,16 @@ pub fn load_settings_cursor(user_id: &str) -> SettingsCursor { Ok(p) => p, Err(_) => return SettingsCursor::default(), }; - match std::fs::read_to_string(&path) { - Ok(raw) => serde_json::from_str(&raw).unwrap_or_default(), - Err(_) => SettingsCursor::default(), - } + crate::remote_persistence::read_settings_cursor(&path) + .ok() + .flatten() + .unwrap_or_default() } /// Persist the settings cursor for `user_id`. pub fn save_settings_cursor(user_id: &str, cursor: &SettingsCursor) -> Result<()> { - let dir = sync_dir()?; - std::fs::create_dir_all(&dir)?; let path = settings_cursor_path(user_id)?; - let raw = serde_json::to_string_pretty(cursor)?; - let tmp = path.with_extension("json.tmp"); - std::fs::write(&tmp, raw)?; - std::fs::rename(&tmp, &path)?; - Ok(()) + crate::remote_persistence::write_settings_cursor(&path, cursor) } impl AccountSyncState { diff --git a/src/crates/services/services-integrations/src/remote_persistence.rs b/src/crates/services/services-integrations/src/remote_persistence.rs new file mode 100644 index 0000000000..3c7bda28c3 --- /dev/null +++ b/src/crates/services/services-integrations/src/remote_persistence.rs @@ -0,0 +1,1255 @@ +//! Path-explicit persistence owners shared by Remote Connect, Remote SSH, and +//! the offline retired-product migrator. +//! +//! Runtime loaders may intentionally recover from missing or corrupt files by +//! returning defaults. Importers must not do that: every reader in this module +//! is strict, bounded by its caller, and never logs persisted content. + +use aes_gcm::aead::{Aead, KeyInit, OsRng}; +use aes_gcm::{Aes256Gcm, Nonce}; +use anyhow::{anyhow, bail, Context, Result}; +use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; +use rand::RngCore; +use serde::de::DeserializeOwned; +use serde::{Deserialize, Deserializer, Serialize}; +use sha2::{Digest, Sha256}; +use std::collections::{BTreeMap, HashMap}; +use std::fs::OpenOptions; +use std::io::Write; +use std::path::{Path, PathBuf}; + +const NONCE_SIZE: usize = 12; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct DeviceIdentityRecord { + pub device_id: String, + pub device_name: String, + pub mac_address: String, +} + +impl DeviceIdentityRecord { + pub fn validate(&self) -> Result<()> { + if self.device_id.len() != 32 + || !self + .device_id + .chars() + .all(|character| character.is_ascii_hexdigit()) + { + bail!("device identity has an invalid stable id"); + } + if self.device_name.trim().is_empty() || self.mac_address.trim().is_empty() { + bail!("device identity is missing display metadata"); + } + Ok(()) + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct AccountHintRecord { + pub username: String, + pub relay_url: String, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct AccountSyncStateRecord { + #[serde(default)] + pub last_session_since: i64, + #[serde(default)] + pub uploaded_hashes: HashMap, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct SettingsCursorRecord { + #[serde(default)] + pub version: i64, + #[serde(default)] + pub hash: String, +} + +#[derive(Clone, PartialEq, Eq)] +pub struct AccountSessionRecord { + pub token: String, + pub user_id: String, + pub master_key: [u8; 32], + pub relay_url: String, + pub device_id: Option, +} + +impl std::fmt::Debug for AccountSessionRecord { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("AccountSessionRecord") + .field("token", &"[REDACTED]") + .field("user_id", &self.user_id) + .field("master_key", &"[REDACTED]") + .field("relay_url", &self.relay_url) + .field("device_id", &self.device_id) + .finish() + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MachineBinding { + pub hostname: String, + pub username: String, + pub os: String, +} + +impl MachineBinding { + pub fn current() -> Self { + let hostname = hostname::get() + .ok() + .and_then(|value| value.into_string().ok()) + .unwrap_or_default(); + let username = std::env::var("USERNAME") + .or_else(|_| std::env::var("USER")) + .unwrap_or_default(); + Self { + hostname, + username, + os: std::env::consts::OS.to_string(), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct LegacyAccountSessionKeyDomains<'a> { + pub v1: &'a [u8], + pub v2: &'a [u8], +} + +#[derive(Serialize, Deserialize)] +struct AccountSessionPayload { + token: String, + user_id: String, + master_key_b64: String, + relay_url: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + device_id: Option, +} + +pub fn read_device_identity(path: &Path) -> Result> { + let value: Option = read_optional_json(path)?; + if let Some(value) = &value { + value.validate()?; + } + Ok(value) +} + +pub fn write_device_identity(path: &Path, value: &DeviceIdentityRecord) -> Result<()> { + value.validate()?; + write_json_atomic(path, value, false) +} + +pub fn read_account_hint(path: &Path) -> Result> { + read_optional_json(path) +} + +pub fn write_account_hint(path: &Path, value: &AccountHintRecord) -> Result<()> { + write_json_atomic(path, value, true) +} + +pub fn read_account_sync_state(path: &Path) -> Result> { + read_optional_json(path) +} + +pub fn write_account_sync_state(path: &Path, value: &AccountSyncStateRecord) -> Result<()> { + write_json_atomic(path, value, false) +} + +pub fn read_settings_cursor(path: &Path) -> Result> { + read_optional_json(path) +} + +pub fn write_settings_cursor(path: &Path, value: &SettingsCursorRecord) -> Result<()> { + write_json_atomic(path, value, false) +} + +pub fn read_legacy_account_session( + directory: &Path, + binding: &MachineBinding, + domains: LegacyAccountSessionKeyDomains<'_>, +) -> Result> { + if domains.v1.is_empty() || domains.v2.is_empty() { + bail!("legacy account session key domains must not be empty"); + } + let encrypted_path = directory.join("account_session.enc"); + let Some(packed) = read_encrypted_blob(&encrypted_path)? else { + return Ok(None); + }; + let local_secret = match std::fs::read(directory.join("account_session.key")) { + Ok(bytes) => Some( + bytes + .try_into() + .map_err(|_| anyhow!("legacy account session key has an invalid length"))?, + ), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, + Err(error) => return Err(error).context("read legacy account session key"), + }; + let (nonce, ciphertext) = split_ciphertext(&packed)?; + let mut plaintext = None; + if let Some(secret) = local_secret { + let key = derive_legacy_v2_key(binding, &secret, domains); + plaintext = decrypt(&key, nonce, ciphertext).ok(); + } + if plaintext.is_none() { + let key = derive_legacy_v1_key(binding, domains.v1); + plaintext = decrypt(&key, nonce, ciphertext).ok(); + } + let plaintext = + plaintext.ok_or_else(|| anyhow!("legacy account session cannot be decrypted"))?; + decode_account_session_payload(&plaintext) + .context("validate legacy account session") + .map(Some) +} + +pub fn read_current_account_session( + directory: &Path, + binding: &MachineBinding, +) -> Result> { + let Some(packed) = read_encrypted_blob(&directory.join("account_session.enc"))? else { + return Ok(None); + }; + let secret: [u8; 32] = std::fs::read(directory.join("account_session.key")) + .context("read current account session key")? + .try_into() + .map_err(|_| anyhow!("current account session key has an invalid length"))?; + let (nonce, ciphertext) = split_ciphertext(&packed)?; + let key = derive_current_key(binding, &secret); + let plaintext = decrypt(&key, nonce, ciphertext) + .map_err(|_| anyhow!("current account session cannot be decrypted"))?; + decode_account_session_payload(&plaintext) + .context("validate current account session") + .map(Some) +} + +pub fn write_current_account_session( + directory: &Path, + binding: &MachineBinding, + session: &AccountSessionRecord, +) -> Result<()> { + validate_account_session(session)?; + std::fs::create_dir_all(directory).context("create account session directory")?; + let key_path = directory.join("account_session.key"); + let secret = match std::fs::read(&key_path) { + Ok(bytes) => bytes + .try_into() + .map_err(|_| anyhow!("current account session key has an invalid length"))?, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + let mut value = [0u8; 32]; + OsRng.fill_bytes(&mut value); + write_atomic(&key_path, &value, true)?; + value + } + Err(error) => return Err(error).context("read current account session key"), + }; + let payload = AccountSessionPayload { + token: session.token.clone(), + user_id: session.user_id.clone(), + master_key_b64: BASE64.encode(session.master_key), + relay_url: session.relay_url.clone(), + device_id: session.device_id.clone(), + }; + let plaintext = serde_json::to_vec(&payload).context("serialize account session payload")?; + let key = derive_current_key(binding, &secret); + let mut nonce = [0u8; NONCE_SIZE]; + OsRng.fill_bytes(&mut nonce); + let ciphertext = Aes256Gcm::new_from_slice(&key) + .map_err(|_| anyhow!("initialize current account session cipher"))? + .encrypt(Nonce::from_slice(&nonce), plaintext.as_slice()) + .map_err(|_| anyhow!("encrypt current account session"))?; + let mut packed = Vec::with_capacity(NONCE_SIZE + ciphertext.len()); + packed.extend_from_slice(&nonce); + packed.extend_from_slice(&ciphertext); + write_atomic( + &directory.join("account_session.enc"), + BASE64.encode(packed).as_bytes(), + true, + ) +} + +fn validate_account_session(session: &AccountSessionRecord) -> Result<()> { + if session.token.trim().is_empty() + || session.user_id.trim().is_empty() + || session.relay_url.trim().is_empty() + { + bail!("account session is missing required identity fields"); + } + if session.device_id.as_deref().is_some_and(|device_id| { + device_id.len() != 32 || !device_id.chars().all(|value| value.is_ascii_hexdigit()) + }) { + bail!("account session has an invalid device id"); + } + Ok(()) +} + +fn decode_account_session_payload(bytes: &[u8]) -> Result { + let payload: AccountSessionPayload = + serde_json::from_slice(bytes).context("deserialize account session payload")?; + let master_key: [u8; 32] = BASE64 + .decode(payload.master_key_b64) + .context("decode account session master key")? + .try_into() + .map_err(|_| anyhow!("account session master key has an invalid length"))?; + let session = AccountSessionRecord { + token: payload.token, + user_id: payload.user_id, + master_key, + relay_url: payload.relay_url, + device_id: payload.device_id, + }; + validate_account_session(&session)?; + Ok(session) +} + +fn derive_legacy_v1_key(binding: &MachineBinding, domain: &[u8]) -> [u8; 32] { + derive_machine_domain_key(binding, domain, None) +} + +fn derive_legacy_v2_key( + binding: &MachineBinding, + local_secret: &[u8; 32], + domains: LegacyAccountSessionKeyDomains<'_>, +) -> [u8; 32] { + let legacy = derive_legacy_v1_key(binding, domains.v1); + let mut hasher = Sha256::new(); + hasher.update(legacy); + hasher.update(domains.v2); + hasher.update(local_secret); + hasher.finalize().into() +} + +fn derive_current_key(binding: &MachineBinding, local_secret: &[u8; 32]) -> [u8; 32] { + let product_id = openbitfun_services_core::product_identity::product_id(); + derive_machine_domain_key( + binding, + format!("{product_id}::session_store::v1|").as_bytes(), + Some(local_secret), + ) +} + +fn derive_machine_domain_key( + binding: &MachineBinding, + domain: &[u8], + local_secret: Option<&[u8; 32]>, +) -> [u8; 32] { + let mut hasher = Sha256::new(); + hasher.update(binding.hostname.as_bytes()); + hasher.update(b"|"); + hasher.update(binding.username.as_bytes()); + hasher.update(b"|"); + hasher.update(binding.os.as_bytes()); + hasher.update(b"|"); + hasher.update(domain); + if let Some(secret) = local_secret { + hasher.update(secret); + } + hasher.finalize().into() +} + +fn read_encrypted_blob(path: &Path) -> Result>> { + let encoded = match std::fs::read_to_string(path) { + Ok(value) => value, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(error).context("read encrypted persistence file"), + }; + BASE64 + .decode(encoded.trim()) + .context("decode encrypted persistence file") + .map(Some) +} + +fn split_ciphertext(packed: &[u8]) -> Result<(&[u8], &[u8])> { + if packed.len() <= NONCE_SIZE { + bail!("encrypted persistence file is too short"); + } + Ok(packed.split_at(NONCE_SIZE)) +} + +fn decrypt(key: &[u8; 32], nonce: &[u8], ciphertext: &[u8]) -> Result> { + Aes256Gcm::new_from_slice(key) + .map_err(|_| anyhow!("initialize encrypted persistence cipher"))? + .decrypt(Nonce::from_slice(nonce), ciphertext) + .map_err(|_| anyhow!("decrypt encrypted persistence payload")) +} + +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "bot_type", rename_all = "snake_case")] +pub enum BotConfigRecord { + Feishu { + app_id: String, + app_secret: String, + }, + Telegram { + bot_token: String, + }, + Weixin { + ilink_token: String, + base_url: String, + bot_account_id: String, + }, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum BotDisplayModeRecord { + #[serde(rename = "pro")] + Pro, + #[default] + #[serde(rename = "assistant")] + Assistant, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct BotWorkspaceRefRecord { + pub path: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub remote_connection_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub remote_ssh_host: Option, +} + +fn deserialize_workspace_ref<'de, D>( + deserializer: D, +) -> std::result::Result, D::Error> +where + D: Deserializer<'de>, +{ + #[derive(Deserialize)] + #[serde(untagged)] + enum Raw { + Path(String), + Full(BotWorkspaceRefRecord), + } + match Option::::deserialize(deserializer)? { + None => Ok(None), + Some(Raw::Path(path)) if path.trim().is_empty() => Ok(None), + Some(Raw::Path(path)) => Ok(Some(BotWorkspaceRefRecord { + path, + remote_connection_id: None, + remote_ssh_host: None, + })), + Some(Raw::Full(value)) if value.path.trim().is_empty() => Ok(None), + Some(Raw::Full(value)) => Ok(Some(value)), + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct BotChatStateRecord { + pub chat_id: String, + pub paired: bool, + #[serde( + default, + deserialize_with = "deserialize_workspace_ref", + skip_serializing_if = "Option::is_none" + )] + pub current_workspace: Option, + pub current_assistant: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub current_assistant_name: Option, + pub current_session_id: Option, + #[serde(default)] + pub display_mode: BotDisplayModeRecord, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub account_remote_context: bool, +} + +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SavedBotConnectionRecord { + pub bot_type: String, + pub chat_id: String, + pub config: BotConfigRecord, + pub chat_state: BotChatStateRecord, + pub connected_at: i64, +} + +#[derive(Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct RemoteConnectFormStateRecord { + pub custom_server_url: String, + pub telegram_bot_token: String, + pub feishu_app_id: String, + pub feishu_app_secret: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub weixin_ilink_token: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub weixin_base_url: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub weixin_bot_account_id: String, +} + +#[derive(Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct BotPersistenceRecord { + #[serde(default)] + pub connections: Vec, + #[serde(default)] + pub form_state: RemoteConnectFormStateRecord, + #[serde(default)] + pub verbose_mode: bool, +} + +impl std::fmt::Debug for BotPersistenceRecord { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("BotPersistenceRecord") + .field("connection_count", &self.connections.len()) + .field("verbose_mode", &self.verbose_mode) + .field("credentials", &"[REDACTED]") + .finish() + } +} + +impl BotPersistenceRecord { + pub fn validate(&self) -> Result<()> { + let mut types = std::collections::BTreeSet::new(); + for connection in &self.connections { + if connection.bot_type.trim().is_empty() + || connection.chat_id != connection.chat_state.chat_id + || !types.insert(connection.bot_type.as_str()) + { + bail!("bot persistence contains an invalid or duplicate connection"); + } + let config_type = match connection.config { + BotConfigRecord::Feishu { .. } => "feishu", + BotConfigRecord::Telegram { .. } => "telegram", + BotConfigRecord::Weixin { .. } => "weixin", + }; + if connection.bot_type != config_type { + bail!("bot persistence connection type does not match its configuration"); + } + } + Ok(()) + } +} + +pub fn read_bot_persistence(path: &Path) -> Result> { + let value: Option = read_optional_json(path)?; + if let Some(value) = &value { + value.validate()?; + } + Ok(value) +} + +pub fn write_bot_persistence(path: &Path, value: &BotPersistenceRecord) -> Result<()> { + value.validate()?; + write_json_atomic(path, value, true) +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum ContainerAccessRecord { + Sshd, + DockerExec, + Auto, +} + +fn default_docker_path() -> String { + "docker".to_string() +} +fn default_container_shell() -> String { + "/bin/sh".to_string() +} +fn default_true() -> bool { + true +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ContainerWorkspaceRecord { + pub name: String, + pub access: ContainerAccessRecord, + #[serde(default)] + pub local: bool, + #[serde(default = "default_docker_path")] + pub docker_path: String, + #[serde(default = "default_container_shell")] + pub shell: String, + #[serde(default)] + pub user: Option, + #[serde(default = "default_true")] + pub interactive: bool, +} + +fn default_connect_timeout_secs() -> u64 { + 30 +} +fn default_auth_timeout_secs() -> u64 { + 60 +} +fn default_auth_attempts() -> u8 { + 3 +} +fn default_connect_attempts() -> u8 { + 1 +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SshConnectionOptionsRecord { + #[serde(default = "default_connect_timeout_secs")] + pub connect_timeout_secs: u64, + #[serde(default = "default_auth_timeout_secs")] + pub auth_timeout_secs: u64, + #[serde(default = "default_auth_attempts")] + pub auth_attempts: u8, + #[serde(default = "default_connect_attempts")] + pub connect_attempts: u8, +} + +impl Default for SshConnectionOptionsRecord { + fn default() -> Self { + Self { + connect_timeout_secs: default_connect_timeout_secs(), + auth_timeout_secs: default_auth_timeout_secs(), + auth_attempts: default_auth_attempts(), + connect_attempts: default_connect_attempts(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type")] +pub enum SavedAuthTypeRecord { + Password, + PrivateKey { + #[serde(rename = "keyPath")] + key_path: String, + #[serde(default, rename = "certificatePath")] + certificate_path: Option, + }, + Agent { + #[serde(default, rename = "keyFingerprint")] + key_fingerprint: Option, + #[serde(default, rename = "fallbackKeyPath")] + fallback_key_path: Option, + }, + KeyboardInteractive, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SavedConnectionRecord { + pub id: String, + pub name: String, + pub host: String, + pub port: u16, + pub username: String, + #[serde(rename = "authType")] + pub auth_type: SavedAuthTypeRecord, + #[serde(rename = "defaultWorkspace")] + pub default_workspace: Option, + #[serde(rename = "lastConnected")] + pub last_connected: Option, + #[serde(default)] + pub proxy_jump: Option, + #[serde(default)] + pub container: Option, + #[serde(default)] + pub options: SshConnectionOptionsRecord, +} + +impl SavedConnectionRecord { + pub fn validate(&self) -> Result<()> { + if self.id.trim().is_empty() || self.name.trim().is_empty() { + bail!("saved SSH connection is missing its identity"); + } + if self + .container + .as_ref() + .is_none_or(|container| !container.local) + && (self.host.trim().is_empty() || self.username.trim().is_empty() || self.port == 0) + { + bail!("saved SSH connection is missing host information"); + } + Ok(()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RemoteWorkspaceRecord { + #[serde(default)] + pub connection_id: String, + #[serde(default)] + pub remote_path: String, + #[serde(default)] + pub connection_name: String, + #[serde(default)] + pub ssh_host: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct KnownHostRecord { + pub host: String, + pub port: u16, + pub key_type: String, + pub fingerprint: String, + pub public_key: String, +} + +pub fn read_saved_connections(path: &Path) -> Result>> { + let values: Option> = read_optional_json(path)?; + if let Some(values) = &values { + for value in values { + value.validate()?; + } + } + Ok(values) +} + +pub fn write_saved_connections(path: &Path, values: &[SavedConnectionRecord]) -> Result<()> { + for value in values { + value.validate()?; + } + write_json_atomic(path, values, false) +} + +pub fn read_current_remote_workspaces(path: &Path) -> Result>> { + let values: Option> = read_optional_json(path)?; + if let Some(values) = &values { + validate_remote_workspaces(values)?; + } + Ok(values) +} + +pub fn read_legacy_remote_workspaces(path: &Path) -> Result>> { + let value: Option = read_optional_json(path)?; + let values = value + .map(|value| match value { + serde_json::Value::Array(_) => { + serde_json::from_value(value).context("parse legacy remote workspace array") + } + serde_json::Value::Object(_) => serde_json::from_value(value) + .map(|value| vec![value]) + .context("parse legacy remote workspace object"), + _ => bail!("legacy remote workspace must be a JSON object or array"), + }) + .transpose()?; + if let Some(values) = &values { + validate_remote_workspaces(values)?; + } + Ok(values) +} + +pub fn write_remote_workspaces(path: &Path, values: &[RemoteWorkspaceRecord]) -> Result<()> { + validate_remote_workspaces(values)?; + write_json_atomic(path, values, false) +} + +fn validate_remote_workspaces(values: &[RemoteWorkspaceRecord]) -> Result<()> { + for value in values { + if value.connection_id.trim().is_empty() || value.remote_path.trim().is_empty() { + bail!("remote workspace is missing its connection or POSIX path"); + } + if !value.remote_path.starts_with('/') { + bail!("remote workspace path is not an absolute POSIX path"); + } + } + Ok(()) +} + +pub fn read_known_hosts(path: &Path) -> Result>> { + let values: Option> = read_optional_json(path)?; + if let Some(values) = &values { + for value in values { + if value.host.trim().is_empty() + || value.port == 0 + || value.key_type.trim().is_empty() + || value.fingerprint.trim().is_empty() + || value.public_key.trim().is_empty() + { + bail!("known-host entry is incomplete"); + } + } + } + Ok(values) +} + +pub fn write_known_hosts(path: &Path, values: &[KnownHostRecord]) -> Result<()> { + let serialized = serde_json::to_vec_pretty(values).context("serialize known hosts")?; + let _: Vec = + serde_json::from_slice(&serialized).context("validate known hosts")?; + write_atomic(path, &serialized, false) +} + +#[derive(Clone, Default, Serialize, Deserialize)] +pub struct SshVaultFile { + pub entries: BTreeMap, +} + +impl std::fmt::Debug for SshVaultFile { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("SshVaultFile") + .field("entry_count", &self.entries.len()) + .finish() + } +} + +#[derive(Clone)] +pub struct SshVaultRecord { + pub key: [u8; 32], + pub file: SshVaultFile, +} + +impl std::fmt::Debug for SshVaultRecord { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("SshVaultRecord") + .field("key", &"[REDACTED]") + .field("file", &self.file) + .finish() + } +} + +impl SshVaultRecord { + pub fn decrypt(&self, connection_id: &str) -> Result> { + self.file + .entries + .get(connection_id) + .map(|ciphertext| decrypt_vault_entry(&self.key, ciphertext)) + .transpose() + } + + pub fn store(&mut self, connection_id: String, plaintext: &str) -> Result<()> { + let ciphertext = encrypt_vault_entry(&self.key, plaintext)?; + self.file.entries.insert(connection_id, ciphertext); + Ok(()) + } + + pub fn remove(&mut self, connection_id: &str) { + self.file.entries.remove(connection_id); + } +} + +pub fn read_ssh_vault(directory: &Path) -> Result> { + let key_path = directory.join(".ssh_password_vault.key"); + let vault_path = directory.join("ssh_password_vault.json"); + let key_exists = key_path.exists(); + let vault_exists = vault_path.exists(); + if !key_exists && !vault_exists { + return Ok(None); + } + if key_exists != vault_exists { + bail!("SSH password vault key and ciphertext file must migrate as a pair"); + } + let key: [u8; 32] = std::fs::read(&key_path) + .context("read SSH password vault key")? + .try_into() + .map_err(|_| anyhow!("SSH password vault key has an invalid length"))?; + let file: SshVaultFile = read_required_json(&vault_path)?; + let record = SshVaultRecord { key, file }; + if let Some(connection_id) = record.file.entries.keys().next() { + let _ = record + .decrypt(connection_id) + .context("validate SSH password vault entry")?; + } + Ok(Some(record)) +} + +pub fn new_ssh_vault() -> SshVaultRecord { + let mut key = [0u8; 32]; + OsRng.fill_bytes(&mut key); + SshVaultRecord { + key, + file: SshVaultFile::default(), + } +} + +pub fn write_ssh_vault(directory: &Path, value: &SshVaultRecord) -> Result<()> { + std::fs::create_dir_all(directory).context("create SSH persistence directory")?; + for connection_id in value.file.entries.keys() { + let _ = value + .decrypt(connection_id) + .context("validate SSH password vault before writing")?; + } + write_atomic(&directory.join(".ssh_password_vault.key"), &value.key, true)?; + write_json_atomic( + &directory.join("ssh_password_vault.json"), + &value.file, + true, + ) +} + +fn encrypt_vault_entry(key: &[u8; 32], plaintext: &str) -> Result { + let mut nonce = [0u8; NONCE_SIZE]; + OsRng.fill_bytes(&mut nonce); + let ciphertext = Aes256Gcm::new_from_slice(key) + .map_err(|_| anyhow!("initialize SSH password vault cipher"))? + .encrypt(Nonce::from_slice(&nonce), plaintext.as_bytes()) + .map_err(|_| anyhow!("encrypt SSH password vault entry"))?; + let mut packed = Vec::with_capacity(NONCE_SIZE + ciphertext.len()); + packed.extend_from_slice(&nonce); + packed.extend_from_slice(&ciphertext); + Ok(BASE64.encode(packed)) +} + +fn decrypt_vault_entry(key: &[u8; 32], ciphertext: &str) -> Result { + let packed = BASE64 + .decode(ciphertext) + .context("decode SSH password vault entry")?; + let (nonce, ciphertext) = split_ciphertext(&packed)?; + let plaintext = + decrypt(key, nonce, ciphertext).map_err(|_| anyhow!("decrypt SSH password vault entry"))?; + String::from_utf8(plaintext).context("decode SSH password vault entry") +} + +pub fn read_context_tokens(path: &Path) -> Result>> { + let value: Option> = read_optional_json(path)?; + if value.as_ref().is_some_and(|tokens| { + tokens + .iter() + .any(|(peer, token)| peer.trim().is_empty() || token.trim().is_empty()) + }) { + bail!("Weixin context-token store contains an empty peer or token"); + } + Ok(value) +} + +pub fn write_context_tokens(path: &Path, value: &BTreeMap) -> Result<()> { + write_json_atomic(path, value, true) +} + +pub fn read_weixin_sync_buffer(path: &Path) -> Result> { + match std::fs::read_to_string(path) { + Ok(value) => Ok(Some(value.trim().to_string())), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(error).context("read Weixin sync buffer"), + } +} + +pub fn write_weixin_sync_buffer(path: &Path, value: &str) -> Result<()> { + write_atomic(path, value.as_bytes(), true) +} + +pub fn write_private_bytes(path: &Path, value: &[u8]) -> Result<()> { + write_atomic(path, value, true) +} + +fn read_optional_json(path: &Path) -> Result> { + match std::fs::read(path) { + Ok(bytes) => serde_json::from_slice(&bytes) + .with_context(|| format!("parse persisted JSON at {}", path.display())) + .map(Some), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(error) => { + Err(error).with_context(|| format!("read persisted JSON at {}", path.display())) + } + } +} + +fn read_required_json(path: &Path) -> Result { + read_optional_json(path)?.ok_or_else(|| anyhow!("required persisted JSON is missing")) +} + +fn write_json_atomic(path: &Path, value: &T, private: bool) -> Result<()> { + let bytes = serde_json::to_vec_pretty(value).context("serialize persisted JSON")?; + write_atomic(path, &bytes, private) +} + +fn write_atomic(path: &Path, bytes: &[u8], private: bool) -> Result<()> { + #[cfg(not(unix))] + let _ = private; + let parent = path + .parent() + .ok_or_else(|| anyhow!("persistence path has no parent directory"))?; + std::fs::create_dir_all(parent).context("create persistence directory")?; + #[cfg(unix)] + if private { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700)) + .context("restrict persistence directory")?; + } + let mut nonce = [0u8; 8]; + OsRng.fill_bytes(&mut nonce); + let name = path + .file_name() + .and_then(|name| name.to_str()) + .ok_or_else(|| anyhow!("persistence path has an invalid file name"))?; + let suffix = nonce + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::(); + let temporary = parent.join(format!(".{name}.{suffix}.tmp")); + let result = (|| -> Result<()> { + let mut options = OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + if private { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let mut file = options + .open(&temporary) + .context("create persistence temporary file")?; + file.write_all(bytes) + .context("write persistence temporary file")?; + file.sync_all() + .context("flush persistence temporary file")?; + drop(file); + replace_file(&temporary, path)?; + #[cfg(unix)] + if private { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)) + .context("restrict persisted file")?; + } + Ok(()) + })(); + if result.is_err() { + let _ = std::fs::remove_file(&temporary); + } + result +} + +#[cfg(not(windows))] +fn replace_file(source: &Path, target: &Path) -> Result<()> { + std::fs::rename(source, target).context("install persisted file") +} + +#[cfg(windows)] +fn replace_file(source: &Path, target: &Path) -> Result<()> { + use std::os::windows::ffi::OsStrExt; + use windows::core::PCWSTR; + use windows::Win32::Storage::FileSystem::{ + MoveFileExW, MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH, + }; + + let source = source + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect::>(); + let target = target + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect::>(); + unsafe { + MoveFileExW( + PCWSTR(source.as_ptr()), + PCWSTR(target.as_ptr()), + MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH, + ) + .context("install persisted file") + } +} + +pub fn safe_account_file_component(value: &str) -> Option { + let normalized: String = value + .chars() + .map(|character| { + if character.is_ascii_alphanumeric() || matches!(character, '-' | '_') { + character + } else { + '_' + } + }) + .collect(); + (!normalized.is_empty()).then_some(normalized) +} + +pub fn is_safe_weixin_account_id(value: &str) -> bool { + !value.is_empty() + && value.len() <= 128 + && value + .chars() + .all(|character| character.is_ascii_alphanumeric() || matches!(character, '-' | '_')) +} + +pub fn account_sync_paths(directory: &Path) -> Result> { + let entries = match std::fs::read_dir(directory) { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(error) => return Err(error).context("read account sync directory"), + }; + let mut paths = Vec::new(); + for entry in entries { + let entry = entry.context("read account sync entry")?; + let file_type = entry.file_type().context("read account sync entry type")?; + if file_type.is_symlink() || !file_type.is_file() { + bail!("account sync directory contains a non-regular entry"); + } + let path = entry.path(); + if path.extension().and_then(|value| value.to_str()) == Some("json") + && !path.to_string_lossy().ends_with(".tmp") + { + paths.push(path); + } + } + paths.sort(); + Ok(paths) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn legacy_v2_account_session_reencrypts_for_the_current_owner() { + let source = test_tempdir("legacy-session-source"); + let target = test_tempdir("legacy-session-target"); + let binding = MachineBinding { + hostname: "fixture-host".to_string(), + username: "fixture-user".to_string(), + os: "windows".to_string(), + }; + let domains = LegacyAccountSessionKeyDomains { + v1: b"retired-product::session_store::v1", + v2: b"|retired-product::session_store::v2|", + }; + let session = AccountSessionRecord { + token: "synthetic-token".to_string(), + user_id: "fixture-account".to_string(), + master_key: [0x42; 32], + relay_url: "https://relay.example.invalid".to_string(), + device_id: Some("0123456789abcdef0123456789abcdef".to_string()), + }; + let local_secret = [0x24; 32]; + std::fs::write(source.path().join("account_session.key"), local_secret).unwrap(); + write_legacy_session_fixture(source.path(), &binding, &session, &local_secret, domains); + + let decoded = read_legacy_account_session(source.path(), &binding, domains) + .unwrap() + .expect("legacy session"); + assert_eq!(decoded, session); + write_current_account_session(target.path(), &binding, &decoded).unwrap(); + assert_eq!( + read_current_account_session(target.path(), &binding).unwrap(), + Some(session) + ); + let ciphertext = + std::fs::read_to_string(target.path().join("account_session.enc")).unwrap(); + assert!(!ciphertext.contains("synthetic-token")); + } + + #[test] + fn bot_owner_upgrades_bare_workspace_and_redacts_debug_output() { + let root = test_tempdir("bot-persistence"); + let path = root.path().join("remote_connect_persistence.json"); + std::fs::write( + &path, + r#"{ + "connections":[{ + "bot_type":"telegram", + "chat_id":"chat-1", + "config":{"bot_type":"telegram","bot_token":"secret-token"}, + "chat_state":{ + "chat_id":"chat-1", + "paired":true, + "current_workspace":"/srv/project", + "current_assistant":null, + "current_session_id":"session-1", + "account_remote_context":true + }, + "connected_at":1 + }] + }"#, + ) + .unwrap(); + let data = read_bot_persistence(&path).unwrap().expect("bot data"); + assert_eq!( + data.connections[0] + .chat_state + .current_workspace + .as_ref() + .map(|workspace| workspace.path.as_str()), + Some("/srv/project") + ); + assert!(data.connections[0].chat_state.account_remote_context); + let debug = format!("{data:?}"); + assert!(!debug.contains("secret-token")); + assert!(debug.contains("[REDACTED]")); + } + + #[test] + fn ssh_owner_accepts_legacy_workspace_and_validates_vault_entries() { + let root = test_tempdir("ssh-persistence"); + let workspace_path = root.path().join("remote_workspace.json"); + std::fs::write( + &workspace_path, + r#"{"connectionId":"ssh-user@example.invalid:22","remotePath":"/srv/project"}"#, + ) + .unwrap(); + let legacy = read_legacy_remote_workspaces(&workspace_path) + .unwrap() + .expect("legacy workspace"); + assert_eq!(legacy.len(), 1); + assert!(read_current_remote_workspaces(&workspace_path).is_err()); + write_remote_workspaces(&workspace_path, &legacy).unwrap(); + assert_eq!( + read_current_remote_workspaces(&workspace_path) + .unwrap() + .expect("current workspaces"), + legacy + ); + + let mut vault = new_ssh_vault(); + vault + .store( + "ssh-user@example.invalid:22".to_string(), + "fixture-password", + ) + .unwrap(); + write_ssh_vault(root.path(), &vault).unwrap(); + let loaded = read_ssh_vault(root.path()).unwrap().expect("vault"); + assert_eq!( + loaded + .decrypt("ssh-user@example.invalid:22") + .unwrap() + .as_deref(), + Some("fixture-password") + ); + assert!(!format!("{loaded:?}").contains("fixture-password")); + } + + #[test] + fn ssh_owner_treats_an_empty_legacy_workspace_array_as_no_workspaces() { + let root = test_tempdir("ssh-empty-workspaces"); + let workspace_path = root.path().join("remote_workspace.json"); + std::fs::write(&workspace_path, "[]").unwrap(); + + assert_eq!( + read_legacy_remote_workspaces(&workspace_path).unwrap(), + Some(Vec::new()) + ); + + std::fs::write(&workspace_path, "{}").unwrap(); + assert!(read_legacy_remote_workspaces(&workspace_path).is_err()); + } + + fn write_legacy_session_fixture( + directory: &Path, + binding: &MachineBinding, + session: &AccountSessionRecord, + local_secret: &[u8; 32], + domains: LegacyAccountSessionKeyDomains<'_>, + ) { + let payload = AccountSessionPayload { + token: session.token.clone(), + user_id: session.user_id.clone(), + master_key_b64: BASE64.encode(session.master_key), + relay_url: session.relay_url.clone(), + device_id: session.device_id.clone(), + }; + let plaintext = serde_json::to_vec(&payload).unwrap(); + let key = derive_legacy_v2_key(binding, local_secret, domains); + let nonce = [0x11; NONCE_SIZE]; + let ciphertext = Aes256Gcm::new_from_slice(&key) + .unwrap() + .encrypt(Nonce::from_slice(&nonce), plaintext.as_slice()) + .unwrap(); + let mut packed = nonce.to_vec(); + packed.extend(ciphertext); + std::fs::write(directory.join("account_session.enc"), BASE64.encode(packed)).unwrap(); + } + + fn test_tempdir(label: &str) -> tempfile::TempDir { + let root = std::env::var_os("OPENBITFUN_TEST_TMPDIR") + .map(PathBuf::from) + .unwrap_or_else(std::env::temp_dir); + std::fs::create_dir_all(&root).expect("test temporary root"); + tempfile::Builder::new() + .prefix(&format!("remote-persistence-{label}-")) + .tempdir_in(root) + .expect("test temporary directory") + } +} diff --git a/src/shared/interactive-capabilities/catalog.json b/src/shared/interactive-capabilities/catalog.json index 1e24bb24d2..5ec7b41f3d 100644 --- a/src/shared/interactive-capabilities/catalog.json +++ b/src/shared/interactive-capabilities/catalog.json @@ -9125,6 +9125,140 @@ "operations": [], "options": [] }, + { + "id": "setting.data.migration", + "kind": "setting", + "categoryId": "data", + "titleZh": "旧版数据迁移", + "titleEn": "Legacy data migration", + "summaryZh": "从本机旧版 安装扫描并导入受支持的数据,查看去敏报告,同时保持旧来源不变。", + "summaryEn": "Scan and import supported data from a local legacy installation, inspect redacted reports, and leave the legacy source unchanged.", + "keywordsZh": [ + "旧版数据迁移", + "旧版数据", + "迁移报告", + "导入旧数据", + "Data Migrator" + ], + "keywordsEn": [ + "legacy data migration", + "legacy data", + "migration report", + "import old data", + "Data Migrator" + ], + "highlightsZh": [ + "只读扫描本机旧版数据", + "按五个高层数据组选择迁移范围", + "通过独立 Data Migrator 导入并查看去敏报告" + ], + "highlightsEn": [ + "Read-only scan of local legacy data", + "Choose migration scope across five high-level data groups", + "Import through the standalone Data Migrator and inspect redacted reports" + ], + "items": [ + { + "id": "scan", + "titleZh": "只读扫描本机旧版数据来源及所选数据组", + "titleEn": "Read-only scan the local legacy source and selected data groups", + "control": { + "kind": "open", + "reasonCode": "unstructuredInteraction", + "reasonZh": "“只读扫描本机旧版数据来源及所选数据组”:扫描依赖当前设备上的旧版数据、实时范围选择和结果状态;Agent 会打开精确入口,并把选择与扫描保留在用户可见界面。", + "reasonEn": "Read-only scan the local legacy source and selected data groups: Scanning depends on legacy data on the current device, live scope selection, and result state; the Agent opens the exact entry and keeps selection and scanning visible to the user." + }, + "evidence": [ + "command:get_legacy_migration_status", + "command:scan_legacy_migration", + "source:src/web-ui/src/locales/zh-CN/settings/legacy-migration.json#actions.scan" + ] + }, + { + "id": "scope", + "titleZh": "选择设置、扩展、会话、记忆和远程连接迁移范围", + "titleEn": "Choose settings, extensions, sessions, memory, and remote-connection migration scope", + "control": { + "kind": "open", + "reasonCode": "visualSelection", + "reasonZh": "迁移范围是影响本机持久数据的五组可见选择;Agent 会打开精确入口,由用户确认所需范围。", + "reasonEn": "Migration scope is a visible five-group selection affecting local persisted data; the Agent opens the exact entry so the user can confirm the intended scope." + }, + "evidence": [ + "source:src/web-ui/src/locales/zh-CN/settings/legacy-migration.json#sections.scope.title" + ] + }, + { + "id": "launch", + "titleZh": "确认关闭影响后启动独立 Data Migrator", + "titleEn": "Launch the standalone Data Migrator after confirming shutdown impact", + "control": { + "kind": "open", + "reasonCode": "unstructuredInteraction", + "reasonZh": "“确认关闭影响后启动独立 Data Migrator”:启动迁移器会停止正在运行的 Agent 和终端任务、关闭 Desktop,并交接到独立本机进程;Agent 只打开入口,确认和启动保留在用户可见界面。", + "reasonEn": "Launch the standalone Data Migrator after confirming shutdown impact: Launching the migrator can stop running agents and terminal tasks, close Desktop, and hand off to a separate local process; the Agent only opens the entry while confirmation and launch remain visible to the user." + }, + "evidence": [ + "command:prepare_legacy_migration", + "source:src/web-ui/src/locales/zh-CN/settings/legacy-migration.json#confirm.title" + ] + }, + { + "id": "report", + "titleZh": "查看最近运行结果和各领域去敏状态", + "titleEn": "Inspect the latest run result and redacted per-domain status", + "control": { + "kind": "open", + "reasonCode": "unstructuredInteraction", + "reasonZh": "“查看最近运行结果和各领域去敏状态”:报告取决于本机最近一次迁移运行和各领域实时状态;Agent 会打开精确入口,并把报告查看与失败组重试保留在用户可见界面。", + "reasonEn": "Inspect the latest run result and redacted per-domain status: Reports depend on the most recent local migration run and live per-domain state; the Agent opens the exact entry and keeps report review and failed-group retry visible to the user." + }, + "evidence": [ + "command:get_legacy_migration_report", + "source:src/web-ui/src/locales/zh-CN/settings/legacy-migration.json#sections.report.title" + ] + }, + { + "id": "reminder", + "titleZh": "恢复已关闭的首次启动迁移提醒", + "titleEn": "Restore the first-start migration reminder after it was disabled", + "control": { + "kind": "open", + "reasonCode": "unstructuredInteraction", + "reasonZh": "“恢复已关闭的首次启动迁移提醒”:提醒偏好与当前本机旧数据来源绑定;Agent 会打开精确入口,由用户在可见界面决定是否恢复提醒。", + "reasonEn": "Restore the first-start migration reminder after it was disabled: The reminder preference is bound to the current local legacy source; the Agent opens the exact entry so the user can decide visibly whether to restore it." + }, + "evidence": [ + "command:set_legacy_migration_prompt_preference", + "source:src/web-ui/src/locales/zh-CN/settings/legacy-migration.json#actions.restoreReminder" + ] + } + ], + "stepsZh": [ + "打开设置", + "进入“数据 > 旧版数据迁移”", + "扫描来源、选择范围并在确认关闭影响后启动迁移器" + ], + "stepsEn": [ + "Open Settings", + "Go to Data > Legacy data migration", + "Scan the source, choose scope, and launch the migrator after confirming shutdown impact" + ], + "agentExamplesZh": [ + "打开旧版数据迁移", + "带我查看数据迁移报告" + ], + "agentExamplesEn": [ + "Open legacy data migration", + "Show me the data migration report" + ], + "destination": { + "kind": "settings", + "pageId": "data.migration" + }, + "operations": [], + "options": [] + }, { "id": "setting.data.diagnostics", "kind": "setting", @@ -9473,6 +9607,9 @@ "debug": { "capabilityId": "setting.data.diagnostics" }, + "legacy_migration": { + "capabilityId": "setting.data.migration" + }, "appearance": { "capabilityId": "feature.desktop-pet" }, diff --git a/src/web-ui/src/app/App.tsx b/src/web-ui/src/app/App.tsx index 0b250bf543..1a5fe5c7e7 100644 --- a/src/web-ui/src/app/App.tsx +++ b/src/web-ui/src/app/App.tsx @@ -38,6 +38,7 @@ import { activateCreationRuntime } from '@/infrastructure/creation/creationRunti import { attachCreationRuntime, recordCreationActivationError } from '@/infrastructure/creation/creationBridge'; import { createCreationUiApi } from './creation/creationUiApi'; import { usePeerDeviceModeOptional } from '@/infrastructure/peer-device/peerDeviceContextState'; +import { showLegacyMigrationStartupNotification } from './startup/legacyMigrationStartupNotification'; const log = createLogger('App'); @@ -872,6 +873,13 @@ function App() { // Debug inspector shortcuts (desktop devtools only) useDebugInspector(); + useEffect(() => { + if (!isTauriRuntime() || !interactiveShellReady) return; + void showLegacyMigrationStartupNotification().catch((error) => { + log.warn('Failed to show legacy migration startup result', error); + }); + }, [interactiveShellReady]); + useEffect(() => { if (!isTauriRuntime() || !interactiveShellReady) { return; diff --git a/src/web-ui/src/app/global-search/generated/interactive-capabilities.json b/src/web-ui/src/app/global-search/generated/interactive-capabilities.json index a8c4149de3..653efc45a6 100644 --- a/src/web-ui/src/app/global-search/generated/interactive-capabilities.json +++ b/src/web-ui/src/app/global-search/generated/interactive-capabilities.json @@ -4,7 +4,7 @@ "title": "OpenBitFun Playbook", "origin": "https://playbook.openbitfun.com", "source": "src/shared/interactive-capabilities/catalog.json", - "digest": "d7a7419ddd673eb733ae8bd33dbb3dcd3b4d1ce0acfc953067a3403fe26d6699", + "digest": "c77c16c414ce9fe50929fe249d900489fdceedb8e646aaaca03893aa33d4fbc8", "ownerDigest": "c0e5c187cf62bc6ed06196ce8520b3eb427bf268cf24659b72d2552fb1d99c54", "searchAcceptance": [ { @@ -136,13 +136,13 @@ ], "counts": { "features": 22, - "settings": 21, - "userFacing": 43, - "documentedItems": 321, + "settings": 22, + "userFacing": 44, + "documentedItems": 326, "controlCoverage": { "direct": 48, "delegated": 61, - "interactive": 212, + "interactive": 217, "unsupported": 0 } }, @@ -18765,6 +18765,285 @@ "pageId": "data.archived" } }, + { + "id": "setting.data.migration:query", + "capabilityId": "setting.data.migration", + "itemIds": [ + "scan", + "scope", + "launch", + "report", + "reminder" + ], + "kind": "query", + "risk": "read", + "executionHost": "productHost", + "availability": { + "desktop": { + "available": true + }, + "cli": { + "available": true + }, + "peer": { + "available": true, + "requiredCapabilities": [ + "product_control_v1" + ] + }, + "remoteControl": { + "available": true + }, + "detachedDispatch": { + "available": true + } + }, + "inputSchema": { + "type": "object", + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "additionalProperties": true + }, + "valueSource": { + "kind": "static" + }, + "presentationTarget": { + "kind": "settings", + "pageId": "data.migration" + } + }, + { + "id": "setting.data.migration:open:scan", + "capabilityId": "setting.data.migration", + "itemIds": [ + "scan" + ], + "kind": "open", + "risk": "ui", + "executionHost": "presentationSurface", + "availability": { + "desktop": { + "available": true + }, + "cli": { + "available": false, + "reason": "This delivery profile has no live presentation surface" + }, + "peer": { + "available": true, + "requiredCapabilities": [ + "product_control_v1", + "product_control_presentation_v1" + ] + }, + "remoteControl": { + "available": true + }, + "detachedDispatch": { + "available": false, + "reason": "This delivery profile has no live presentation surface" + } + }, + "inputSchema": { + "type": "object", + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "additionalProperties": true + }, + "openReason": "unstructuredInteraction", + "presentationTarget": { + "kind": "settings", + "pageId": "data.migration" + } + }, + { + "id": "setting.data.migration:open:scope", + "capabilityId": "setting.data.migration", + "itemIds": [ + "scope" + ], + "kind": "open", + "risk": "ui", + "executionHost": "presentationSurface", + "availability": { + "desktop": { + "available": true + }, + "cli": { + "available": false, + "reason": "This delivery profile has no live presentation surface" + }, + "peer": { + "available": true, + "requiredCapabilities": [ + "product_control_v1", + "product_control_presentation_v1" + ] + }, + "remoteControl": { + "available": true + }, + "detachedDispatch": { + "available": false, + "reason": "This delivery profile has no live presentation surface" + } + }, + "inputSchema": { + "type": "object", + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "additionalProperties": true + }, + "openReason": "visualSelection", + "presentationTarget": { + "kind": "settings", + "pageId": "data.migration" + } + }, + { + "id": "setting.data.migration:open:launch", + "capabilityId": "setting.data.migration", + "itemIds": [ + "launch" + ], + "kind": "open", + "risk": "ui", + "executionHost": "presentationSurface", + "availability": { + "desktop": { + "available": true + }, + "cli": { + "available": false, + "reason": "This delivery profile has no live presentation surface" + }, + "peer": { + "available": true, + "requiredCapabilities": [ + "product_control_v1", + "product_control_presentation_v1" + ] + }, + "remoteControl": { + "available": true + }, + "detachedDispatch": { + "available": false, + "reason": "This delivery profile has no live presentation surface" + } + }, + "inputSchema": { + "type": "object", + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "additionalProperties": true + }, + "openReason": "unstructuredInteraction", + "presentationTarget": { + "kind": "settings", + "pageId": "data.migration" + } + }, + { + "id": "setting.data.migration:open:report", + "capabilityId": "setting.data.migration", + "itemIds": [ + "report" + ], + "kind": "open", + "risk": "ui", + "executionHost": "presentationSurface", + "availability": { + "desktop": { + "available": true + }, + "cli": { + "available": false, + "reason": "This delivery profile has no live presentation surface" + }, + "peer": { + "available": true, + "requiredCapabilities": [ + "product_control_v1", + "product_control_presentation_v1" + ] + }, + "remoteControl": { + "available": true + }, + "detachedDispatch": { + "available": false, + "reason": "This delivery profile has no live presentation surface" + } + }, + "inputSchema": { + "type": "object", + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "additionalProperties": true + }, + "openReason": "unstructuredInteraction", + "presentationTarget": { + "kind": "settings", + "pageId": "data.migration" + } + }, + { + "id": "setting.data.migration:open:reminder", + "capabilityId": "setting.data.migration", + "itemIds": [ + "reminder" + ], + "kind": "open", + "risk": "ui", + "executionHost": "presentationSurface", + "availability": { + "desktop": { + "available": true + }, + "cli": { + "available": false, + "reason": "This delivery profile has no live presentation surface" + }, + "peer": { + "available": true, + "requiredCapabilities": [ + "product_control_v1", + "product_control_presentation_v1" + ] + }, + "remoteControl": { + "available": true + }, + "detachedDispatch": { + "available": false, + "reason": "This delivery profile has no live presentation surface" + } + }, + "inputSchema": { + "type": "object", + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "additionalProperties": true + }, + "openReason": "unstructuredInteraction", + "presentationTarget": { + "kind": "settings", + "pageId": "data.migration" + } + }, { "id": "setting.data.diagnostics:query", "capabilityId": "setting.data.diagnostics", @@ -29565,6 +29844,156 @@ ], "docsUrl": "https://playbook.openbitfun.com/capabilities/setting.data.archived/" }, + { + "id": "setting.data.migration", + "kind": "setting", + "categoryId": "data", + "titleZh": "旧版数据迁移", + "titleEn": "Legacy data migration", + "summaryZh": "从本机旧版 安装扫描并导入受支持的数据,查看去敏报告,同时保持旧来源不变。", + "summaryEn": "Scan and import supported data from a local legacy installation, inspect redacted reports, and leave the legacy source unchanged.", + "keywordsZh": [ + "旧版数据迁移", + "旧版数据", + "迁移报告", + "导入旧数据", + "Data Migrator" + ], + "keywordsEn": [ + "legacy data migration", + "legacy data", + "migration report", + "import old data", + "Data Migrator" + ], + "highlightsZh": [ + "只读扫描本机旧版数据", + "按五个高层数据组选择迁移范围", + "通过独立 Data Migrator 导入并查看去敏报告" + ], + "highlightsEn": [ + "Read-only scan of local legacy data", + "Choose migration scope across five high-level data groups", + "Import through the standalone Data Migrator and inspect redacted reports" + ], + "items": [ + { + "id": "scan", + "titleZh": "只读扫描本机旧版数据来源及所选数据组", + "titleEn": "Read-only scan the local legacy source and selected data groups", + "control": { + "kind": "open", + "reasonCode": "unstructuredInteraction", + "reasonZh": "“只读扫描本机旧版数据来源及所选数据组”:扫描依赖当前设备上的旧版数据、实时范围选择和结果状态;Agent 会打开精确入口,并把选择与扫描保留在用户可见界面。", + "reasonEn": "Read-only scan the local legacy source and selected data groups: Scanning depends on legacy data on the current device, live scope selection, and result state; the Agent opens the exact entry and keeps selection and scanning visible to the user." + } + }, + { + "id": "scope", + "titleZh": "选择设置、扩展、会话、记忆和远程连接迁移范围", + "titleEn": "Choose settings, extensions, sessions, memory, and remote-connection migration scope", + "control": { + "kind": "open", + "reasonCode": "visualSelection", + "reasonZh": "迁移范围是影响本机持久数据的五组可见选择;Agent 会打开精确入口,由用户确认所需范围。", + "reasonEn": "Migration scope is a visible five-group selection affecting local persisted data; the Agent opens the exact entry so the user can confirm the intended scope." + } + }, + { + "id": "launch", + "titleZh": "确认关闭影响后启动独立 Data Migrator", + "titleEn": "Launch the standalone Data Migrator after confirming shutdown impact", + "control": { + "kind": "open", + "reasonCode": "unstructuredInteraction", + "reasonZh": "“确认关闭影响后启动独立 Data Migrator”:启动迁移器会停止正在运行的 Agent 和终端任务、关闭 Desktop,并交接到独立本机进程;Agent 只打开入口,确认和启动保留在用户可见界面。", + "reasonEn": "Launch the standalone Data Migrator after confirming shutdown impact: Launching the migrator can stop running agents and terminal tasks, close Desktop, and hand off to a separate local process; the Agent only opens the entry while confirmation and launch remain visible to the user." + } + }, + { + "id": "report", + "titleZh": "查看最近运行结果和各领域去敏状态", + "titleEn": "Inspect the latest run result and redacted per-domain status", + "control": { + "kind": "open", + "reasonCode": "unstructuredInteraction", + "reasonZh": "“查看最近运行结果和各领域去敏状态”:报告取决于本机最近一次迁移运行和各领域实时状态;Agent 会打开精确入口,并把报告查看与失败组重试保留在用户可见界面。", + "reasonEn": "Inspect the latest run result and redacted per-domain status: Reports depend on the most recent local migration run and live per-domain state; the Agent opens the exact entry and keeps report review and failed-group retry visible to the user." + } + }, + { + "id": "reminder", + "titleZh": "恢复已关闭的首次启动迁移提醒", + "titleEn": "Restore the first-start migration reminder after it was disabled", + "control": { + "kind": "open", + "reasonCode": "unstructuredInteraction", + "reasonZh": "“恢复已关闭的首次启动迁移提醒”:提醒偏好与当前本机旧数据来源绑定;Agent 会打开精确入口,由用户在可见界面决定是否恢复提醒。", + "reasonEn": "Restore the first-start migration reminder after it was disabled: The reminder preference is bound to the current local legacy source; the Agent opens the exact entry so the user can decide visibly whether to restore it." + } + } + ], + "stepsZh": [ + "打开设置", + "进入“数据 > 旧版数据迁移”", + "扫描来源、选择范围并在确认关闭影响后启动迁移器" + ], + "stepsEn": [ + "Open Settings", + "Go to Data > Legacy data migration", + "Scan the source, choose scope, and launch the migrator after confirming shutdown impact" + ], + "agentExamplesZh": [ + "打开旧版数据迁移", + "带我查看数据迁移报告" + ], + "agentExamplesEn": [ + "Open legacy data migration", + "Show me the data migration report" + ], + "destination": { + "kind": "settings", + "pageId": "data.migration" + }, + "operations": [], + "options": [], + "searchTerms": [ + "setting.data.migration", + "旧版数据迁移", + "Legacy data migration", + "数据与诊断", + "Data & diagnostics", + "旧版数据", + "迁移报告", + "导入旧数据", + "Data Migrator", + "legacy data migration", + "legacy data", + "migration report", + "import old data", + "只读扫描本机旧版数据", + "按五个高层数据组选择迁移范围", + "通过独立 Data Migrator 导入并查看去敏报告", + "Read-only scan of local legacy data", + "Choose migration scope across five high-level data groups", + "Import through the standalone Data Migrator and inspect redacted reports", + "只读扫描本机旧版数据来源及所选数据组", + "Read-only scan the local legacy source and selected data groups", + "选择设置、扩展、会话、记忆和远程连接迁移范围", + "Choose settings, extensions, sessions, memory, and remote-connection migration scope", + "确认关闭影响后启动独立 Data Migrator", + "Launch the standalone Data Migrator after confirming shutdown impact", + "查看最近运行结果和各领域去敏状态", + "Inspect the latest run result and redacted per-domain status", + "恢复已关闭的首次启动迁移提醒", + "Restore the first-start migration reminder after it was disabled", + "打开旧版数据迁移", + "带我查看数据迁移报告", + "Open legacy data migration", + "Show me the data migration report" + ], + "docsUrl": "https://playbook.openbitfun.com/capabilities/setting.data.migration/" + }, { "id": "setting.data.diagnostics", "kind": "setting", diff --git a/src/web-ui/src/app/scenes/settings/settingsRegistry.test.ts b/src/web-ui/src/app/scenes/settings/settingsRegistry.test.ts index 7dd3066e23..ce7458ab48 100644 --- a/src/web-ui/src/app/scenes/settings/settingsRegistry.test.ts +++ b/src/web-ui/src/app/scenes/settings/settingsRegistry.test.ts @@ -15,7 +15,7 @@ vi.mock('@/infrastructure/i18n/core/I18nService', () => ({ })); describe('settings information architecture', () => { - it('uses five ownership categories and twenty canonical pages', () => { + it('uses five ownership categories and twenty-one canonical pages', () => { expect(SETTINGS_CATEGORIES.map((category) => category.id)).toEqual([ 'application', 'ai', @@ -23,8 +23,8 @@ describe('settings information architecture', () => { 'tools', 'data', ]); - expect(SETTINGS_PAGE_MANIFESTS).toHaveLength(20); - expect(new Set(SETTINGS_PAGE_MANIFESTS.map((page) => page.id)).size).toBe(20); + expect(SETTINGS_PAGE_MANIFESTS).toHaveLength(21); + expect(new Set(SETTINGS_PAGE_MANIFESTS.map((page) => page.id)).size).toBe(21); }); it('keeps memory with AI, pet with application, and review inside execution', () => { @@ -142,10 +142,13 @@ describe('settings information architecture', () => { expect(dataPages?.map((page) => page.id)).toEqual([ 'data.usage', 'data.archived', + 'data.migration', 'data.diagnostics', ]); expect(SETTINGS_PAGE_MANIFESTS.find((page) => page.id === 'data.usage')?.views).toBeUndefined(); expect(SETTINGS_PAGE_MANIFESTS.find((page) => page.id === 'data.archived')?.views).toBeUndefined(); + expect(SETTINGS_PAGE_MANIFESTS.find((page) => page.id === 'data.migration')?.namespaces) + .toEqual(['settings/legacy-migration']); }); it('contains old links at the upgrade boundary and emits canonical destinations', () => { diff --git a/src/web-ui/src/app/scenes/settings/settingsRegistry.ts b/src/web-ui/src/app/scenes/settings/settingsRegistry.ts index bec302602d..64fa3d00df 100644 --- a/src/web-ui/src/app/scenes/settings/settingsRegistry.ts +++ b/src/web-ui/src/app/scenes/settings/settingsRegistry.ts @@ -377,6 +377,21 @@ export const SETTINGS_PAGE_MANIFESTS: readonly SettingsPageManifest[] = [ ], load: () => import('./components/ArchivedSessionsConfig'), }), + definePage({ + id: 'data.migration', + categoryId: 'data', + labelKey: 'navigation.pages.legacyMigration.label', + descriptionKey: 'navigation.pages.legacyMigration.description', + keywords: ['legacy', 'migration', 'import', 'upgrade', 'maintenance'], + namespaces: ['settings/legacy-migration'], + searchPhrases: [ + phrase('settings/legacy-migration', 'title'), + phrase('settings/legacy-migration', 'subtitle'), + phrase('settings/legacy-migration', 'sections.source.title'), + phrase('settings/legacy-migration', 'sections.report.title'), + ], + load: () => import('../../../infrastructure/config/components/LegacyMigrationSettingsPage'), + }), definePage({ id: 'data.diagnostics', categoryId: 'data', diff --git a/src/web-ui/src/app/scenes/settings/settingsTypes.ts b/src/web-ui/src/app/scenes/settings/settingsTypes.ts index 848833b8b5..8caa196a31 100644 --- a/src/web-ui/src/app/scenes/settings/settingsTypes.ts +++ b/src/web-ui/src/app/scenes/settings/settingsTypes.ts @@ -25,6 +25,7 @@ export type SettingsPageId = | 'tools.acp' | 'data.usage' | 'data.archived' + | 'data.migration' | 'data.diagnostics'; export type SettingsViewId = diff --git a/src/web-ui/src/app/startup/legacyMigrationStartupNotification.test.ts b/src/web-ui/src/app/startup/legacyMigrationStartupNotification.test.ts new file mode 100644 index 0000000000..c1e0589421 --- /dev/null +++ b/src/web-ui/src/app/startup/legacyMigrationStartupNotification.test.ts @@ -0,0 +1,90 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { showLegacyMigrationStartupNotification } from './legacyMigrationStartupNotification'; + +const mocks = vi.hoisted(() => ({ + getStatus: vi.fn(), + loadNamespace: vi.fn(), + t: vi.fn((key: string) => key), + success: vi.fn(), + warning: vi.fn(), + error: vi.fn(), + info: vi.fn(), +})); + +vi.mock('@/infrastructure/api/service-api/LegacyMigrationAPI', () => ({ + legacyMigrationAPI: { getStatus: mocks.getStatus }, +})); +vi.mock('@/infrastructure/i18n', () => ({ + i18nService: { loadNamespace: mocks.loadNamespace, t: mocks.t }, +})); +vi.mock('@/shared/notification-system', () => ({ + notificationService: { + success: mocks.success, + warning: mocks.warning, + error: mocks.error, + info: mocks.info, + }, +})); + +describe('legacy migration startup notification', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.loadNamespace.mockResolvedValue(undefined); + }); + + it('loads the lazy namespace and shows a completed report once', async () => { + mocks.getStatus.mockResolvedValue({ + startupError: null, + startupReport: { runId: 'run-1', status: 'completed' }, + }); + const values = new Map(); + const storage = { + getItem: (key: string) => values.get(key) ?? null, + setItem: (key: string, value: string) => { values.set(key, value); }, + }; + + await showLegacyMigrationStartupNotification(storage); + await showLegacyMigrationStartupNotification(storage); + + expect(mocks.loadNamespace).toHaveBeenCalledWith('settings/legacy-migration'); + expect(mocks.success).toHaveBeenCalledTimes(1); + expect(mocks.success.mock.calls[0][1].metadata).toEqual({ + source: 'legacy-migration-startup-result', + runId: 'run-1', + status: 'completed', + }); + }); + + it('prioritizes a redacted startup launch error over an older report', async () => { + mocks.getStatus.mockResolvedValue({ + startupError: { + code: 'data_migrator_launch_failed', + message: 'A redacted backend message', + recoverable: true, + }, + startupReport: { runId: 'run-1', status: 'completed' }, + }); + const values = new Map(); + const storage = { + getItem: (key: string) => values.get(key) ?? null, + setItem: (key: string, value: string) => { values.set(key, value); }, + }; + + await showLegacyMigrationStartupNotification(storage); + await showLegacyMigrationStartupNotification(storage); + + expect(mocks.error).toHaveBeenCalledTimes(1); + expect(mocks.error.mock.calls[0][0]).toBe( + 'startupNotification.launchFailed', + ); + expect(mocks.t).toHaveBeenCalledWith('startupNotification.launchFailed', { + ns: 'settings/legacy-migration', + }); + expect(mocks.error.mock.calls[0][1].metadata).toEqual({ + source: 'legacy-migration-startup-error', + code: 'data_migrator_launch_failed', + recoverable: true, + }); + expect(mocks.success).not.toHaveBeenCalled(); + }); +}); diff --git a/src/web-ui/src/app/startup/legacyMigrationStartupNotification.ts b/src/web-ui/src/app/startup/legacyMigrationStartupNotification.ts new file mode 100644 index 0000000000..1044e3f69d --- /dev/null +++ b/src/web-ui/src/app/startup/legacyMigrationStartupNotification.ts @@ -0,0 +1,75 @@ +import { legacyMigrationAPI, type MigrationRunStatus } from '@/infrastructure/api/service-api/LegacyMigrationAPI'; +import { i18nService } from '@/infrastructure/i18n'; +import { notificationService } from '@/shared/notification-system'; + +function notificationKind(status: MigrationRunStatus): 'success' | 'warning' | 'error' | 'info' { + if (status === 'completed') return 'success'; + if (status === 'completed_with_warnings' || status === 'cancelled') return 'warning'; + if (status.startsWith('failed_')) return 'error'; + return 'info'; +} + +export async function showLegacyMigrationStartupNotification( + storage: Pick = sessionStorage, +): Promise { + const status = await legacyMigrationAPI.getStatus(); + const startupError = status.startupError; + const report = status.startupReport; + if (!startupError && !report) return; + + await i18nService.loadNamespace('settings/legacy-migration'); + const namespace = 'settings/legacy-migration'; + const openMigrationSettings = () => { + void import('@/shared/services/ide-control').then(({ quickActions }) => { + quickActions.openSettings({ pageId: 'data.migration' }); + }); + }; + + if (startupError) { + const noticeKey = `openbitfun:legacy-migration-startup-error:${startupError.code}`; + if (storage.getItem(noticeKey) === 'shown') return; + + storage.setItem(noticeKey, 'shown'); + notificationService.error( + i18nService.t('startupNotification.launchFailed', { ns: namespace }), + { + title: i18nService.t('startupNotification.title', { ns: namespace }), + duration: 0, + actions: [{ + label: i18nService.t('startupNotification.openSettings', { ns: namespace }), + variant: 'primary' as const, + onClick: openMigrationSettings, + }], + metadata: { + source: 'legacy-migration-startup-error', + code: startupError.code, + recoverable: startupError.recoverable, + }, + }, + ); + return; + } + + if (!report) return; + + const noticeKey = `openbitfun:legacy-migration-notice:${report.runId}`; + if (storage.getItem(noticeKey) === 'shown') return; + + storage.setItem(noticeKey, 'shown'); + const options = { + title: i18nService.t('startupNotification.title', { ns: namespace }), + duration: 0, + actions: [{ + label: i18nService.t('actions.viewReport', { ns: namespace }), + variant: 'primary' as const, + onClick: openMigrationSettings, + }], + metadata: { + source: 'legacy-migration-startup-result', + runId: report.runId, + status: report.status, + }, + }; + const message = i18nService.t(`startupNotification.statuses.${report.status}`, { ns: namespace }); + notificationService[notificationKind(report.status)](message, options); +} diff --git a/src/web-ui/src/infrastructure/api/generated/productControl.ts b/src/web-ui/src/infrastructure/api/generated/productControl.ts index e7bf7973cb..16c6dd80af 100644 --- a/src/web-ui/src/infrastructure/api/generated/productControl.ts +++ b/src/web-ui/src/infrastructure/api/generated/productControl.ts @@ -1,7 +1,7 @@ // Generated by scripts/generate-interactive-capabilities.mjs; do not edit. -export const PRODUCT_CONTROL_GRAPH_DIGEST = "d7a7419ddd673eb733ae8bd33dbb3dcd3b4d1ce0acfc953067a3403fe26d6699" as const; +export const PRODUCT_CONTROL_GRAPH_DIGEST = "c77c16c414ce9fe50929fe249d900489fdceedb8e646aaaca03893aa33d4fbc8" as const; -export type ProductControlCapabilityId = "feature.ai-assistant" | "feature.agents" | "feature.personal-assistants" | "feature.projects" | "feature.files-editor" | "feature.terminal" | "feature.git" | "feature.code-review" | "feature.browser" | "feature.computer-use" | "feature.skills" | "feature.miniapps" | "feature.canvas" | "feature.tasks-automation" | "feature.insights" | "feature.ecosystem-compatibility" | "feature.remote-workspaces" | "feature.remote-connect" | "feature.detached-dispatch" | "feature.pages" | "feature.voice-input" | "feature.desktop-pet" | "setting.application.general" | "setting.application.appearance" | "setting.application.pet" | "setting.application.input" | "setting.application.shortcuts" | "setting.application.development" | "setting.ai.models" | "setting.ai.memory" | "setting.workspace.session" | "setting.workspace.worktrees" | "setting.tools.execution" | "setting.application.terminal" | "setting.tools.desktop-control" | "setting.tools.browser-control" | "setting.tools.automation" | "setting.tools.web-search" | "setting.tools.mcp" | "setting.tools.acp" | "setting.data.usage" | "setting.data.archived" | "setting.data.diagnostics"; +export type ProductControlCapabilityId = "feature.ai-assistant" | "feature.agents" | "feature.personal-assistants" | "feature.projects" | "feature.files-editor" | "feature.terminal" | "feature.git" | "feature.code-review" | "feature.browser" | "feature.computer-use" | "feature.skills" | "feature.miniapps" | "feature.canvas" | "feature.tasks-automation" | "feature.insights" | "feature.ecosystem-compatibility" | "feature.remote-workspaces" | "feature.remote-connect" | "feature.detached-dispatch" | "feature.pages" | "feature.voice-input" | "feature.desktop-pet" | "setting.application.general" | "setting.application.appearance" | "setting.application.pet" | "setting.application.input" | "setting.application.shortcuts" | "setting.application.development" | "setting.ai.models" | "setting.ai.memory" | "setting.workspace.session" | "setting.workspace.worktrees" | "setting.tools.execution" | "setting.application.terminal" | "setting.tools.desktop-control" | "setting.tools.browser-control" | "setting.tools.automation" | "setting.tools.web-search" | "setting.tools.mcp" | "setting.tools.acp" | "setting.data.usage" | "setting.data.archived" | "setting.data.migration" | "setting.data.diagnostics"; export interface ProductControlOptionIdsByCapability { "feature.ai-assistant": never; @@ -46,6 +46,7 @@ export interface ProductControlOptionIdsByCapability { "setting.tools.acp": never; "setting.data.usage": never; "setting.data.archived": never; + "setting.data.migration": never; "setting.data.diagnostics": "log-level" | "sensitive-diagnostics"; } @@ -92,6 +93,7 @@ export interface ProductControlOperationIdsByCapability { "setting.tools.acp": never; "setting.data.usage": never; "setting.data.archived": never; + "setting.data.migration": never; "setting.data.diagnostics": never; } diff --git a/src/web-ui/src/infrastructure/api/generated/remoteSurface.ts b/src/web-ui/src/infrastructure/api/generated/remoteSurface.ts index 4b94ff2c3b..39c35913da 100644 --- a/src/web-ui/src/infrastructure/api/generated/remoteSurface.ts +++ b/src/web-ui/src/infrastructure/api/generated/remoteSurface.ts @@ -1,6 +1,6 @@ // Generated by scripts/generate-interactive-capabilities.mjs; do not edit. // Source: openbitfun_product_domains::remote_surface (Product Operation Registry). -export const REMOTE_SURFACE_REGISTRY_DIGEST = "fnv1a64:4cdcd7ff4dcd85a9" as const; +export const REMOTE_SURFACE_REGISTRY_DIGEST = "fnv1a64:f5390eecb4710d17" as const; /** * Registered Tauri commands the Peer Device controller keeps on the controller @@ -83,6 +83,8 @@ export const PEER_CONTROLLER_LOCAL_COMMANDS: ReadonlySet = new Set([ "get_announcement_tips", "get_frontend_update_status", "get_latest_insights", + "get_legacy_migration_report", + "get_legacy_migration_status", "get_pending_announcements", "get_pending_update", "get_prevent_sleep_enabled", @@ -112,6 +114,7 @@ export const PEER_CONTROLLER_LOCAL_COMMANDS: ReadonlySet = new Set([ "peer_controller_set_active", "peer_host_invoke_complete", "peer_mode_ping", + "prepare_legacy_migration", "quit_app", "relay_deploy_cancel", "relay_deploy_install_docker", @@ -142,6 +145,8 @@ export const PEER_CONTROLLER_LOCAL_COMMANDS: ReadonlySet = new Set([ "resolve_browser_dropped_file_paths", "restart_app", "rollback_frontend_update", + "scan_legacy_migration", + "set_legacy_migration_prompt_preference", "set_main_window_transient_geometry", "set_prevent_sleep_enabled", "show_agent_companion_desktop_pet", diff --git a/src/web-ui/src/infrastructure/api/service-api/LegacyMigrationAPI.test.ts b/src/web-ui/src/infrastructure/api/service-api/LegacyMigrationAPI.test.ts new file mode 100644 index 0000000000..7d4e19c54c --- /dev/null +++ b/src/web-ui/src/infrastructure/api/service-api/LegacyMigrationAPI.test.ts @@ -0,0 +1,30 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { LegacyMigrationAPI } from './LegacyMigrationAPI'; + +const invokeMock = vi.hoisted(() => vi.fn()); + +vi.mock('./ApiClient', () => ({ + api: { invoke: invokeMock }, +})); + +describe('LegacyMigrationAPI', () => { + beforeEach(() => invokeMock.mockReset()); + + it('uses structured requests through the active ApiClient transport', async () => { + invokeMock.mockResolvedValue({ runId: 'run-1', mode: 'execute' }); + const selection = { groups: ['memory'] as const }; + + await new LegacyMigrationAPI().prepare({ groups: [...selection.groups] }); + + expect(invokeMock).toHaveBeenCalledWith('prepare_legacy_migration', { + request: { selection: { groups: ['memory'] } }, + }); + }); + + it('does not expose an in-process execute command', () => { + const migration = new LegacyMigrationAPI() as unknown as Record; + + expect(migration.execute).toBeUndefined(); + expect(migration.run).toBeUndefined(); + }); +}); diff --git a/src/web-ui/src/infrastructure/api/service-api/LegacyMigrationAPI.ts b/src/web-ui/src/infrastructure/api/service-api/LegacyMigrationAPI.ts new file mode 100644 index 0000000000..f2e5ecf3a5 --- /dev/null +++ b/src/web-ui/src/infrastructure/api/service-api/LegacyMigrationAPI.ts @@ -0,0 +1,184 @@ +import { createTauriCommandError } from '../errors/TauriCommandError'; +import { api } from './ApiClient'; + +export type MigrationGroupId = + | 'settings_and_credentials' + | 'agents_skills_and_miniapps' + | 'workspaces_sessions_and_tasks' + | 'memory' + | 'remote_connections_and_devices'; + +export type MigrationDomainId = + | 'settings' + | 'credentials' + | 'skills' + | 'miniapps' + | 'agents' + | 'workspace_sessions' + | 'agent_coordination' + | 'structured_memory' + | 'file_memory' + | 'remote_connect_devices' + | 'remote_ssh' + | 'cross_reference_repair'; + +export type MigrationPromptChoice = + | 'unset' + | 'migrate_now' + | 'remind_later' + | 'do_not_remind'; + +export type MigrationRunStatus = + | 'discovered' + | 'scanned' + | 'planned' + | 'waiting_for_processes' + | 'staging' + | 'validating_stage' + | 'committing' + | 'validating_commit' + | 'completed' + | 'completed_with_warnings' + | 'cancelled' + | 'failed_recoverable' + | 'failed_manual_action_required'; + +export type MigrationDomainState = + | 'not_started' + | 'staged' + | 'committed' + | 'verified' + | 'failed' + | 'skipped'; + +export interface MigrationSelection { + groups: MigrationGroupId[]; +} + +export interface MigrationDiagnostic { + code: string; + severity: 'info' | 'warning' | 'blocking'; + domain: MigrationDomainId | null; + relativePath: string | null; + message: string; + action: string | null; +} + +export interface LegacySourceDescriptor { + sourceId: string; + sourceFingerprint: string; + productId: string; + productVersion: string; + platform: string; + readable: boolean; + supported: boolean; + approximateBytes: number; + alreadyMigrated: boolean; + diagnostics: MigrationDiagnostic[]; +} + +export interface MigrationOnboardingState { + formatVersion: number; + sourceFingerprint: string; + detectedAtMs: number | null; + lastScannedAtMs: number | null; + choice: MigrationPromptChoice; + lastPromptedVersion: string | null; + runId: string | null; + lastReportRunId: string | null; + handledRunId: string | null; +} + +export interface MigrationDomainResult { + domain: MigrationDomainId; + state: MigrationDomainState; + imported: number; + skipped: number; + conflicts: number; + warnings: MigrationDiagnostic[]; + requiresReauthentication: string[]; + requiresRelocation: string[]; +} + +export interface MigrationRunReport { + formatVersion: number; + runId: string; + sourceFingerprint: string; + planHash: string; + status: MigrationRunStatus; + startedAtMs: number; + finishedAtMs: number | null; + domainResults: MigrationDomainResult[]; + diagnostics: MigrationDiagnostic[]; + requiresReauthentication: string[]; + requiresRelocation: string[]; +} + +export interface LegacyMigrationStartupError { + code: string; + message: string; + recoverable: boolean; +} + +export interface LegacyMigrationStatusView { + source: LegacySourceDescriptor | null; + onboarding: MigrationOnboardingState; + latestReport: MigrationRunReport | null; + startupReport: MigrationRunReport | null; + startupError?: LegacyMigrationStartupError | null; +} + +export interface ScanFinding { + domain: MigrationDomainId; + code: string; + severity: 'info' | 'warning' | 'blocking'; + entityCount: number; + logicalBytes: number; + sourceSchema: string | null; + migratable: boolean; + detail: string; +} + +export interface LegacyMigrationScanView { + source: LegacySourceDescriptor; + selection: MigrationSelection; + scannedAtMs: number; + findings: ScanFinding[]; +} + +export interface LegacyMigrationHandoffView { + runId: string; + mode: 'onboarding' | 'execute'; +} + +export class LegacyMigrationAPI { + private async invoke(command: string, request: object): Promise { + try { + return await api.invoke(command, { request }); + } catch (error) { + throw createTauriCommandError(command, error, request); + } + } + + getStatus(): Promise { + return this.invoke('get_legacy_migration_status', {}); + } + + scan(selection?: MigrationSelection): Promise { + return this.invoke('scan_legacy_migration', selection ? { selection } : {}); + } + + prepare(selection: MigrationSelection): Promise { + return this.invoke('prepare_legacy_migration', { selection }); + } + + getReport(runId?: string): Promise { + return this.invoke('get_legacy_migration_report', runId ? { runId } : {}); + } + + setPromptPreference(choice: MigrationPromptChoice): Promise { + return this.invoke('set_legacy_migration_prompt_preference', { choice }); + } +} + +export const legacyMigrationAPI = new LegacyMigrationAPI(); diff --git a/src/web-ui/src/infrastructure/config/components/LegacyMigrationSettingsPage.scss b/src/web-ui/src/infrastructure/config/components/LegacyMigrationSettingsPage.scss new file mode 100644 index 0000000000..38106e38c8 --- /dev/null +++ b/src/web-ui/src/infrastructure/config/components/LegacyMigrationSettingsPage.scss @@ -0,0 +1,20 @@ +.openbitfun-legacy-migration__actions { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: var(--openbitfun-space-2); +} + +.openbitfun-legacy-migration__value { + color: var(--openbitfun-color-content-secondary); + font-size: var(--openbitfun-type-body-sm-font-size); + overflow-wrap: anywhere; + text-align: end; +} + +.openbitfun-legacy-migration__impact-list { + display: grid; + gap: var(--openbitfun-space-1); + margin: 0; + padding-inline-start: var(--openbitfun-space-5); +} diff --git a/src/web-ui/src/infrastructure/config/components/LegacyMigrationSettingsPage.tsx b/src/web-ui/src/infrastructure/config/components/LegacyMigrationSettingsPage.tsx new file mode 100644 index 0000000000..7f83e9b6cd --- /dev/null +++ b/src/web-ui/src/infrastructure/config/components/LegacyMigrationSettingsPage.tsx @@ -0,0 +1,443 @@ +import { Button, Checkbox, Dialog, DialogBody, DialogClose, DialogHeader, DialogHeading, DialogTitle, StatusPill, type StatusPillTone } from '@openbitfun/ui'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { confirmDialog } from '@/infrastructure/confirm-dialog'; +import { useI18n } from '@/infrastructure/i18n'; +import { + legacyMigrationAPI, + type LegacyMigrationScanView, + type LegacyMigrationStatusView, + type MigrationDomainId, + type MigrationDomainState, + type MigrationGroupId, + type MigrationRunReport, + type MigrationRunStatus, + type MigrationSelection, +} from '@/infrastructure/api/service-api/LegacyMigrationAPI'; +import { useNotification } from '@/shared/notification-system'; +import { createLogger } from '@/shared/utils/logger'; +import { + ConfigLoadingState, + ConfigMessage, + ConfigPageContent, + ConfigPageHeader, + ConfigPageLayout, + ConfigPageRow, + ConfigPageSection, + ConfigRetryState, +} from './common'; +import './LegacyMigrationSettingsPage.scss'; + +const log = createLogger('LegacyMigrationSettings'); + +const MIGRATION_GROUPS: readonly MigrationGroupId[] = [ + 'settings_and_credentials', + 'agents_skills_and_miniapps', + 'workspaces_sessions_and_tasks', + 'memory', + 'remote_connections_and_devices', +]; + +const DOMAIN_GROUPS: Partial> = { + settings: 'settings_and_credentials', + credentials: 'settings_and_credentials', + skills: 'agents_skills_and_miniapps', + miniapps: 'agents_skills_and_miniapps', + agents: 'agents_skills_and_miniapps', + workspace_sessions: 'workspaces_sessions_and_tasks', + agent_coordination: 'workspaces_sessions_and_tasks', + structured_memory: 'memory', + file_memory: 'memory', + remote_connect_devices: 'remote_connections_and_devices', + remote_ssh: 'remote_connections_and_devices', +}; + +function reportTone(status: MigrationRunStatus): StatusPillTone { + if (status === 'completed') return 'success'; + if (status === 'completed_with_warnings' || status === 'cancelled') return 'warning'; + if (status.startsWith('failed_')) return 'danger'; + return 'info'; +} + +function domainTone(state: MigrationDomainState): StatusPillTone { + if (state === 'verified') return 'success'; + if (state === 'failed') return 'danger'; + if (state === 'skipped') return 'warning'; + return 'neutral'; +} + +function errorMessage(error: unknown, fallback: string): string { + return error instanceof Error && error.message ? error.message : fallback; +} + +export default function LegacyMigrationSettingsPage() { + const { t, formatDate, formatNumber } = useI18n('settings/legacy-migration'); + const notification = useNotification(); + const [status, setStatus] = useState(null); + const [scan, setScan] = useState(null); + const [report, setReport] = useState(null); + const [reportOpen, setReportOpen] = useState(false); + const [selectedGroups, setSelectedGroups] = useState>( + () => new Set(MIGRATION_GROUPS), + ); + const [loading, setLoading] = useState(true); + const [loadFailed, setLoadFailed] = useState(false); + const [busy, setBusy] = useState<'scan' | 'prepare' | 'report' | 'preference' | null>(null); + + const loadStatus = useCallback(async () => { + setLoading(true); + setLoadFailed(false); + try { + const next = await legacyMigrationAPI.getStatus(); + setStatus(next); + setReport(next.latestReport); + } catch (error) { + log.error('Failed to load legacy migration status', error); + setLoadFailed(true); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void loadStatus(); + }, [loadStatus]); + + const selection = useMemo(() => ({ + groups: MIGRATION_GROUPS.filter((group) => selectedGroups.has(group)), + }), [selectedGroups]); + + const toggleGroup = useCallback((group: MigrationGroupId) => { + setSelectedGroups((current) => { + const next = new Set(current); + if (next.has(group)) next.delete(group); + else next.add(group); + return next; + }); + }, []); + + const handleScan = useCallback(async () => { + if (selection.groups.length === 0) { + notification.warning(t('messages.emptySelection')); + return; + } + setBusy('scan'); + try { + const next = await legacyMigrationAPI.scan(selection); + setScan(next); + setStatus((current) => current ? { + ...current, + source: next.source, + onboarding: { ...current.onboarding, lastScannedAtMs: next.scannedAtMs }, + } : current); + notification.success(t('messages.scanComplete')); + } catch (error) { + log.error('Failed to scan legacy migration source', error); + notification.error(errorMessage(error, t('messages.scanFailed'))); + } finally { + setBusy(null); + } + }, [notification, selection, t]); + + const prepareMigration = useCallback(async (nextSelection: MigrationSelection) => { + if (nextSelection.groups.length === 0) { + notification.warning(t('messages.emptySelection')); + return; + } + const confirmed = await confirmDialog({ + title: t('confirm.title'), + message: t('confirm.message'), + preview: ( +
    +
  • {t('confirm.agentImpact')}
  • +
  • {t('confirm.terminalImpact')}
  • +
  • {t('confirm.fileImpact')}
  • +
  • {t('confirm.unsavedImpact')}
  • +
  • {t('confirm.closeImpact')}
  • +
+ ), + confirmText: t('actions.startMigration'), + cancelText: t('actions.cancel'), + type: 'warning', + }); + if (!confirmed) return; + + setBusy('prepare'); + try { + await legacyMigrationAPI.prepare(nextSelection); + notification.info(t('messages.handoffStarted')); + } catch (error) { + log.error('Failed to prepare legacy migration handoff', error); + notification.error(errorMessage(error, t('messages.prepareFailed'))); + setBusy(null); + } + }, [notification, t]); + + const handleLoadReport = useCallback(async () => { + setBusy('report'); + try { + const next = await legacyMigrationAPI.getReport(); + setReport(next); + setReportOpen(next !== null); + if (!next) notification.info(t('messages.noReport')); + } catch (error) { + log.error('Failed to load legacy migration report', error); + notification.error(errorMessage(error, t('messages.reportFailed'))); + } finally { + setBusy(null); + } + }, [notification, t]); + + const retrySelection = useMemo(() => { + if (!report) return { groups: [] }; + const failed = new Set(); + let crossReferenceFailed = false; + for (const result of report.domainResults) { + if (result.state !== 'failed') continue; + const group = DOMAIN_GROUPS[result.domain]; + if (group) failed.add(group); + else crossReferenceFailed = true; + } + return { groups: crossReferenceFailed && failed.size === 0 ? [...MIGRATION_GROUPS] : [...failed] }; + }, [report]); + + const restoreReminder = useCallback(async () => { + setBusy('preference'); + try { + const onboarding = await legacyMigrationAPI.setPromptPreference('remind_later'); + setStatus((current) => current ? { ...current, onboarding } : current); + notification.success(t('messages.reminderRestored')); + } catch (error) { + log.error('Failed to restore legacy migration reminder', error); + notification.error(errorMessage(error, t('messages.preferenceFailed'))); + } finally { + setBusy(null); + } + }, [notification, t]); + + if (loading || loadFailed) { + return ( + + + + {loading ? ( + + ) : ( + void loadStatus()} + /> + )} + + + ); + } + + const source = status?.source ?? null; + const lastScannedAtMs = scan?.scannedAtMs ?? status?.onboarding.lastScannedAtMs ?? null; + + return ( + + + + + + + + + {source + ? t(source.supported ? 'source.supported' : 'source.unsupported') + : t('source.notDetected')} + + + + + {source + ? t('source.summary', { + product: source.productId, + version: source.productVersion || t('source.unknownVersion'), + platform: source.platform, + }) + : t('source.none')} + + + + + {lastScannedAtMs + ? formatDate(lastScannedAtMs, { dateStyle: 'medium', timeStyle: 'short' }) + : t('source.neverScanned')} + + + +
+ + + +
+
+ {status?.onboarding.choice === 'do_not_remind' ? ( + + + + ) : null} +
+ + + {MIGRATION_GROUPS.map((group) => ( + + toggleGroup(group)} + aria-label={t(`groups.${group}.label`)} + /> + + ))} + + + {scan ? ( + + {scan.findings.length === 0 ? ( + + ) : scan.findings.map((finding) => ( + + + {t(finding.migratable ? 'scan.migratable' : 'scan.blocked')} + + + ))} + + ) : null} + + + {report ? ( + <> + + {t(`statuses.${report.status}`)} + + {report.domainResults.map((result) => ( + + {t(`domainStates.${result.state}`)} + + ))} + {retrySelection.groups.length > 0 ? ( + + + + ) : null} + + ) : ( + + )} + +
+ + + {t('actions.viewReport')} + + + + {report ? <> +

{t(`statuses.${report.status}`)}

+ {report.domainResults.map((result) => ( +
+

{t(`domains.${result.domain}`)} — {t(`domainStates.${result.state}`)}

+

{t('report.domainCounts', { + imported: formatNumber(result.imported), skipped: formatNumber(result.skipped), + conflicts: formatNumber(result.conflicts), + })}

+
    {Array.from(new Map(result.warnings.map((item) => [item.code, item])).values()).map((item) => ( +
  • + {item.code === 'session_path_not_migrated' ? t('report.excludedPaths') + : item.code === 'session_parent_not_present' ? t('report.orphanedSessions') : item.message} + {' '}{t('report.occurrences', { count: result.warnings.filter((entry) => entry.code === item.code).length })} + {item.action ?

    {item.action}

    : null} +
  • + ))}
+
+ ))} + {report.diagnostics.map((item, index) =>

{item.message}

)} + {(report.requiresReauthentication ?? []).length > 0 ?

{t('report.reauthentication')}

: null} + {(report.requiresRelocation ?? []).length > 0 ?

{t('report.relocation')}

: null} + : null} +
+
+
+ ); +} diff --git a/src/web-ui/src/infrastructure/i18n/presets/namespaceRegistry.ts b/src/web-ui/src/infrastructure/i18n/presets/namespaceRegistry.ts index 01475580a8..b51e185115 100644 --- a/src/web-ui/src/infrastructure/i18n/presets/namespaceRegistry.ts +++ b/src/web-ui/src/infrastructure/i18n/presets/namespaceRegistry.ts @@ -32,6 +32,7 @@ export const ALL_NAMESPACES = [ 'settings/editor', 'settings/external-apps', 'settings/hooks', + 'settings/legacy-migration', 'settings/mcp', 'settings/mcp-tools', 'settings/memory', diff --git a/src/web-ui/src/locales/en-US/settings.json b/src/web-ui/src/locales/en-US/settings.json index 661d005665..424eb21ae1 100644 --- a/src/web-ui/src/locales/en-US/settings.json +++ b/src/web-ui/src/locales/en-US/settings.json @@ -31,6 +31,7 @@ "acp": { "label": "ACP Agents", "description": "Configure external agents that connect through Agent Client Protocol." }, "usage": { "label": "Call Statistics", "description": "Model requests, token usage, and cache hit rates." }, "archivedSessions": { "label": "Archived Sessions", "description": "View, restore, or permanently delete archived sessions." }, + "legacyMigration": { "label": "Data Migration", "description": "Import supported data from an older installation." }, "diagnostics": { "label": "Logs & Diagnostics", "description": "Log levels, runtime information, and troubleshooting." } }, "views": { diff --git a/src/web-ui/src/locales/en-US/settings/legacy-migration.json b/src/web-ui/src/locales/en-US/settings/legacy-migration.json new file mode 100644 index 0000000000..05255bea08 --- /dev/null +++ b/src/web-ui/src/locales/en-US/settings/legacy-migration.json @@ -0,0 +1,152 @@ +{ + "title": "Data Migration", + "subtitle": "Import supported data from an older BitFun installation without changing the source.", + "localOnlyNotice": "Migration is available only in OpenBitFun Desktop on the device that stores the older BitFun data. It cannot be started through Remote Control, Peer Device Mode, or Detached Dispatch.", + "sections": { + "source": { + "title": "Legacy source", + "description": "OpenBitFun performs a read-only check before the standalone Data Migrator is started." + }, + "scope": { + "title": "Migration scope", + "description": "Select high-level data groups. Session data always includes Agent coordination state." + }, + "scan": { + "title": "Latest scan", + "description": "Counts and sizes are summaries only; private content and full paths are not shown here." + }, + "report": { + "title": "Latest report", + "description": "The report shows redacted results for each owning domain. Older BitFun data is never deleted automatically." + } + }, + "fields": { + "detected": { "label": "Detection", "description": "Whether a supported local BitFun source is available." }, + "source": { "label": "Source", "description": "Product version and platform reported by the read-only probe." }, + "lastScan": { "label": "Last scanned", "description": "Time of the latest explicit read-only scan." }, + "actions": { "label": "Actions", "description": "Scan locally, start the standalone migrator, or refresh the report." }, + "reminder": { "label": "First-start reminder", "description": "Automatic migration prompts are currently disabled for this source." }, + "retryFailed": { "label": "Retry failed groups", "description": "Start a new standalone run containing only groups with failed domains." } + }, + "source": { + "supported": "Supported", + "unsupported": "Unsupported format", + "notDetected": "Not detected", + "summary": "{{product}} {{version}} on {{platform}}", + "unknownVersion": "unknown version", + "none": "No older BitFun data was detected on this device.", + "neverScanned": "Never scanned" + }, + "actions": { + "scan": "Scan old data", + "startMigration": "Start migration", + "viewReport": "View report", + "retryFailed": "Retry failed items", + "restoreReminder": "Restore reminder", + "retry": "Retry", + "cancel": "Cancel" + }, + "groups": { + "settings_and_credentials": { "label": "Settings and credentials", "description": "Supported preferences, model/service configuration, and secure credential references." }, + "agents_skills_and_miniapps": { "label": "Agents, Skills, and MiniApps", "description": "User Agents, non-system Skills, and non-built-in MiniApps." }, + "workspaces_sessions_and_tasks": { "label": "Workspaces, sessions, and Agent tasks", "description": "Workspace and session records together with required coordination state." }, + "memory": { "label": "Memory", "description": "Supported structured memory and user-authored memory files." }, + "remote_connections_and_devices": { "label": "Remote connections and devices", "description": "Supported Remote Connect device records and SSH profiles; secrets may require reauthentication." } + }, + "scan": { + "empty": "The selected groups contain no migratable records.", + "summary": "{{count}} records, {{bytes}} logical bytes", + "migratable": "Migratable", + "blocked": "Needs attention" + }, + "report": { + "excludedPaths": "Non-core session files were left in the legacy source as intended. No action is required.", + "orphanedSessions": "Some parent sessions are absent from the legacy source. Relationships and session history were preserved; no action is required.", + "occurrences": "{{count}} records", + "reauthentication": "Some connections require authentication again. Review connection settings.", + "relocation": "Some paths require relocation. Review workspace settings.", + "result": "Run result", + "none": "No migration report is available yet.", + "timeUnavailable": "Completion time unavailable", + "domainCounts": "{{imported}} imported, {{skipped}} skipped, {{conflicts}} conflicts" + }, + "statuses": { + "discovered": "Discovered", + "scanned": "Scanned", + "planned": "Planned", + "waiting_for_processes": "Waiting for applications", + "staging": "Staging", + "validating_stage": "Validating staged data", + "committing": "Committing", + "validating_commit": "Validating imported data", + "completed": "$t(shared:statuses.done)", + "completed_with_warnings": "Completed with warnings", + "cancelled": "$t(shared:statuses.cancelled)", + "failed_recoverable": "Failed; retry available", + "failed_manual_action_required": "Manual action required" + }, + "domainStates": { + "not_started": "Not started", + "staged": "Staged", + "committed": "Committed", + "verified": "Verified", + "failed": "$t(shared:statuses.failed)", + "skipped": "Skipped" + }, + "domains": { + "settings": "$t(shared:features.settings)", + "credentials": "Credentials", + "skills": "Skills", + "miniapps": "MiniApps", + "agents": "Agents", + "workspace_sessions": "Workspaces and sessions", + "agent_coordination": "Agent coordination", + "structured_memory": "Structured memory", + "file_memory": "Memory files", + "remote_connect_devices": "Remote Connect devices", + "remote_ssh": "Remote SSH", + "cross_reference_repair": "Cross-reference repair" + }, + "messages": { + "loading": "Loading migration status…", + "loadFailed": "Migration status could not be loaded on this device.", + "emptySelection": "Select at least one migration group.", + "scanComplete": "Read-only scan completed.", + "scanFailed": "The old data could not be scanned safely.", + "handoffStarted": "The Data Migrator is starting. OpenBitFun will close after the handoff.", + "prepareFailed": "The Data Migrator could not be started safely.", + "noReport": "No migration report is available yet.", + "reportFailed": "The migration report could not be loaded.", + "reminderRestored": "The first-start migration reminder was restored.", + "preferenceFailed": "The reminder preference could not be changed." + }, + "startupNotification": { + "title": "BitFun data migration", + "launchFailed": "OpenBitFun continued to start, but the Data Migrator could not be launched. Existing OpenBitFun and BitFun data were not changed.", + "openSettings": "Open migration settings", + "statuses": { + "discovered": "The Data Migrator discovered old data but did not finish a migration.", + "scanned": "The Data Migrator completed a read-only scan.", + "planned": "The Data Migrator saved a plan but did not import data.", + "waiting_for_processes": "Migration is still waiting for local applications to close.", + "staging": "Migration stopped while staging data and can be resumed safely.", + "validating_stage": "Migration stopped while validating staged data and can be resumed safely.", + "committing": "Migration stopped during a domain commit; review the report before retrying.", + "validating_commit": "Migration stopped while validating imported data; review the report.", + "completed": "Supported BitFun data was migrated successfully.", + "completed_with_warnings": "BitFun data migration completed with warnings.", + "cancelled": "BitFun data migration was cancelled at a safe boundary.", + "failed_recoverable": "BitFun data migration did not finish and can be retried.", + "failed_manual_action_required": "BitFun data migration needs manual action before it can continue." + } + }, + "confirm": { + "title": "Close OpenBitFun and start Data Migrator?", + "message": "The standalone Data Migrator needs exclusive access to local product data. Review the shutdown impact before continuing.", + "agentImpact": "Running Agent turns will stop.", + "terminalImpact": "Terminal tasks and other child processes may stop.", + "fileImpact": "Active file writes must finish before migration can proceed.", + "unsavedImpact": "Unsaved editor or settings changes may be lost.", + "closeImpact": "OpenBitFun will close, the migrator will confirm the final scope, and OpenBitFun will reopen when migration ends." + } +} diff --git a/src/web-ui/src/locales/zh-CN/settings.json b/src/web-ui/src/locales/zh-CN/settings.json index 6af3eb7f42..ec361d80b6 100644 --- a/src/web-ui/src/locales/zh-CN/settings.json +++ b/src/web-ui/src/locales/zh-CN/settings.json @@ -31,6 +31,7 @@ "acp": { "label": "ACP Agent", "description": "配置通过 Agent Client Protocol 接入的外部 Agent。" }, "usage": { "label": "调用统计", "description": "模型请求数、Token 用量与缓存命中率。" }, "archivedSessions": { "label": "已归档会话", "description": "查看、恢复或永久删除已归档的会话。" }, + "legacyMigration": { "label": "数据迁移", "description": "从受支持的旧版 安装导入数据。" }, "diagnostics": { "label": "日志与诊断", "description": "日志级别、运行信息与故障排查。" } }, "views": { diff --git a/src/web-ui/src/locales/zh-CN/settings/legacy-migration.json b/src/web-ui/src/locales/zh-CN/settings/legacy-migration.json new file mode 100644 index 0000000000..8d6feb9951 --- /dev/null +++ b/src/web-ui/src/locales/zh-CN/settings/legacy-migration.json @@ -0,0 +1,140 @@ +{ + "title": "数据迁移", + "subtitle": "从旧版 BitFun 安装导入受支持的数据,同时保持来源不变。", + "localOnlyNotice": "只能在保存旧 BitFun 数据的设备上,通过 OpenBitFun Desktop 启动迁移。Remote Control、Peer Device Mode 和 Detached Dispatch 均不能启动迁移。", + "sections": { + "source": { "title": "旧数据来源", "description": "启动独立 Data Migrator 前,OpenBitFun 会先执行只读检查。" }, + "scope": { "title": "迁移范围", "description": "选择高层数据组。会话数据始终包含 Agent 协调状态。" }, + "scan": { "title": "最近扫描", "description": "这里只显示数量和大小摘要,不显示私密正文或完整路径。" }, + "report": { "title": "最近报告", "description": "报告按所属领域显示去敏结果。旧 BitFun 数据永远不会被自动删除。" } + }, + "fields": { + "detected": { "label": "检测状态", "description": "本机是否存在受支持的 BitFun 数据来源。" }, + "source": { "label": "来源", "description": "只读探测得到的产品版本和平台。" }, + "lastScan": { "label": "上次扫描", "description": "最近一次显式只读扫描的时间。" }, + "actions": { "label": "操作", "description": "在本机扫描、启动独立迁移器或刷新报告。" }, + "reminder": { "label": "首次启动提醒", "description": "当前来源的自动迁移提示已关闭。" }, + "retryFailed": { "label": "重试失败数据组", "description": "仅使用包含失败领域的数据组启动新的独立迁移。" } + }, + "source": { + "supported": "支持迁移", + "unsupported": "格式不受支持", + "notDetected": "未检测到", + "summary": "{{platform}} 上的 {{product}} {{version}}", + "unknownVersion": "未知版本", + "none": "此设备上未检测到旧版 BitFun 数据。", + "neverScanned": "尚未扫描" + }, + "actions": { + "scan": "扫描旧数据", + "startMigration": "开始迁移", + "viewReport": "查看报告", + "retryFailed": "重试失败项", + "restoreReminder": "恢复提醒", + "retry": "重试", + "cancel": "取消" + }, + "groups": { + "settings_and_credentials": { "label": "设置和凭据", "description": "受支持的偏好、模型与服务配置,以及安全凭据引用。" }, + "agents_skills_and_miniapps": { "label": "Agents、Skills 和 MiniApps", "description": "用户 Agents、非系统 Skills 和非内置 MiniApps。" }, + "workspaces_sessions_and_tasks": { "label": "工作区、会话与 Agent 任务", "description": "工作区和会话记录,以及必需的协调状态。" }, + "memory": { "label": "Memory", "description": "受支持的结构化记忆和用户编写的记忆文件。" }, + "remote_connections_and_devices": { "label": "远程连接和设备", "description": "受支持的 Remote Connect 设备记录和 SSH 配置;秘密可能需要重新认证。" } + }, + "scan": { + "empty": "所选数据组中没有可迁移记录。", + "summary": "{{count}} 条记录,{{bytes}} 逻辑字节", + "migratable": "可迁移", + "blocked": "需要处理" + }, + "report": { + "excludedPaths": "按迁移范围保留在旧数据中的非核心会话文件,无需处理。", + "orphanedSessions": "部分会话的父会话在旧数据中不存在;已保留关系及会话历史,无需处理。", + "occurrences": "共 {{count}} 条记录", + "reauthentication": "部分连接需要重新认证,请到连接设置中处理。", + "relocation": "部分路径需要重新定位,请检查工作区设置。", + "result": "运行结果", + "none": "尚无迁移报告。", + "timeUnavailable": "完成时间不可用", + "domainCounts": "导入 {{imported}},跳过 {{skipped}},冲突 {{conflicts}}" + }, + "statuses": { + "discovered": "已发现", + "scanned": "已扫描", + "planned": "已计划", + "waiting_for_processes": "正在等待应用退出", + "staging": "正在暂存", + "validating_stage": "正在验证暂存数据", + "committing": "正在提交", + "validating_commit": "正在验证导入数据", + "completed": "$t(shared:statuses.done)", + "completed_with_warnings": "已完成,但有警告", + "cancelled": "$t(shared:statuses.cancelled)", + "failed_recoverable": "失败,可以重试", + "failed_manual_action_required": "需要手动处理" + }, + "domainStates": { + "not_started": "未开始", + "staged": "已暂存", + "committed": "已提交", + "verified": "已验证", + "failed": "$t(shared:statuses.failed)", + "skipped": "已跳过" + }, + "domains": { + "settings": "$t(shared:features.settings)", + "credentials": "凭据", + "skills": "Skills", + "miniapps": "MiniApps", + "agents": "Agents", + "workspace_sessions": "工作区和会话", + "agent_coordination": "Agent 协调", + "structured_memory": "结构化记忆", + "file_memory": "记忆文件", + "remote_connect_devices": "Remote Connect 设备", + "remote_ssh": "Remote SSH", + "cross_reference_repair": "交叉引用修复" + }, + "messages": { + "loading": "正在加载迁移状态…", + "loadFailed": "无法加载此设备上的迁移状态。", + "emptySelection": "请至少选择一个迁移数据组。", + "scanComplete": "只读扫描已完成。", + "scanFailed": "无法安全扫描旧数据。", + "handoffStarted": "Data Migrator 正在启动。交接完成后 OpenBitFun 将关闭。", + "prepareFailed": "无法安全启动 Data Migrator。", + "noReport": "尚无迁移报告。", + "reportFailed": "无法加载迁移报告。", + "reminderRestored": "已恢复首次启动迁移提醒。", + "preferenceFailed": "无法修改提醒偏好。" + }, + "startupNotification": { + "title": "BitFun 数据迁移", + "launchFailed": "OpenBitFun 已继续启动,但无法启动 Data Migrator。现有 OpenBitFun 和 BitFun 数据均未更改。", + "openSettings": "打开迁移设置", + "statuses": { + "discovered": "Data Migrator 发现了旧数据,但尚未完成迁移。", + "scanned": "Data Migrator 已完成只读扫描。", + "planned": "Data Migrator 已保存计划,但尚未导入数据。", + "waiting_for_processes": "迁移仍在等待本机应用退出。", + "staging": "迁移在暂存数据时停止,可以安全恢复。", + "validating_stage": "迁移在验证暂存数据时停止,可以安全恢复。", + "committing": "迁移在提交领域数据时停止;重试前请查看报告。", + "validating_commit": "迁移在验证导入数据时停止;请查看报告。", + "completed": "受支持的 BitFun 数据已成功迁移。", + "completed_with_warnings": "BitFun 数据迁移已完成,但有警告。", + "cancelled": "BitFun 数据迁移已在安全边界取消。", + "failed_recoverable": "BitFun 数据迁移未完成,可以重试。", + "failed_manual_action_required": "BitFun 数据迁移需要手动处理后才能继续。" + } + }, + "confirm": { + "title": "关闭 OpenBitFun 并启动 Data Migrator?", + "message": "独立 Data Migrator 需要独占访问本机产品数据。继续前请确认关闭影响。", + "agentImpact": "正在运行的 Agent 回合将停止。", + "terminalImpact": "终端任务和其他子进程可能会停止。", + "fileImpact": "迁移开始前,正在进行的文件写入必须完成。", + "unsavedImpact": "未保存的编辑器或设置更改可能会丢失。", + "closeImpact": "OpenBitFun 将关闭;迁移器会再次确认最终范围,并在迁移结束后重新打开 OpenBitFun。" + } +} diff --git a/src/web-ui/src/locales/zh-TW/settings.json b/src/web-ui/src/locales/zh-TW/settings.json index 17f45bf10f..d420864e33 100644 --- a/src/web-ui/src/locales/zh-TW/settings.json +++ b/src/web-ui/src/locales/zh-TW/settings.json @@ -31,6 +31,7 @@ "acp": { "label": "ACP Agent", "description": "設定透過 Agent Client Protocol 接入的外部 Agent。" }, "usage": { "label": "調用統計", "description": "模型請求數、Token 用量與快取命中率。" }, "archivedSessions": { "label": "已歸檔會話", "description": "檢視、恢復或永久刪除已歸檔的會話。" }, + "legacyMigration": { "label": "資料遷移", "description": "從受支援的舊版 產品安裝匯入資料。" }, "diagnostics": { "label": "日誌與診斷", "description": "日誌層級、執行資訊與故障排查。" } }, "views": { diff --git a/src/web-ui/src/locales/zh-TW/settings/legacy-migration.json b/src/web-ui/src/locales/zh-TW/settings/legacy-migration.json new file mode 100644 index 0000000000..6d22c83153 --- /dev/null +++ b/src/web-ui/src/locales/zh-TW/settings/legacy-migration.json @@ -0,0 +1,140 @@ +{ + "title": "資料遷移", + "subtitle": "從舊版 BitFun 安裝匯入受支援的資料,同時保持來源不變。", + "localOnlyNotice": "只能在儲存舊 BitFun 資料的裝置上,透過 OpenBitFun Desktop 啟動遷移。Remote Control、Peer Device Mode 和 Detached Dispatch 均不能啟動遷移。", + "sections": { + "source": { "title": "舊資料來源", "description": "啟動獨立 Data Migrator 前,OpenBitFun 會先執行唯讀檢查。" }, + "scope": { "title": "遷移範圍", "description": "選擇高層資料群組。會話資料始終包含 Agent 協調狀態。" }, + "scan": { "title": "最近掃描", "description": "這裡只顯示數量和大小摘要,不顯示私密正文或完整路徑。" }, + "report": { "title": "最近報告", "description": "報告按所屬領域顯示去敏結果。舊 BitFun 資料永遠不會被自動刪除。" } + }, + "fields": { + "detected": { "label": "偵測狀態", "description": "本機是否存在受支援的 BitFun 資料來源。" }, + "source": { "label": "來源", "description": "唯讀探測得到的產品版本和平台。" }, + "lastScan": { "label": "上次掃描", "description": "最近一次明確唯讀掃描的時間。" }, + "actions": { "label": "操作", "description": "在本機掃描、啟動獨立遷移器或重新整理報告。" }, + "reminder": { "label": "首次啟動提醒", "description": "目前來源的自動遷移提示已關閉。" }, + "retryFailed": { "label": "重試失敗資料群組", "description": "僅使用包含失敗領域的資料群組啟動新的獨立遷移。" } + }, + "source": { + "supported": "支援遷移", + "unsupported": "格式不受支援", + "notDetected": "未偵測到", + "summary": "{{platform}} 上的 {{product}} {{version}}", + "unknownVersion": "未知版本", + "none": "此裝置上未偵測到舊版 BitFun 資料。", + "neverScanned": "尚未掃描" + }, + "actions": { + "scan": "掃描舊資料", + "startMigration": "開始遷移", + "viewReport": "檢視報告", + "retryFailed": "重試失敗項", + "restoreReminder": "恢復提醒", + "retry": "重試", + "cancel": "取消" + }, + "groups": { + "settings_and_credentials": { "label": "設定和憑據", "description": "受支援的偏好、模型與服務設定,以及安全憑據引用。" }, + "agents_skills_and_miniapps": { "label": "Agents、Skills 和 MiniApps", "description": "使用者 Agents、非系統 Skills 和非內建 MiniApps。" }, + "workspaces_sessions_and_tasks": { "label": "工作區、會話與 Agent 任務", "description": "工作區和會話記錄,以及必需的協調狀態。" }, + "memory": { "label": "Memory", "description": "受支援的結構化記憶和使用者編寫的記憶檔案。" }, + "remote_connections_and_devices": { "label": "遠端連線和裝置", "description": "受支援的 Remote Connect 裝置記錄和 SSH 設定;秘密可能需要重新驗證。" } + }, + "scan": { + "empty": "所選資料群組中沒有可遷移記錄。", + "summary": "{{count}} 筆記錄,{{bytes}} 邏輯位元組", + "migratable": "可遷移", + "blocked": "需要處理" + }, + "report": { + "excludedPaths": "依遷移範圍保留在舊資料中的非核心會話檔案,無須處理。", + "orphanedSessions": "部分會話的父會話在舊資料中不存在;已保留關係及會話歷史,無須處理。", + "occurrences": "共 {{count}} 筆記錄", + "reauthentication": "部分連線需要重新認證,請至連線設定處理。", + "relocation": "部分路徑需要重新定位,請檢查工作區設定。", + "result": "執行結果", + "none": "尚無遷移報告。", + "timeUnavailable": "完成時間不可用", + "domainCounts": "匯入 {{imported}},跳過 {{skipped}},衝突 {{conflicts}}" + }, + "statuses": { + "discovered": "已發現", + "scanned": "已掃描", + "planned": "已規劃", + "waiting_for_processes": "正在等待應用程式退出", + "staging": "正在暫存", + "validating_stage": "正在驗證暫存資料", + "committing": "正在提交", + "validating_commit": "正在驗證匯入資料", + "completed": "$t(shared:statuses.done)", + "completed_with_warnings": "已完成,但有警告", + "cancelled": "$t(shared:statuses.cancelled)", + "failed_recoverable": "失敗,可以重試", + "failed_manual_action_required": "需要手動處理" + }, + "domainStates": { + "not_started": "未開始", + "staged": "已暫存", + "committed": "已提交", + "verified": "已驗證", + "failed": "$t(shared:statuses.failed)", + "skipped": "已跳過" + }, + "domains": { + "settings": "$t(shared:features.settings)", + "credentials": "憑據", + "skills": "Skills", + "miniapps": "MiniApps", + "agents": "Agents", + "workspace_sessions": "工作區和會話", + "agent_coordination": "Agent 協調", + "structured_memory": "結構化記憶", + "file_memory": "記憶檔案", + "remote_connect_devices": "Remote Connect 裝置", + "remote_ssh": "Remote SSH", + "cross_reference_repair": "交叉引用修復" + }, + "messages": { + "loading": "正在載入遷移狀態…", + "loadFailed": "無法載入此裝置上的遷移狀態。", + "emptySelection": "請至少選擇一個遷移資料群組。", + "scanComplete": "唯讀掃描已完成。", + "scanFailed": "無法安全掃描舊資料。", + "handoffStarted": "Data Migrator 正在啟動。交接完成後 OpenBitFun 將關閉。", + "prepareFailed": "無法安全啟動 Data Migrator。", + "noReport": "尚無遷移報告。", + "reportFailed": "無法載入遷移報告。", + "reminderRestored": "已恢復首次啟動遷移提醒。", + "preferenceFailed": "無法修改提醒偏好。" + }, + "startupNotification": { + "title": "BitFun 資料遷移", + "launchFailed": "OpenBitFun 已繼續啟動,但無法啟動 Data Migrator。現有 OpenBitFun 和 BitFun 資料均未變更。", + "openSettings": "開啟遷移設定", + "statuses": { + "discovered": "Data Migrator 發現了舊資料,但尚未完成遷移。", + "scanned": "Data Migrator 已完成唯讀掃描。", + "planned": "Data Migrator 已儲存計畫,但尚未匯入資料。", + "waiting_for_processes": "遷移仍在等待本機應用程式退出。", + "staging": "遷移在暫存資料時停止,可以安全恢復。", + "validating_stage": "遷移在驗證暫存資料時停止,可以安全恢復。", + "committing": "遷移在提交領域資料時停止;重試前請檢視報告。", + "validating_commit": "遷移在驗證匯入資料時停止;請檢視報告。", + "completed": "受支援的 BitFun 資料已成功遷移。", + "completed_with_warnings": "BitFun 資料遷移已完成,但有警告。", + "cancelled": "BitFun 資料遷移已在安全邊界取消。", + "failed_recoverable": "BitFun 資料遷移未完成,可以重試。", + "failed_manual_action_required": "BitFun 資料遷移需要手動處理後才能繼續。" + } + }, + "confirm": { + "title": "關閉 OpenBitFun 並啟動 Data Migrator?", + "message": "獨立 Data Migrator 需要獨占存取本機產品資料。繼續前請確認關閉影響。", + "agentImpact": "正在執行的 Agent 回合將停止。", + "terminalImpact": "終端任務和其他子程序可能會停止。", + "fileImpact": "遷移開始前,正在進行的檔案寫入必須完成。", + "unsavedImpact": "未儲存的編輯器或設定變更可能會遺失。", + "closeImpact": "OpenBitFun 將關閉;遷移器會再次確認最終範圍,並在遷移結束後重新開啟 OpenBitFun。" + } +}