agent: lumotia-rebrand — fix Codex cross-model review blockers
Some checks failed
check / cargo check (macos-latest) (push) Has been cancelled
check / cargo check (ubuntu-22.04) (push) Has been cancelled
check / cargo check (windows-latest) (push) Has been cancelled
check / svelte build + lint (push) Has been cancelled

Codex independent review found 11 blockers post-cascade. All addressed.

CRITICAL (data-loss / crash):

10. crates/core/src/paths.rs — migrate_legacy_data_dir_inner used
    fs::rename which fails with EXDEV when source + target are on
    different filesystems (encrypted-home, bind mounts, separate
    $XDG_DATA_HOME partition). Combined with the Phase 5 QC fix that
    made migration errors fatal, this would crash on first launch
    for any user whose data dir spans filesystems. Added
    rename_or_copy_tree() that falls back to copy_dir_recursive +
    remove_dir_all on CrossesDevices / errno 18 (EXDEV). Symlinks
    preserved verbatim. Same fallback applied to magnotia.db ->
    lumotia.db inside the dir.

11. Added 4 unit tests: copy_dir_recursive preserves nested
    structure, rename_or_copy_tree same-filesystem happy path,
    is_cross_device classifies CrossesDevices kind + raw errno 18.

Doc residuals (blockers 1-9):

1. crates/cloud-providers/Cargo.toml — "Wyrdnote pending rebrand"
   description.
2. crates/cloud-providers/src/provider.rs — module docs + test
   fixture Wyrdnote refs.
3. crates/transcription/src/orchestrator.rs — module docs + test
   fixture Wyrdnote refs.
4. docs/roadmap/2026-05-10-pkm-phase-tooling-shortlist.md — phase
   name + outputs/wyrdnote path refs.
5. docs/architecture-map/04-llm-formatting-mcp/llm-tests.md —
   MAGNOTIA_LLM_TEST_MODEL env var.
6. .../cloud-providers-stubs.md — MAGNOTIA_API_KEY_*.
7. docs/architecture-map/03-audio-transcription/tests-and-fixtures.md
   — MAGNOTIA_WHISPER_*.
8. docs/gpu-tuning/plan.md — MAGNOTIA_BENCH_RUN.
9. docs/superpowers/specs/2026-05-09-battery-gpu-aware-thread-tuning-
   design.md + corresponding plan — MAGNOTIA_POWER_STATE_OVERRIDE,
   MAGNOTIA_INFERENCE_THREADS.

cargo test --workspace: 343 pass / 0 fail (up from 339; +4 EXDEV
fallback tests).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-13 13:39:21 +01:00
parent f093d18a5e
commit 2491c7a7dd
11 changed files with 235 additions and 70 deletions

View File

@@ -2,7 +2,7 @@
name = "lumotia-cloud-providers"
version = "0.1.0"
edition = "2021"
description = "Provider trait and BYOK cloud STT scaffolding for Lumotia (Wyrdnote pending rebrand)"
description = "Provider trait and BYOK cloud STT scaffolding for Lumotia (Lumotia)"
[dependencies]
lumotia-core = { path = "../core" }

View File

@@ -6,7 +6,7 @@
//!
//! Living in `lumotia-cloud-providers` is deliberate: the AGPL OEM
//! exception (≥£2k/yr) requires a clean trait surface that an OEM
//! licensee can implement without depending on Wyrdnote's transcription
//! licensee can implement without depending on Lumotia's transcription
//! internals. The trait crate stays small; provider implementations
//! sit alongside.
//!
@@ -175,10 +175,10 @@ mod tests {
engine_id: ProviderId::new("local-whisper"),
model_id: None,
language: Some("en".to_string()),
initial_prompt: Some("Wyrdnote".to_string()),
initial_prompt: Some("Lumotia".to_string()),
};
let opts = profile.to_options();
assert_eq!(opts.language, Some("en".to_string()));
assert_eq!(opts.initial_prompt, Some("Wyrdnote".to_string()));
assert_eq!(opts.initial_prompt, Some("Lumotia".to_string()));
}
}

View File

