agent: lumotia — Phase A.7 fix startup-order race that silently orphaned legacy data

Critical bug surfaced by the dogfood drill: every upgrading Magnotia user
would silently keep a fresh empty Lumotia install while their Magnotia
data sat orphaned next to it. Drill caught it on the first real run
under sandboxed HOME.

ROOT CAUSE

src-tauri/src/lib.rs::run() previously called the migrations from inside
the Tauri setup hook (post `tauri::Builder::default()`). But three
sequential actions BEFORE the setup hook had already created the
destination directories:

  1. init_tracing() -> logs_dir() -> create_dir_all(app_data_dir/logs)
     creates the lumotia/ root.
  2. install_panic_hook() -> crashes_dir() -> create_dir_all() ditto.
  3. Tauri's WebKitGTK runtime / plugin chain creates the bundle-id-keyed
     consulting.corbel.lumotia/ dir eagerly when the WebContext spins up
     (mediakeys, storage, WebKitCache subdirs appeared even without our
     hook explicitly creating them).

By the time the setup-hook migrations fired, every legacy candidate
returned `TargetAlreadyExists` (paths.rs) or `BothExistLegacyPreserved`
(tauri_app_data_migration.rs) — both silent no-op codepaths. Legacy
data was left untouched, fresh Lumotia install gained no transcripts,
settings, or window state.

FIX

Migrate BEFORE any other code touches app_data_dir().

src-tauri/src/tauri_app_data_migration.rs:
  - NEW_BUNDLE_ID const ("consulting.corbel.lumotia"). MUST agree with
    tauri.conf.json#identifier; reviewer-enforced invariant.
  - Renamed private `legacy_tauri_app_data_dir_for` -> public
    `tauri_app_data_dir_for(identifier)`. Function is parameterised by
    bundle id; the "legacy" name was misleading after this change.
  - New `current_tauri_app_data_dir()` resolves the NEW bundle path
    from platform env vars (same convention Tauri 2 uses), so the
    pre-runtime migration can address its destination without
    needing an AppHandle.

src-tauri/src/lib.rs:
  - New `migrate_user_data_pre_runtime()` orchestrates the two
    migrations + ambiguity guard. Uses `eprintln!` for surface events
    (tracing not yet initialised at this stage; stderr lands in
    journald / foreground terminal which is the right transport for
    boot-phase output). FATAL errors call process::exit(1) — the
    setup-hook version returned Err from the closure, equivalent
    effect.
  - run() now calls migrate_user_data_pre_runtime() as its first line,
    BEFORE init_tracing(), install_panic_hook(), and the Tauri
    builder.
  - Setup-hook migration blocks deleted (~90 lines). Setup hook now
    starts with a one-line comment pointing at the pre-runtime fn.

VERIFICATION

Re-ran the dogfood drill (scripts/dogfood-rebrand-drill.sh) — 8/8 probes
pass after the fix (was 4/8). Both stderr lines fire:

  [lumotia-startup] migrated legacy magnotia data dir to lumotia:
      .../magnotia -> .../lumotia (renamed_db=true, elapsed_ms=0)
  [lumotia-startup] migrated Tauri app_data_dir from legacy bundle
      identifier: .../uk.co.corbel.magnotia ->
      .../consulting.corbel.lumotia (elapsed_ms=0)

On-disk post-state confirms: magnotia/ gone, lumotia/ has migrated db
+ recordings, uk.co.corbel.magnotia/ preserved as backup,
consulting.corbel.lumotia/localStorage/leveldb/ has migrated data.

- cargo fmt --check: clean
- cargo clippy --workspace --all-targets -- -D warnings: clean
- cargo test --workspace: 409/0 (no regression)
This commit is contained in:
2026-05-14 13:59:08 +01:00
parent 2aac366f32
commit ff8dda06d0
2 changed files with 168 additions and 116 deletions

View File

@@ -30,6 +30,14 @@ use lumotia_core::paths::copy_dir_recursive;
/// (commit `14313cf`).
pub const OLD_BUNDLE_ID: &str = "uk.co.corbel.magnotia";
/// Bundle identifier in use post-rebrand. MUST match the `identifier`
/// field in `src-tauri/tauri.conf.json` — if those two ever drift,
/// the pre-runtime migration would copy data into a path Tauri never
/// looks at. There is no compile-time check for this invariant;
/// reviewer's job to catch a tauri.conf.json edit that doesn't update
/// this const.
pub const NEW_BUNDLE_ID: &str = "consulting.corbel.lumotia";
/// Outcome of an `app_data_dir` migration attempt. Mostly informational
/// for the setup-hook tracing layer; the function never aborts startup.
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -47,9 +55,10 @@ pub enum AppDataMigrationStatus {
Migrated { old: PathBuf, new: PathBuf },
}
/// Resolve the OLD Tauri `app_data_dir` from platform conventions. The
/// AppHandle no longer knows the legacy identifier, so this is
/// hand-rolled per platform.
/// Resolve the OLD Tauri `app_data_dir` from platform conventions.
/// Used by the pre-runtime migration; the AppHandle isn't available
/// yet at that stage AND the AppHandle is keyed by the NEW identifier
/// anyway, so it couldn't surface the legacy path even if we had it.
///
/// Returns `None` when the environment variable required to anchor the
/// path is missing or empty — `HOME` on Unix, `APPDATA` on Windows. In
@@ -57,10 +66,28 @@ pub enum AppDataMigrationStatus {
/// fallback (the new path also depends on the same env vars and would
/// be equally unrooted).
pub fn legacy_tauri_app_data_dir() -> Option<PathBuf> {
legacy_tauri_app_data_dir_for(OLD_BUNDLE_ID)
tauri_app_data_dir_for(OLD_BUNDLE_ID)
}
fn legacy_tauri_app_data_dir_for(identifier: &str) -> Option<PathBuf> {
/// Resolve the CURRENT (post-rebrand) Tauri `app_data_dir` from
/// platform conventions. Used by the pre-runtime migration as the
/// destination for the copy — pre-runtime means we run before
/// `tauri::Builder::default()` so we can't call `app.path().app_data_dir()`.
///
/// CRITICAL: this resolver MUST agree with Tauri 2's own resolution at
/// runtime, otherwise the migration copies data into a path Tauri never
/// reads. Both sides use the same XDG / Library / APPDATA conventions
/// keyed by the bundle identifier, so they agree by construction as
/// long as `NEW_BUNDLE_ID` matches `tauri.conf.json#identifier`.
pub fn current_tauri_app_data_dir() -> Option<PathBuf> {
tauri_app_data_dir_for(NEW_BUNDLE_ID)
}
/// Platform-aware resolver shared by [`legacy_tauri_app_data_dir`] and
/// [`current_tauri_app_data_dir`]. Public so integration tests in
/// sibling crates can substitute an arbitrary identifier — production
/// callers should prefer the two wrappers above.
pub fn tauri_app_data_dir_for(identifier: &str) -> Option<PathBuf> {
// Exactly one of the four cfg blocks below is present per target
// compile. Each is a tail expression that becomes the function's
// return value. Avoiding explicit `return` keeps clippy's