agent: lumotia-rebrand — fix Codex cross-model review blockers
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:
@@ -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" }
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user