diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index f8f19f530..f6f0f4632 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -266,6 +266,224 @@ async fn start_core_process( state.inner().ensure_running().await } +/// Reset the user's local OpenHuman data and bounce the embedded core. +/// +/// Replaces the prior two-step UI flow that called the core JSON-RPC +/// `openhuman.config_reset_local_data` (in-process removal) followed by +/// `restart_core_process`. The in-process removal failed on Windows with +/// `ERROR_SHARING_VIOLATION` (os error 32) because the running core held +/// open handles to SQLite databases, log files, the Sentry session store, +/// etc. inside the directory it was being asked to delete — see +/// OPENHUMAN-TAURI-AF. +/// +/// New order: +/// +/// 1. Query the core for the **paths** it would remove (`config_get_data_paths`) +/// while the core is still up — these are derived from the loaded config +/// and the active workspace marker, so the core is authoritative. +/// 2. Acquire the restart lock so a concurrent `restart_core_process` cannot +/// interleave with the remove. +/// 3. Shut down the embedded core. `CoreProcessHandle::shutdown` cancels +/// the cancellation token and awaits the tokio task, which drops the +/// SQLite pool, log writer, etc. — releasing every Windows file handle. +/// 4. Remove the three paths (current data dir, default data dir, active +/// workspace marker) from this process. Missing entries are non-fatal. +/// 5. Restart the embedded core via `ensure_running`. +/// +/// Returns `Ok(())` only when the core is back up and the directories are +/// gone (or were already absent). Any step's `Err` short-circuits and +/// surfaces to the UI, which already renders the message as a toast. +#[tauri::command] +async fn reset_local_data( + state: tauri::State<'_, core_process::CoreProcessHandle>, +) -> Result<(), String> { + log::info!("[core] reset_local_data: command invoked from frontend"); + + // ── 1. Ask the core for the paths it would remove ──────────────────── + // + // The core is authoritative for path resolution (it owns config + // loading, the workspace marker, and the staging-vs-prod default-dir + // suffix). Resolve while the core is still up so we don't duplicate + // that logic here. + let paths = fetch_data_paths().await?; + log::info!( + "[core] reset_local_data: paths resolved current={} default={} marker={}", + paths.current_openhuman_dir.display(), + paths.default_openhuman_dir.display(), + paths.active_workspace_marker_path.display() + ); + + // ── 2. Acquire the restart lock ───────────────────────────────────── + // + // Prevents a concurrent `restart_core_process` from re-spawning the + // embedded server in the middle of the remove step. + let _guard = state.inner().restart_lock().await; + log::debug!("[core] reset_local_data: acquired restart lock"); + + // ── 3. Shut down the embedded core ────────────────────────────────── + // + // Drops the tokio task, which drops the SQLite pool, log writer, and + // every other RAII owner of a file handle inside the data directory. + // On Windows this is the load-bearing step for OPENHUMAN-TAURI-AF. + state.inner().shutdown().await; + log::info!("[core] reset_local_data: embedded core stopped"); + + // ── 4. Remove the paths ───────────────────────────────────────────── + // + // Missing entries are non-fatal: the user may already have manually + // cleared the dir, or the marker may not exist for fresh installs. + // + // Capture the first delete error (if any) instead of propagating with + // `?` — we must still restart the embedded core in step 5 so the app + // doesn't end up with the sidecar dead. The original delete error is + // surfaced after the restart attempt. + let delete_result: Result<(), String> = async { + remove_path_if_exists( + &paths.active_workspace_marker_path, + "active workspace marker", + ) + .await?; + remove_dir_if_exists(&paths.current_openhuman_dir, "current openhuman dir").await?; + if paths.default_openhuman_dir != paths.current_openhuman_dir { + remove_dir_if_exists(&paths.default_openhuman_dir, "default openhuman dir").await?; + } else { + log::debug!( + "[core] reset_local_data: default dir == current dir; already removed above" + ); + } + Ok(()) + } + .await; + if let Err(ref e) = delete_result { + log::warn!("[core] reset_local_data: delete step failed: {e}; will still restart core"); + } + + // ── 5. Restart the embedded core ──────────────────────────────────── + // + // Always attempt restart, even if delete failed — otherwise the user + // is left with a dead sidecar. If restart itself fails, prefer the + // original delete error (more actionable) over the restart error. + let restart_result = state.inner().ensure_running().await; + match (&delete_result, &restart_result) { + (Ok(()), Ok(())) => log::info!("[core] reset_local_data: embedded core back up"), + (Err(_), Ok(())) => log::warn!( + "[core] reset_local_data: core restarted but delete step failed; surfacing delete error" + ), + (Ok(()), Err(e)) => log::error!("[core] reset_local_data: core restart failed: {e}"), + (Err(_), Err(e)) => log::error!( + "[core] reset_local_data: both delete and restart failed; restart error: {e}" + ), + } + delete_result?; + restart_result?; + Ok(()) +} + +/// Resolved data paths returned by `config_get_data_paths`. +struct ResolvedDataPaths { + current_openhuman_dir: std::path::PathBuf, + default_openhuman_dir: std::path::PathBuf, + active_workspace_marker_path: std::path::PathBuf, +} + +/// Call the core's `config_get_data_paths` RPC and parse the response. +async fn fetch_data_paths() -> Result { + let url = crate::core_rpc::core_rpc_url_value(); + let body = serde_json::json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "openhuman.config_get_data_paths", + "params": {} + }); + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(10)) + .build() + .map_err(|e| format!("config_get_data_paths client build failed: {e}"))?; + let req = crate::core_rpc::apply_auth(client.post(&url))?; + let res = req + .json(&body) + .send() + .await + .map_err(|e| format!("config_get_data_paths request failed: {e}"))?; + if !res.status().is_success() { + return Err(format!("config_get_data_paths http {}", res.status())); + } + let envelope: serde_json::Value = res + .json() + .await + .map_err(|e| format!("config_get_data_paths decode failed: {e}"))?; + // JSON-RPC envelope wraps the `RpcOutcome` result twice: + // `{ "result": { "result": { ...paths... }, "logs": [...] } }`. + let inner = envelope + .pointer("/result/result") + .ok_or_else(|| "config_get_data_paths missing /result/result".to_string())?; + let current = inner + .get("current_openhuman_dir") + .and_then(|v| v.as_str()) + .ok_or_else(|| "config_get_data_paths missing current_openhuman_dir".to_string())?; + let default = inner + .get("default_openhuman_dir") + .and_then(|v| v.as_str()) + .ok_or_else(|| "config_get_data_paths missing default_openhuman_dir".to_string())?; + let marker = inner + .get("active_workspace_marker_path") + .and_then(|v| v.as_str()) + .ok_or_else(|| "config_get_data_paths missing active_workspace_marker_path".to_string())?; + Ok(ResolvedDataPaths { + current_openhuman_dir: std::path::PathBuf::from(current), + default_openhuman_dir: std::path::PathBuf::from(default), + active_workspace_marker_path: std::path::PathBuf::from(marker), + }) +} + +/// Remove a regular file if present. Missing → debug log + Ok. +async fn remove_path_if_exists(path: &std::path::Path, label: &str) -> Result<(), String> { + match tokio::fs::remove_file(path).await { + Ok(()) => { + log::info!( + "[core] reset_local_data: removed {label} at {}", + path.display() + ); + Ok(()) + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + log::debug!( + "[core] reset_local_data: {label} already absent at {}", + path.display() + ); + Ok(()) + } + Err(e) => Err(format!( + "Failed to remove {label} at {}: {e}", + path.display() + )), + } +} + +/// Remove a directory tree if present. Missing → debug log + Ok. +async fn remove_dir_if_exists(path: &std::path::Path, label: &str) -> Result<(), String> { + match tokio::fs::remove_dir_all(path).await { + Ok(()) => { + log::info!( + "[core] reset_local_data: removed {label} at {}", + path.display() + ); + Ok(()) + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + log::debug!( + "[core] reset_local_data: {label} already absent at {}", + path.display() + ); + Ok(()) + } + Err(e) => Err(format!( + "Failed to remove {label} at {}: {e}", + path.display() + )), + } +} + /// Cleanly exit the application. /// /// Called by the BootCheckGate "Quit" button when the core is unreachable and @@ -2487,6 +2705,7 @@ pub fn run() { install_app_update, restart_core_process, start_core_process, + reset_local_data, app_quit, restart_app, get_active_user_id, diff --git a/app/src/utils/__tests__/tauriCommands.test.ts b/app/src/utils/__tests__/tauriCommands.test.ts index a18130de8..31be1c428 100644 --- a/app/src/utils/__tests__/tauriCommands.test.ts +++ b/app/src/utils/__tests__/tauriCommands.test.ts @@ -1,11 +1,13 @@ import { invoke, isTauri } from '@tauri-apps/api/core'; -import { beforeEach, describe, expect, type Mock, test, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, type Mock, test, vi } from 'vitest'; import { callCoreRpc } from '../../services/coreRpcClient'; vi.mock('@tauri-apps/api/core', () => ({ invoke: vi.fn(), isTauri: vi.fn() })); vi.mock('../../services/coreRpcClient', () => ({ callCoreRpc: vi.fn() })); +type TauriInternalsHolder = { __TAURI_INTERNALS__?: { invoke: unknown } }; + describe('tauriCommands', () => { const mockIsTauri = isTauri as Mock; const mockInvoke = invoke as Mock; @@ -15,10 +17,20 @@ describe('tauriCommands', () => { let storeSession: typeof import('../tauriCommands').storeSession; let openhumanLocalAiStatus: typeof import('../tauriCommands').openhumanLocalAiStatus; let openhumanServiceStatus: typeof import('../tauriCommands').openhumanServiceStatus; + let prevInternals: TauriInternalsHolder['__TAURI_INTERNALS__']; beforeEach(async () => { vi.clearAllMocks(); mockIsTauri.mockReturnValue(true); + // The local `isTauri()` wrapper in `tauriCommands/common.ts` ALSO checks + // `window.__TAURI_INTERNALS__.invoke` to detect the CEF bootstrap gap + // (see OPENHUMAN-REACT-S). Mocking only the upstream `coreIsTauri` + // isn't enough — the wrapper would still return false in tests and + // every helper would hit its `if (!isTauri()) return;` early-exit. + // Stub a minimal internals shape so the wrapper resolves to true. + const holder = window as unknown as TauriInternalsHolder; + prevInternals = holder.__TAURI_INTERNALS__; + holder.__TAURI_INTERNALS__ = { invoke: () => undefined }; const actual = await vi.importActual('../tauriCommands'); getAuthState = actual.getAuthState; resetOpenHumanDataAndRestartCore = actual.resetOpenHumanDataAndRestartCore; @@ -27,6 +39,15 @@ describe('tauriCommands', () => { openhumanServiceStatus = actual.openhumanServiceStatus; }); + afterEach(() => { + const holder = window as unknown as TauriInternalsHolder; + if (prevInternals === undefined) { + delete holder.__TAURI_INTERNALS__; + } else { + holder.__TAURI_INTERNALS__ = prevInternals; + } + }); + test('getAuthState maps result shape from core response', async () => { mockCallCoreRpc.mockResolvedValueOnce({ result: { isAuthenticated: true, user: { id: 'u1' } }, @@ -50,8 +71,30 @@ describe('tauriCommands', () => { test('resetOpenHumanDataAndRestartCore invokes the destructive Tauri command', async () => { await resetOpenHumanDataAndRestartCore(); - expect(mockCallCoreRpc).toHaveBeenCalledWith({ method: 'openhuman.config_reset_local_data' }); - expect(mockInvoke).toHaveBeenCalledWith('restart_core_process'); + // The helper used to call `openhuman.config_reset_local_data` over + // JSON-RPC followed by `restart_core_process`, but the in-process + // remove failed on Windows when the running core held open handles + // inside the data directory (OPENHUMAN-TAURI-AF). The Tauri shell + // now owns the full sequence (stop core → remove paths → restart + // core) behind a single `reset_local_data` command, so no core RPC + // call should reach `callCoreRpc` from this helper. + expect(mockCallCoreRpc).not.toHaveBeenCalled(); + expect(mockInvoke).toHaveBeenCalledWith('reset_local_data'); + }); + + test('resetOpenHumanDataAndRestartCore surfaces invoke failures to the caller', async () => { + // Callers (e.g. `clearAllAppData`) treat a thrown error as unrecoverable + // and abort the flow — so the helper must rethrow instead of swallowing + // a `reset_local_data` failure (e.g. Windows `ERROR_SHARING_VIOLATION` + // when a handle outside the embedded core still holds a path). + const boom = new Error('reset_local_data failed'); + mockInvoke.mockRejectedValueOnce(boom); + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + await expect(resetOpenHumanDataAndRestartCore()).rejects.toBe(boom); + expect(consoleErrorSpy).toHaveBeenCalled(); + + consoleErrorSpy.mockRestore(); }); test('openhumanLocalAiStatus returns upgrade hint on unknown method', async () => { diff --git a/app/src/utils/tauriCommands/core.ts b/app/src/utils/tauriCommands/core.ts index a4b196d6b..6571dbee2 100644 --- a/app/src/utils/tauriCommands/core.ts +++ b/app/src/utils/tauriCommands/core.ts @@ -261,14 +261,21 @@ export async function resetOpenHumanDataAndRestartCore(): Promise { console.debug('[core] resetOpenHumanDataAndRestartCore: skipped — not running in Tauri'); return; } - console.debug( - '[core] resetOpenHumanDataAndRestartCore: invoking openhuman.config_reset_local_data' - ); - await callCoreRpc({ method: 'openhuman.config_reset_local_data' }); - console.debug( - '[core] resetOpenHumanDataAndRestartCore: local data reset complete, restarting core' - ); - await restartCoreProcess(); + // Single Tauri command: the shell stops the embedded core (dropping + // every open file handle inside the data directory), removes the + // resolved data paths, then restarts the core. Previously this was a + // two-step `callCoreRpc('config_reset_local_data') + restartCoreProcess()` + // dance, but the core RPC ran the remove *inside* the running core's + // tokio task — on Windows that hit `ERROR_SHARING_VIOLATION` (os error + // 32) because the core still held SQLite / log / Sentry handles open in + // the directory it was trying to delete (OPENHUMAN-TAURI-AF). + console.debug('[core] resetOpenHumanDataAndRestartCore: invoking reset_local_data'); + try { + await invoke('reset_local_data'); + } catch (err) { + console.error('[core] resetOpenHumanDataAndRestartCore: reset_local_data failed', err); + throw err; + } console.debug('[core] resetOpenHumanDataAndRestartCore: done'); } diff --git a/src/openhuman/config/ops.rs b/src/openhuman/config/ops.rs index a01fb4858..e7575c373 100644 --- a/src/openhuman/config/ops.rs +++ b/src/openhuman/config/ops.rs @@ -1088,6 +1088,19 @@ pub fn agent_server_status() -> RpcOutcome { } /// Deletes all local data directories and workspace markers. +/// +/// Runs **inside the core's tokio task**, which means the running core +/// holds open handles to SQLite databases, log files, the Sentry session +/// store, etc. On Windows, `remove_dir_all` therefore fails with +/// `ERROR_SHARING_VIOLATION` (os error 32) — see OPENHUMAN-TAURI-AF. +/// +/// GUI callers must use the Tauri-side `reset_local_data` command instead: +/// it stops the embedded core via `CoreProcessHandle::shutdown` (dropping +/// the file handles), removes the directories from the Tauri host process, +/// and restarts the core. This JSON-RPC method is kept for headless / CLI +/// callers where in-process removal is acceptable (POSIX file semantics +/// tolerate unlinking open files; on Windows the CLI invocation runs +/// without the core attached, so no handle is in the way). pub async fn reset_local_data() -> Result, String> { let config = load_config_with_timeout().await?; let current_openhuman_dir = config_openhuman_dir(&config); @@ -1095,6 +1108,35 @@ pub async fn reset_local_data() -> Result, String> reset_local_data_for_paths(¤t_openhuman_dir, &default_openhuman_dir).await } +/// Reports the resolved paths that `reset_local_data` would remove, without +/// performing any filesystem changes. +/// +/// Lets the Tauri-side `reset_local_data` command discover the active +/// workspace dir, the default `~/.openhuman` dir (which can differ when +/// `OPENHUMAN_WORKSPACE` is set or a staging build is in use), and the +/// active workspace marker file **before** the core sidecar is shut down — +/// after which the Tauri shell removes them while no process holds open +/// handles. See OPENHUMAN-TAURI-AF for the Windows file-locking failure +/// that motivated the split. +pub async fn get_data_paths() -> Result, String> { + let config = load_config_with_timeout().await?; + let current_openhuman_dir = config_openhuman_dir(&config); + let default_openhuman_dir = default_openhuman_dir(); + let active_workspace_marker = active_workspace_marker_path(&default_openhuman_dir); + Ok(RpcOutcome::new( + json!({ + "current_openhuman_dir": current_openhuman_dir.display().to_string(), + "default_openhuman_dir": default_openhuman_dir.display().to_string(), + "active_workspace_marker_path": active_workspace_marker.display().to_string(), + }), + vec![format!( + "data paths resolved (current={}, default={})", + current_openhuman_dir.display(), + default_openhuman_dir.display() + )], + )) +} + #[cfg(test)] #[path = "ops_tests.rs"] mod tests; diff --git a/src/openhuman/config/schemas.rs b/src/openhuman/config/schemas.rs index 85a5266a7..2e6683bb9 100644 --- a/src/openhuman/config/schemas.rs +++ b/src/openhuman/config/schemas.rs @@ -196,6 +196,7 @@ pub fn all_controller_schemas() -> Vec { schemas("get_meet_settings"), schemas("agent_server_status"), schemas("reset_local_data"), + schemas("get_data_paths"), schemas("get_onboarding_completed"), schemas("set_onboarding_completed"), schemas("get_dictation_settings"), @@ -285,6 +286,10 @@ pub fn all_registered_controllers() -> Vec { schema: schemas("reset_local_data"), handler: handle_reset_local_data, }, + RegisteredController { + schema: schemas("get_data_paths"), + handler: handle_get_data_paths, + }, RegisteredController { schema: schemas("get_onboarding_completed"), handler: handle_get_onboarding_completed, @@ -689,6 +694,17 @@ pub fn schemas(function: &str) -> ControllerSchema { inputs: vec![], outputs: vec![json_output("result", "Reset result with removed paths.")], }, + "get_data_paths" => ControllerSchema { + namespace: "config", + function: "get_data_paths", + description: + "Resolve the OpenHuman data directories (current workspace, default ~/.openhuman, active workspace marker) that reset_local_data would remove. Read-only — performs no filesystem changes.", + inputs: vec![], + outputs: vec![json_output( + "paths", + "Resolved data paths: current_openhuman_dir, default_openhuman_dir, active_workspace_marker_path.", + )], + }, "get_onboarding_completed" => ControllerSchema { namespace: "config", function: "get_onboarding_completed", @@ -1184,6 +1200,22 @@ fn handle_reset_local_data(_params: Map) -> ControllerFuture { Box::pin(async { to_json(config_rpc::reset_local_data().await?) }) } +fn handle_get_data_paths(_params: Map) -> ControllerFuture { + Box::pin(async { + log::debug!("[config][rpc] get_data_paths enter"); + match config_rpc::get_data_paths().await { + Ok(outcome) => { + log::debug!("[config][rpc] get_data_paths ok"); + to_json(outcome) + } + Err(err) => { + log::warn!("[config][rpc] get_data_paths fail: {err}"); + Err(err) + } + } + }) +} + fn handle_get_onboarding_completed(_params: Map) -> ControllerFuture { Box::pin(async { to_json(config_rpc::get_onboarding_completed().await?) }) }