@@ -221,7 +221,7 @@ fn migrate_legacy_data_dir_inner(
if let Some(parent) = to.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::rename(&from, &to)?;
rename_or_copy_tree(&from, &to)?;
let renamed_db = rename_db_file_if_present(&to)?;
@@ -232,16 +232,106 @@ fn migrate_legacy_data_dir_inner(
})
}
/// Move a directory tree, falling back to copy + remove if the
/// destination is on a different filesystem (EXDEV / CrossesDevices).
///
/// Real-world cases this defends against:
/// * `~/.magnotia` on the user's home partition, `~/.local/share/lumotia`
/// on a bind-mounted partition.
/// * Encrypted-home (`/home/.ecryptfs/`) vs decrypted view.
/// * `$XDG_DATA_HOME` set to a non-home device.
///
/// On a copy-then-remove fallback we tolerate partial cleanup failure
/// (the source dir not fully deleted) by surfacing the error from the
/// remove step only if the copy succeeded — losing data via a half-rolled
/// migration is worse than leaving a stale legacy dir behind.
fn rename_or_copy_tree(from: &Path, to: &Path) -> Result<(), std::io::Error> {
match std::fs::rename(from, to) {
Ok(()) => Ok(()),
Err(e) if is_cross_device(&e) => {
copy_dir_recursive(from, to)?;
// Only attempt removal after the copy fully succeeded.
// remove_dir_all is best-effort: if it leaves files behind
// (permission edge cases), the user can clean up the stale
// legacy dir manually.
std::fs::remove_dir_all(from)?;
Ok(())
}
Err(e) => Err(e),
}
}
fn is_cross_device(err: &std::io::Error) -> bool {
// ErrorKind::CrossesDevices is stable from Rust 1.85. Fall back to
// the raw OS error code (EXDEV = 18 on Linux, 17 on macOS, 17 on
// BSDs) for older toolchains.
if err.kind() == std::io::ErrorKind::CrossesDevices {
return true;
}
#[cfg(unix)]
{
return matches!(err.raw_os_error(), Some(18));
}
#[allow(unreachable_code)]
false
}
fn copy_dir_recursive(from: &Path, to: &Path) -> Result<(), std::io::Error> {
std::fs::create_dir_all(to)?;
for entry in std::fs::read_dir(from)? {
let entry = entry?;
let entry_path = entry.path();
let target_path = to.join(entry.file_name());
let metadata = entry.metadata()?;
if metadata.is_dir() {
copy_dir_recursive(&entry_path, &target_path)?;
} else if metadata.file_type().is_symlink() {
// Recreate symlink rather than dereferencing — the
// transcription app stores recording paths verbatim so a
// dereferenced symlink could orphan large audio blobs.
#[cfg(unix)]
{
let target = std::fs::read_link(&entry_path)?;
std::os::unix::fs::symlink(target, &target_path)?;
}
#[cfg(windows)]
{
let target = std::fs::read_link(&entry_path)?;
if metadata.is_dir() {
std::os::windows::fs::symlink_dir(target, &target_path)?;
} else {
std::os::windows::fs::symlink_file(target, &target_path)?;
}
}
} else {
std::fs::copy(&entry_path, &target_path)?;
}
}
Ok(())
}
fn rename_db_file_if_present(dir: &Path) -> Result<bool, std::io::Error> {
let legacy_db = dir.join("magnotia.db");
if !legacy_db.exists() {
return Ok(false);
}
let new_db = dir.join("lumotia.db");
std::fs::rename(&legacy_db, &new_db)?;
rename_or_copy_file(&legacy_db, &new_db)?;
Ok(true)
}
fn rename_or_copy_file(from: &Path, to: &Path) -> Result<(), std::io::Error> {
match std::fs::rename(from, to) {
Ok(()) => Ok(()),
Err(e) if is_cross_device(&e) => {
std::fs::copy(from, to)?;
std::fs::remove_file(from)?;
Ok(())
}
Err(e) => Err(e),
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -390,4 +480,79 @@ mod tests {
std::fs::remove_dir_all(&root).ok();
}
#[test]
fn copy_dir_recursive_preserves_nested_files_and_directories() {
// Regression for Codex Blocker 11: EXDEV fallback path uses
// copy_dir_recursive. Verify it preserves directory structure,
// file contents, and arbitrary depth.
let root = unique_tmp("copy-recursive");
let src = root.join("legacy");
let dst = root.join("new");
std::fs::create_dir_all(src.join("recordings/2026-05")).unwrap();
std::fs::create_dir_all(src.join("models/whisper-base-en")).unwrap();
std::fs::write(src.join("magnotia.db"), b"sqlite-bytes").unwrap();
std::fs::write(
src.join("recordings/2026-05/clip-001.wav"),
b"wav-bytes",
)
.unwrap();
std::fs::write(src.join("models/whisper-base-en/manifest.json"), b"{}").unwrap();
copy_dir_recursive(&src, &dst).expect("copy ok");
assert_eq!(std::fs::read(dst.join("magnotia.db")).unwrap(), b"sqlite-bytes");
assert_eq!(
std::fs::read(dst.join("recordings/2026-05/clip-001.wav")).unwrap(),
b"wav-bytes"
);
assert!(dst.join("models/whisper-base-en/manifest.json").exists());
// Source still present — copy_dir_recursive does not delete.
assert!(src.exists());
std::fs::remove_dir_all(&root).ok();
}
#[test]
fn rename_or_copy_tree_succeeds_on_same_filesystem() {
// Smoke for the happy path: same-filesystem case uses rename
// and leaves no source behind.
let root = unique_tmp("rename-tree");
let src = root.join("legacy");
let dst = root.join("new");
std::fs::create_dir_all(&src).unwrap();
std::fs::write(src.join("magnotia.db"), b"data").unwrap();
rename_or_copy_tree(&src, &dst).expect("rename ok");
assert!(!src.exists(), "source removed");
assert!(dst.exists(), "destination present");
assert_eq!(std::fs::read(dst.join("magnotia.db")).unwrap(), b"data");
std::fs::remove_dir_all(&root).ok();
}
#[test]
fn is_cross_device_classifies_exdev_error_kind() {
// Synthesise an io::Error tagged with CrossesDevices and ensure
// the classifier returns true. This is the path that triggers
// the copy-then-delete fallback in rename_or_copy_tree on Rust
// 1.85+ toolchains.
let err = std::io::Error::new(std::io::ErrorKind::CrossesDevices, "test exdev");
assert!(is_cross_device(&err));
let unrelated = std::io::Error::new(std::io::ErrorKind::NotFound, "test notfound");
assert!(!is_cross_device(&unrelated));
}
#[cfg(unix)]
#[test]
fn is_cross_device_classifies_raw_exdev_on_unix() {
// Belt-and-braces: ensure raw errno 18 (EXDEV on Linux) is
// classified as cross-device even if the ErrorKind doesn't
// map to CrossesDevices (older toolchains, future kernel
// surprises).
let err = std::io::Error::from_raw_os_error(18);
assert!(is_cross_device(&err));
}
}

View File

@@ -1,7 +1,7 @@
//! `Orchestrator` is the single entry point for transcription. Tauri
//! commands resolve a provider through it; nothing else calls a
//! provider directly. This is where the AGPL OEM trait boundary meets
//! Wyrdnote's local engines.
//! Lumotia's local engines.
//!
//! `LocalProviderAdapter` lives here, not as an `impl
//! TranscriptionProvider for LocalEngine`. The adapter wraps an
@@ -223,7 +223,7 @@ mod tests {
#[tokio::test]
async fn orchestrator_dispatches_to_named_provider() {
let calls = Arc::new(AtomicUsize::new(0));
let registry = build_registry_with("canned", "hello wyrdnote", calls.clone());
let registry = build_registry_with("canned", "hello lumotia", calls.clone());
let orchestrator = Orchestrator::new(registry);
let profile = EngineProfile::new(ProviderId::new("canned"));
@@ -232,7 +232,7 @@ mod tests {
.await
.expect("dispatch succeeds");
assert_eq!(result.transcript.text(), "hello wyrdnote");
assert_eq!(result.transcript.text(), "hello lumotia");
assert_eq!(result.inference_ms, 7);
assert_eq!(calls.load(Ordering::SeqCst), 1);
}
@@ -264,7 +264,7 @@ mod tests {
let mut profile = EngineProfile::new(ProviderId::new("canned"));
profile.language = Some("en".to_string());
profile.initial_prompt = Some("Wyrdnote".to_string());
profile.initial_prompt = Some("Lumotia".to_string());
let _ = orchestrator
.transcribe(one_second_of_silence(), &profile)